···27 },
28 "manifestDigest": {
29 "type": "string",
30- "description": "DEPRECATED: Digest of the manifest (e.g., 'sha256:...'). Kept for backward compatibility with old records. New records should use 'manifest' field instead."
031 },
32 "createdAt": {
33 "type": "string",
···27 },
28 "manifestDigest": {
29 "type": "string",
30+ "description": "DEPRECATED: Digest of the manifest (e.g., 'sha256:...'). Kept for backward compatibility with old records. New records should use 'manifest' field instead.",
31+ "maxLength": 128
32 },
33 "createdAt": {
34 "type": "string",
-25
pkg/appview/db/queries.go
···1634 return time.Time{}, fmt.Errorf("unable to parse timestamp: %s", s)
1635}
16361637-// MetricsDB wraps a sql.DB and implements the metrics interface for middleware
1638-type MetricsDB struct {
1639- db *sql.DB
1640-}
1641-1642-// NewMetricsDB creates a new metrics database wrapper
1643-func NewMetricsDB(db *sql.DB) *MetricsDB {
1644- return &MetricsDB{db: db}
1645-}
1646-1647-// IncrementPullCount increments the pull count for a repository
1648-func (m *MetricsDB) IncrementPullCount(did, repository string) error {
1649- return IncrementPullCount(m.db, did, repository)
1650-}
1651-1652-// IncrementPushCount increments the push count for a repository
1653-func (m *MetricsDB) IncrementPushCount(did, repository string) error {
1654- return IncrementPushCount(m.db, did, repository)
1655-}
1656-1657-// GetLatestHoldDIDForRepo returns the hold DID from the most recent manifest for a repository
1658-func (m *MetricsDB) GetLatestHoldDIDForRepo(did, repository string) (string, error) {
1659- return GetLatestHoldDIDForRepo(m.db, did, repository)
1660-}
1661-1662// GetFeaturedRepositories fetches top repositories sorted by stars and pulls
1663func GetFeaturedRepositories(db *sql.DB, limit int, currentUserDID string) ([]FeaturedRepository, error) {
1664 query := `
···1634 return time.Time{}, fmt.Errorf("unable to parse timestamp: %s", s)
1635}
163600000000000000000000000001637// GetFeaturedRepositories fetches top repositories sorted by stars and pulls
1638func GetFeaturedRepositories(db *sql.DB, limit int, currentUserDID string) ([]FeaturedRepository, error) {
1639 query := `
+59-6
pkg/appview/middleware/auth.go
···11 "net/url"
1213 "atcr.io/pkg/appview/db"
0014)
1516type contextKey string
1718const userKey contextKey = "user"
190000000020// RequireAuth is middleware that requires authentication
21func RequireAuth(store *db.SessionStore, database *sql.DB) func(http.Handler) http.Handler {
0000000022 return func(next http.Handler) http.Handler {
23 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
24 sessionID, ok := getSessionID(r)
···32 return
33 }
3435- sess, ok := store.Get(sessionID)
36 if !ok {
37 // Build return URL with query parameters preserved
38 returnTo := r.URL.Path
···44 }
4546 // Look up full user from database to get avatar
47- user, err := db.GetUserByDID(database, sess.DID)
48 if err != nil || user == nil {
49 // Fallback to session data if DB lookup fails
50 user = &db.User{
···54 }
55 }
5657- ctx := context.WithValue(r.Context(), userKey, user)
000000000000058 next.ServeHTTP(w, r.WithContext(ctx))
59 })
60 }
···6263// OptionalAuth is middleware that optionally includes user if authenticated
64func OptionalAuth(store *db.SessionStore, database *sql.DB) func(http.Handler) http.Handler {
0000000065 return func(next http.Handler) http.Handler {
66 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
67 sessionID, ok := getSessionID(r)
68 if ok {
69- if sess, ok := store.Get(sessionID); ok {
70 // Look up full user from database to get avatar
71- user, err := db.GetUserByDID(database, sess.DID)
72 if err != nil || user == nil {
73 // Fallback to session data if DB lookup fails
74 user = &db.User{
···77 PDSEndpoint: sess.PDSEndpoint,
78 }
79 }
80- ctx := context.WithValue(r.Context(), userKey, user)
0000000000000081 r = r.WithContext(ctx)
82 }
83 }
···11 "net/url"
1213 "atcr.io/pkg/appview/db"
14+ "atcr.io/pkg/auth"
15+ "atcr.io/pkg/auth/oauth"
16)
1718type contextKey string
1920const userKey contextKey = "user"
2122+// WebAuthDeps contains dependencies for web auth middleware
23+type WebAuthDeps struct {
24+ SessionStore *db.SessionStore
25+ Database *sql.DB
26+ Refresher *oauth.Refresher
27+ DefaultHoldDID string
28+}
29+30// RequireAuth is middleware that requires authentication
31func RequireAuth(store *db.SessionStore, database *sql.DB) func(http.Handler) http.Handler {
32+ return RequireAuthWithDeps(WebAuthDeps{
33+ SessionStore: store,
34+ Database: database,
35+ })
36+}
37+38+// RequireAuthWithDeps is middleware that requires authentication and creates UserContext
39+func RequireAuthWithDeps(deps WebAuthDeps) func(http.Handler) http.Handler {
40 return func(next http.Handler) http.Handler {
41 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
42 sessionID, ok := getSessionID(r)
···50 return
51 }
5253+ sess, ok := deps.SessionStore.Get(sessionID)
54 if !ok {
55 // Build return URL with query parameters preserved
56 returnTo := r.URL.Path
···62 }
6364 // Look up full user from database to get avatar
65+ user, err := db.GetUserByDID(deps.Database, sess.DID)
66 if err != nil || user == nil {
67 // Fallback to session data if DB lookup fails
68 user = &db.User{
···72 }
73 }
7475+ ctx := r.Context()
76+ ctx = context.WithValue(ctx, userKey, user)
77+78+ // Create UserContext for authenticated users (enables EnsureUserSetup)
79+ if deps.Refresher != nil {
80+ userCtx := auth.NewUserContext(sess.DID, auth.AuthMethodOAuth, r.Method, &auth.Dependencies{
81+ Refresher: deps.Refresher,
82+ DefaultHoldDID: deps.DefaultHoldDID,
83+ })
84+ userCtx.SetPDS(sess.Handle, sess.PDSEndpoint)
85+ userCtx.EnsureUserSetup()
86+ ctx = auth.WithUserContext(ctx, userCtx)
87+ }
88+89 next.ServeHTTP(w, r.WithContext(ctx))
90 })
91 }
···9394// OptionalAuth is middleware that optionally includes user if authenticated
95func OptionalAuth(store *db.SessionStore, database *sql.DB) func(http.Handler) http.Handler {
96+ return OptionalAuthWithDeps(WebAuthDeps{
97+ SessionStore: store,
98+ Database: database,
99+ })
100+}
101+102+// OptionalAuthWithDeps is middleware that optionally includes user and UserContext if authenticated
103+func OptionalAuthWithDeps(deps WebAuthDeps) func(http.Handler) http.Handler {
104 return func(next http.Handler) http.Handler {
105 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
106 sessionID, ok := getSessionID(r)
107 if ok {
108+ if sess, ok := deps.SessionStore.Get(sessionID); ok {
109 // Look up full user from database to get avatar
110+ user, err := db.GetUserByDID(deps.Database, sess.DID)
111 if err != nil || user == nil {
112 // Fallback to session data if DB lookup fails
113 user = &db.User{
···116 PDSEndpoint: sess.PDSEndpoint,
117 }
118 }
119+120+ ctx := r.Context()
121+ ctx = context.WithValue(ctx, userKey, user)
122+123+ // Create UserContext for authenticated users (enables EnsureUserSetup)
124+ if deps.Refresher != nil {
125+ userCtx := auth.NewUserContext(sess.DID, auth.AuthMethodOAuth, r.Method, &auth.Dependencies{
126+ Refresher: deps.Refresher,
127+ DefaultHoldDID: deps.DefaultHoldDID,
128+ })
129+ userCtx.SetPDS(sess.Handle, sess.PDSEndpoint)
130+ userCtx.EnsureUserSetup()
131+ ctx = auth.WithUserContext(ctx, userCtx)
132+ }
133+134 r = r.WithContext(ctx)
135 }
136 }
+102-322
pkg/appview/middleware/registry.go
···23import (
4 "context"
05 "fmt"
6 "log/slog"
7 "net/http"
8 "strings"
9- "sync"
10- "time"
1112 "github.com/distribution/distribution/v3"
13- "github.com/distribution/distribution/v3/registry/api/errcode"
14 registrymw "github.com/distribution/distribution/v3/registry/middleware/registry"
15 "github.com/distribution/distribution/v3/registry/storage/driver"
16 "github.com/distribution/reference"
1718- "atcr.io/pkg/appview/readme"
19 "atcr.io/pkg/appview/storage"
20 "atcr.io/pkg/atproto"
21 "atcr.io/pkg/auth"
···29// authMethodKey is the context key for storing auth method from JWT
30const authMethodKey contextKey = "auth.method"
3132-// validationCacheEntry stores a validated service token with expiration
33-type validationCacheEntry struct {
34- serviceToken string
35- validUntil time.Time
36- err error // Cached error for fast-fail
37- mu sync.Mutex // Per-entry lock to serialize cache population
38- inFlight bool // True if another goroutine is fetching the token
39- done chan struct{} // Closed when fetch completes
40-}
41-42-// validationCache provides request-level caching for service tokens
43-// This prevents concurrent layer uploads from racing on OAuth/DPoP requests
44-type validationCache struct {
45- mu sync.RWMutex
46- entries map[string]*validationCacheEntry // key: "did:holdDID"
47-}
48-49-// newValidationCache creates a new validation cache
50-func newValidationCache() *validationCache {
51- return &validationCache{
52- entries: make(map[string]*validationCacheEntry),
53- }
54-}
55-56-// getOrFetch retrieves a service token from cache or fetches it
57-// Multiple concurrent requests for the same DID:holdDID will share the fetch operation
58-func (vc *validationCache) getOrFetch(ctx context.Context, cacheKey string, fetchFunc func() (string, error)) (string, error) {
59- // Fast path: check cache with read lock
60- vc.mu.RLock()
61- entry, exists := vc.entries[cacheKey]
62- vc.mu.RUnlock()
63-64- if exists {
65- // Entry exists, check if it's still valid
66- entry.mu.Lock()
67-68- // If another goroutine is fetching, wait for it
69- if entry.inFlight {
70- done := entry.done
71- entry.mu.Unlock()
72-73- select {
74- case <-done:
75- // Fetch completed, check result
76- entry.mu.Lock()
77- defer entry.mu.Unlock()
78-79- if entry.err != nil {
80- return "", entry.err
81- }
82- if time.Now().Before(entry.validUntil) {
83- return entry.serviceToken, nil
84- }
85- // Fall through to refetch
86- case <-ctx.Done():
87- return "", ctx.Err()
88- }
89- } else {
90- // Check if cached token is still valid
91- if entry.err != nil && time.Now().Before(entry.validUntil) {
92- // Return cached error (fast-fail)
93- entry.mu.Unlock()
94- return "", entry.err
95- }
96- if entry.err == nil && time.Now().Before(entry.validUntil) {
97- // Return cached token
98- token := entry.serviceToken
99- entry.mu.Unlock()
100- return token, nil
101- }
102- entry.mu.Unlock()
103- }
104- }
105-106- // Slow path: need to fetch token
107- vc.mu.Lock()
108- entry, exists = vc.entries[cacheKey]
109- if !exists {
110- // Create new entry
111- entry = &validationCacheEntry{
112- inFlight: true,
113- done: make(chan struct{}),
114- }
115- vc.entries[cacheKey] = entry
116- }
117- vc.mu.Unlock()
118-119- // Lock the entry to perform fetch
120- entry.mu.Lock()
121-122- // Double-check: another goroutine may have fetched while we waited
123- if !entry.inFlight {
124- if entry.err != nil && time.Now().Before(entry.validUntil) {
125- err := entry.err
126- entry.mu.Unlock()
127- return "", err
128- }
129- if entry.err == nil && time.Now().Before(entry.validUntil) {
130- token := entry.serviceToken
131- entry.mu.Unlock()
132- return token, nil
133- }
134- }
135-136- // Mark as in-flight and create fresh done channel for this fetch
137- // IMPORTANT: Always create a new channel - a closed channel is not nil
138- entry.done = make(chan struct{})
139- entry.inFlight = true
140- done := entry.done
141- entry.mu.Unlock()
142-143- // Perform the fetch (outside the lock to allow other operations)
144- serviceToken, err := fetchFunc()
145-146- // Update the entry with result
147- entry.mu.Lock()
148- entry.inFlight = false
149-150- if err != nil {
151- // Cache errors for 5 seconds (fast-fail for subsequent requests)
152- entry.err = err
153- entry.validUntil = time.Now().Add(5 * time.Second)
154- entry.serviceToken = ""
155- } else {
156- // Cache token for 45 seconds (covers typical Docker push operation)
157- entry.err = nil
158- entry.serviceToken = serviceToken
159- entry.validUntil = time.Now().Add(45 * time.Second)
160- }
161-162- // Signal completion to waiting goroutines
163- close(done)
164- entry.mu.Unlock()
165-166- return serviceToken, err
167-}
168169// Global variables for initialization only
170// These are set by main.go during startup and copied into NamespaceResolver instances.
171// After initialization, request handling uses the NamespaceResolver's instance fields.
172var (
173 globalRefresher *oauth.Refresher
174- globalDatabase storage.DatabaseMetrics
175 globalAuthorizer auth.HoldAuthorizer
176)
177···183184// SetGlobalDatabase sets the database instance during initialization
185// Must be called before the registry starts serving requests
186-func SetGlobalDatabase(database storage.DatabaseMetrics) {
187 globalDatabase = database
188}
189···201// NamespaceResolver wraps a namespace and resolves names
202type NamespaceResolver struct {
203 distribution.Namespace
204- defaultHoldDID string // Default hold DID (e.g., "did:web:hold01.atcr.io")
205- baseURL string // Base URL for error messages (e.g., "https://atcr.io")
206- testMode bool // If true, fallback to default hold when user's hold is unreachable
207- refresher *oauth.Refresher // OAuth session manager (copied from global on init)
208- database storage.DatabaseMetrics // Metrics database (copied from global on init)
209- authorizer auth.HoldAuthorizer // Hold authorization (copied from global on init)
210- validationCache *validationCache // Request-level service token cache
211- readmeFetcher *readme.Fetcher // README fetcher for repo pages
212}
213214// initATProtoResolver initializes the name resolution middleware
···235 // Copy shared services from globals into the instance
236 // This avoids accessing globals during request handling
237 return &NamespaceResolver{
238- Namespace: ns,
239- defaultHoldDID: defaultHoldDID,
240- baseURL: baseURL,
241- testMode: testMode,
242- refresher: globalRefresher,
243- database: globalDatabase,
244- authorizer: globalAuthorizer,
245- validationCache: newValidationCache(),
246- readmeFetcher: readme.NewFetcher(),
247 }, nil
248-}
249-250-// authErrorMessage creates a user-friendly auth error with login URL
251-func (nr *NamespaceResolver) authErrorMessage(message string) error {
252- loginURL := fmt.Sprintf("%s/auth/oauth/login", nr.baseURL)
253- fullMessage := fmt.Sprintf("%s - please re-authenticate at %s", message, loginURL)
254- return errcode.ErrorCodeUnauthorized.WithMessage(fullMessage)
255}
256257// Repository resolves the repository name and delegates to underlying namespace
···287 }
288 ctx = context.WithValue(ctx, holdDIDKey, holdDID)
289290- // Auto-reconcile crew membership on first push/pull
291- // This ensures users can push immediately after docker login without web sign-in
292- // EnsureCrewMembership is best-effort and logs errors without failing the request
293- // Run in background to avoid blocking registry operations if hold is offline
294- if holdDID != "" && nr.refresher != nil {
295- slog.Debug("Auto-reconciling crew membership", "component", "registry/middleware", "did", did, "hold_did", holdDID)
296- client := atproto.NewClient(pdsEndpoint, did, "")
297- go func(ctx context.Context, client *atproto.Client, refresher *oauth.Refresher, holdDID string) {
298- storage.EnsureCrewMembership(ctx, client, refresher, holdDID)
299- }(ctx, client, nr.refresher, holdDID)
300- }
301-302- // Get service token for hold authentication (only if authenticated)
303- // Use validation cache to prevent concurrent requests from racing on OAuth/DPoP
304- // Route based on auth method from JWT token
305- var serviceToken string
306- authMethod, _ := ctx.Value(authMethodKey).(string)
307-308- // Only fetch service token if user is authenticated
309- // Unauthenticated requests (like /v2/ ping) should not trigger token fetching
310- if authMethod != "" {
311- // Create cache key: "did:holdDID"
312- cacheKey := fmt.Sprintf("%s:%s", did, holdDID)
313-314- // Fetch service token through validation cache
315- // This ensures only ONE request per DID:holdDID pair fetches the token
316- // Concurrent requests will wait for the first request to complete
317- var fetchErr error
318- serviceToken, fetchErr = nr.validationCache.getOrFetch(ctx, cacheKey, func() (string, error) {
319- if authMethod == token.AuthMethodAppPassword {
320- // App-password flow: use Bearer token authentication
321- slog.Debug("Using app-password flow for service token",
322- "component", "registry/middleware",
323- "did", did,
324- "cacheKey", cacheKey)
325-326- token, err := token.GetOrFetchServiceTokenWithAppPassword(ctx, did, holdDID, pdsEndpoint)
327- if err != nil {
328- slog.Error("Failed to get service token with app-password",
329- "component", "registry/middleware",
330- "did", did,
331- "holdDID", holdDID,
332- "pdsEndpoint", pdsEndpoint,
333- "error", err)
334- return "", err
335- }
336- return token, nil
337- } else if nr.refresher != nil {
338- // OAuth flow: use DPoP authentication
339- slog.Debug("Using OAuth flow for service token",
340- "component", "registry/middleware",
341- "did", did,
342- "cacheKey", cacheKey)
343-344- token, err := token.GetOrFetchServiceToken(ctx, nr.refresher, did, holdDID, pdsEndpoint)
345- if err != nil {
346- slog.Error("Failed to get service token with OAuth",
347- "component", "registry/middleware",
348- "did", did,
349- "holdDID", holdDID,
350- "pdsEndpoint", pdsEndpoint,
351- "error", err)
352- return "", err
353- }
354- return token, nil
355- }
356- return "", fmt.Errorf("no authentication method available")
357- })
358-359- // Handle errors from cached fetch
360- if fetchErr != nil {
361- errMsg := fetchErr.Error()
362-363- // Check for app-password specific errors
364- if authMethod == token.AuthMethodAppPassword {
365- if strings.Contains(errMsg, "expired or invalid") || strings.Contains(errMsg, "no app-password") {
366- return nil, nr.authErrorMessage("App-password authentication failed. Please re-authenticate with: docker login")
367- }
368- }
369-370- // Check for OAuth specific errors
371- if strings.Contains(errMsg, "OAuth session") || strings.Contains(errMsg, "OAuth validation") {
372- return nil, nr.authErrorMessage("OAuth session expired or invalidated by PDS. Your session has been cleared")
373- }
374-375- // Generic service token error
376- return nil, nr.authErrorMessage(fmt.Sprintf("Failed to obtain storage credentials: %v", fetchErr))
377- }
378- } else {
379- slog.Debug("Skipping service token fetch for unauthenticated request",
380- "component", "registry/middleware",
381- "did", did)
382- }
383384 // Create a new reference with identity/image format
385 // Use the identity (or DID) as the namespace to ensure canonical format
···396 return nil, err
397 }
398399- // Get access token for PDS operations
400- // Use auth method from JWT to determine client type:
401- // - OAuth users: use session provider (DPoP-enabled)
402- // - App-password users: use Basic Auth token cache
403- var atprotoClient *atproto.Client
404-405- if authMethod == token.AuthMethodOAuth && nr.refresher != nil {
406- // OAuth flow: use session provider for locked OAuth sessions
407- // This prevents DPoP nonce race conditions during concurrent layer uploads
408- slog.Debug("Creating ATProto client with OAuth session provider",
409- "component", "registry/middleware",
410- "did", did,
411- "authMethod", authMethod)
412- atprotoClient = atproto.NewClientWithSessionProvider(pdsEndpoint, did, nr.refresher)
413- } else {
414- // App-password flow (or fallback): use Basic Auth token cache
415- accessToken, ok := auth.GetGlobalTokenCache().Get(did)
416- if !ok {
417- slog.Debug("No cached access token found for app-password auth",
418- "component", "registry/middleware",
419- "did", did,
420- "authMethod", authMethod)
421- accessToken = "" // Will fail on manifest push, but let it try
422- } else {
423- slog.Debug("Creating ATProto client with app-password",
424- "component", "registry/middleware",
425- "did", did,
426- "authMethod", authMethod,
427- "token_length", len(accessToken))
428- }
429- atprotoClient = atproto.NewClient(pdsEndpoint, did, accessToken)
430- }
431-432 // IMPORTANT: Use only the image name (not identity/image) for ATProto storage
433 // ATProto records are scoped to the user's DID, so we don't need the identity prefix
434 // Example: "evan.jarrett.net/debian" -> store as "debian"
435 repositoryName := imageName
436437- // Default auth method to OAuth if not already set (backward compatibility with old tokens)
438- if authMethod == "" {
439- authMethod = token.AuthMethodOAuth
0440 }
4410000442 // Create routing repository - routes manifests to ATProto, blobs to hold service
443 // The registry is stateless - no local storage is used
444- // Bundle all context into a single RegistryContext struct
445 //
446 // NOTE: We create a fresh RoutingRepository on every request (no caching) because:
447 // 1. Each layer upload is a separate HTTP request (possibly different process)
448 // 2. OAuth sessions can be refreshed/invalidated between requests
449 // 3. The refresher already caches sessions efficiently (in-memory + DB)
450- // 4. Caching the repository with a stale ATProtoClient causes refresh token errors
451- registryCtx := &storage.RegistryContext{
452- DID: did,
453- Handle: handle,
454- HoldDID: holdDID,
455- PDSEndpoint: pdsEndpoint,
456- Repository: repositoryName,
457- ServiceToken: serviceToken, // Cached service token from middleware validation
458- ATProtoClient: atprotoClient,
459- AuthMethod: authMethod, // Auth method from JWT token
460- Database: nr.database,
461- Authorizer: nr.authorizer,
462- Refresher: nr.refresher,
463- ReadmeFetcher: nr.readmeFetcher,
464- }
465-466- return storage.NewRoutingRepository(repo, registryCtx), nil
467}
468469// Repositories delegates to underlying namespace
···498 }
499500 if profile != nil && profile.DefaultHold != "" {
501- // Profile exists with defaultHold set
502- // In test mode, verify it's reachable before using it
503 if nr.testMode {
504 if nr.isHoldReachable(ctx, profile.DefaultHold) {
505 return profile.DefaultHold
···533 return false
534}
535536-// ExtractAuthMethod is an HTTP middleware that extracts the auth method from the JWT Authorization header
537-// and stores it in the request context for later use by the registry middleware
0538func ExtractAuthMethod(next http.Handler) http.Handler {
539 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
000000540 // Extract Authorization header
541 authHeader := r.Header.Get("Authorization")
542 if authHeader != "" {
···549 authMethod := token.ExtractAuthMethod(tokenString)
550 if authMethod != "" {
551 // Store in context for registry middleware
552- ctx := context.WithValue(r.Context(), authMethodKey, authMethod)
553- r = r.WithContext(ctx)
554- slog.Debug("Extracted auth method from JWT",
555- "component", "registry/middleware",
556- "authMethod", authMethod)
000557 }
000000558 }
559 }
5600561 next.ServeHTTP(w, r)
562 })
563}
0000000000000000000000000000000000000000000000
···23import (
4 "context"
5+ "database/sql"
6 "fmt"
7 "log/slog"
8 "net/http"
9 "strings"
001011 "github.com/distribution/distribution/v3"
012 registrymw "github.com/distribution/distribution/v3/registry/middleware/registry"
13 "github.com/distribution/distribution/v3/registry/storage/driver"
14 "github.com/distribution/reference"
15016 "atcr.io/pkg/appview/storage"
17 "atcr.io/pkg/atproto"
18 "atcr.io/pkg/auth"
···26// authMethodKey is the context key for storing auth method from JWT
27const authMethodKey contextKey = "auth.method"
2829+// pullerDIDKey is the context key for storing the authenticated user's DID from JWT
30+const pullerDIDKey contextKey = "puller.did"
000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003132// Global variables for initialization only
33// These are set by main.go during startup and copied into NamespaceResolver instances.
34// After initialization, request handling uses the NamespaceResolver's instance fields.
35var (
36 globalRefresher *oauth.Refresher
37+ globalDatabase *sql.DB
38 globalAuthorizer auth.HoldAuthorizer
39)
40···4647// SetGlobalDatabase sets the database instance during initialization
48// Must be called before the registry starts serving requests
49+func SetGlobalDatabase(database *sql.DB) {
50 globalDatabase = database
51}
52···64// NamespaceResolver wraps a namespace and resolves names
65type NamespaceResolver struct {
66 distribution.Namespace
67+ defaultHoldDID string // Default hold DID (e.g., "did:web:hold01.atcr.io")
68+ baseURL string // Base URL for error messages (e.g., "https://atcr.io")
69+ testMode bool // If true, fallback to default hold when user's hold is unreachable
70+ refresher *oauth.Refresher // OAuth session manager (copied from global on init)
71+ sqlDB *sql.DB // Database for hold DID lookup and metrics (copied from global on init)
72+ authorizer auth.HoldAuthorizer // Hold authorization (copied from global on init)
0073}
7475// initATProtoResolver initializes the name resolution middleware
···96 // Copy shared services from globals into the instance
97 // This avoids accessing globals during request handling
98 return &NamespaceResolver{
99+ Namespace: ns,
100+ defaultHoldDID: defaultHoldDID,
101+ baseURL: baseURL,
102+ testMode: testMode,
103+ refresher: globalRefresher,
104+ sqlDB: globalDatabase,
105+ authorizer: globalAuthorizer,
00106 }, nil
0000000107}
108109// Repository resolves the repository name and delegates to underlying namespace
···139 }
140 ctx = context.WithValue(ctx, holdDIDKey, holdDID)
141142+ // Note: Profile and crew membership are now ensured in UserContextMiddleware
143+ // via EnsureUserSetup() - no need to call here
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000144145 // Create a new reference with identity/image format
146 // Use the identity (or DID) as the namespace to ensure canonical format
···157 return nil, err
158 }
159000000000000000000000000000000000160 // IMPORTANT: Use only the image name (not identity/image) for ATProto storage
161 // ATProto records are scoped to the user's DID, so we don't need the identity prefix
162 // Example: "evan.jarrett.net/debian" -> store as "debian"
163 repositoryName := imageName
164165+ // Get UserContext from request context (set by UserContextMiddleware)
166+ userCtx := auth.FromContext(ctx)
167+ if userCtx == nil {
168+ return nil, fmt.Errorf("UserContext not set in request context - ensure UserContextMiddleware is configured")
169 }
170171+ // Set target repository info on UserContext
172+ // ATProtoClient is cached lazily via userCtx.GetATProtoClient()
173+ userCtx.SetTarget(did, handle, pdsEndpoint, repositoryName, holdDID)
174+175 // Create routing repository - routes manifests to ATProto, blobs to hold service
176 // The registry is stateless - no local storage is used
0177 //
178 // NOTE: We create a fresh RoutingRepository on every request (no caching) because:
179 // 1. Each layer upload is a separate HTTP request (possibly different process)
180 // 2. OAuth sessions can be refreshed/invalidated between requests
181 // 3. The refresher already caches sessions efficiently (in-memory + DB)
182+ // 4. ATProtoClient is now cached in UserContext via GetATProtoClient()
183+ return storage.NewRoutingRepository(repo, userCtx, nr.sqlDB), nil
000000000000000184}
185186// Repositories delegates to underlying namespace
···215 }
216217 if profile != nil && profile.DefaultHold != "" {
218+ // In test mode, verify the hold is reachable (fall back to default if not)
219+ // In production, trust the user's profile and return their hold
220 if nr.testMode {
221 if nr.isHoldReachable(ctx, profile.DefaultHold) {
222 return profile.DefaultHold
···250 return false
251}
252253+// ExtractAuthMethod is an HTTP middleware that extracts the auth method and puller DID from the JWT Authorization header
254+// and stores them in the request context for later use by the registry middleware.
255+// Also stores the HTTP method for routing decisions (GET/HEAD = pull, PUT/POST = push).
256func ExtractAuthMethod(next http.Handler) http.Handler {
257 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
258+ ctx := r.Context()
259+260+ // Store HTTP method in context for routing decisions
261+ // This is used by routing_repository.go to distinguish pull (GET/HEAD) from push (PUT/POST)
262+ ctx = context.WithValue(ctx, "http.request.method", r.Method)
263+264 // Extract Authorization header
265 authHeader := r.Header.Get("Authorization")
266 if authHeader != "" {
···273 authMethod := token.ExtractAuthMethod(tokenString)
274 if authMethod != "" {
275 // Store in context for registry middleware
276+ ctx = context.WithValue(ctx, authMethodKey, authMethod)
277+ }
278+279+ // Extract puller DID (Subject) from JWT
280+ // This is the authenticated user's DID, used for service token requests
281+ pullerDID := token.ExtractSubject(tokenString)
282+ if pullerDID != "" {
283+ ctx = context.WithValue(ctx, pullerDIDKey, pullerDID)
284 }
285+286+ slog.Debug("Extracted auth info from JWT",
287+ "component", "registry/middleware",
288+ "authMethod", authMethod,
289+ "pullerDID", pullerDID,
290+ "httpMethod", r.Method)
291 }
292 }
293294+ r = r.WithContext(ctx)
295 next.ServeHTTP(w, r)
296 })
297}
298+299+// UserContextMiddleware creates a UserContext from the extracted JWT claims
300+// and stores it in the request context for use throughout request processing.
301+// This middleware should be chained AFTER ExtractAuthMethod.
302+func UserContextMiddleware(deps *auth.Dependencies) func(http.Handler) http.Handler {
303+ return func(next http.Handler) http.Handler {
304+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
305+ ctx := r.Context()
306+307+ // Get values set by ExtractAuthMethod
308+ authMethod, _ := ctx.Value(authMethodKey).(string)
309+ pullerDID, _ := ctx.Value(pullerDIDKey).(string)
310+311+ // Build UserContext with all dependencies
312+ userCtx := auth.NewUserContext(pullerDID, authMethod, r.Method, deps)
313+314+ // Eagerly resolve user's PDS for authenticated users
315+ // This is a fast path that avoids lazy loading in most cases
316+ if userCtx.IsAuthenticated {
317+ if err := userCtx.ResolvePDS(ctx); err != nil {
318+ slog.Warn("Failed to resolve puller's PDS",
319+ "component", "registry/middleware",
320+ "did", pullerDID,
321+ "error", err)
322+ // Continue without PDS - will fail on service token request
323+ }
324+325+ // Ensure user has profile and crew membership (runs in background, cached)
326+ userCtx.EnsureUserSetup()
327+ }
328+329+ // Store UserContext in request context
330+ ctx = auth.WithUserContext(ctx, userCtx)
331+ r = r.WithContext(ctx)
332+333+ slog.Debug("Created UserContext",
334+ "component", "registry/middleware",
335+ "isAuthenticated", userCtx.IsAuthenticated,
336+ "authMethod", userCtx.AuthMethod,
337+ "action", userCtx.Action.String(),
338+ "pullerDID", pullerDID)
339+340+ next.ServeHTTP(w, r)
341+ })
342+ }
343+}
-11
pkg/appview/middleware/registry_test.go
···129 }
130}
131132-// TestAuthErrorMessage tests the error message formatting
133-func TestAuthErrorMessage(t *testing.T) {
134- resolver := &NamespaceResolver{
135- baseURL: "https://atcr.io",
136- }
137-138- err := resolver.authErrorMessage("OAuth session expired")
139- assert.Contains(t, err.Error(), "OAuth session expired")
140- assert.Contains(t, err.Error(), "https://atcr.io/auth/oauth/login")
141-}
142-143// TestFindHoldDID_DefaultFallback tests default hold DID fallback
144func TestFindHoldDID_DefaultFallback(t *testing.T) {
145 // Start a mock PDS server that returns 404 for profile and empty list for holds
···129 }
130}
13100000000000132// TestFindHoldDID_DefaultFallback tests default hold DID fallback
133func TestFindHoldDID_DefaultFallback(t *testing.T) {
134 // Start a mock PDS server that returns 404 for profile and empty list for holds
···1-package storage
2-3-import (
4- "context"
5- "testing"
6-)
7-8-func TestEnsureCrewMembership_EmptyHoldDID(t *testing.T) {
9- // Test that empty hold DID returns early without error (best-effort function)
10- EnsureCrewMembership(context.Background(), nil, nil, "")
11- // If we get here without panic, test passes
12-}
13-14-// TODO: Add comprehensive tests with HTTP client mocking
···00000000000000
+63-78
pkg/appview/storage/manifest_store.go
···3import (
4 "bytes"
5 "context"
06 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "log/slog"
11- "maps"
12 "net/http"
13 "strings"
14- "sync"
15 "time"
16017 "atcr.io/pkg/appview/readme"
18 "atcr.io/pkg/atproto"
019 "github.com/distribution/distribution/v3"
20 "github.com/opencontainers/go-digest"
21)
···23// ManifestStore implements distribution.ManifestService
24// It stores manifests in ATProto as records
25type ManifestStore struct {
26- ctx *RegistryContext // Context with user/hold info
27- mu sync.RWMutex // Protects lastFetchedHoldDID
28- lastFetchedHoldDID string // Hold DID from most recently fetched manifest (for pull)
29 blobStore distribution.BlobStore // Blob store for fetching config during push
030}
3132// NewManifestStore creates a new ATProto-backed manifest store
33-func NewManifestStore(ctx *RegistryContext, blobStore distribution.BlobStore) *ManifestStore {
34 return &ManifestStore{
35- ctx: ctx,
36 blobStore: blobStore,
037 }
38}
3940// Exists checks if a manifest exists by digest
41func (s *ManifestStore) Exists(ctx context.Context, dgst digest.Digest) (bool, error) {
42 rkey := digestToRKey(dgst)
43- _, err := s.ctx.ATProtoClient.GetRecord(ctx, atproto.ManifestCollection, rkey)
44 if err != nil {
45 // If not found, return false without error
46 if errors.Is(err, atproto.ErrRecordNotFound) {
···54// Get retrieves a manifest by digest
55func (s *ManifestStore) Get(ctx context.Context, dgst digest.Digest, options ...distribution.ManifestServiceOption) (distribution.Manifest, error) {
56 rkey := digestToRKey(dgst)
57- record, err := s.ctx.ATProtoClient.GetRecord(ctx, atproto.ManifestCollection, rkey)
58 if err != nil {
59 return nil, distribution.ErrManifestUnknownRevision{
60- Name: s.ctx.Repository,
61 Revision: dgst,
62 }
63 }
···67 return nil, fmt.Errorf("failed to unmarshal manifest record: %w", err)
68 }
6970- // Store the hold DID for subsequent blob requests during pull
71- // Prefer HoldDID (new format) with fallback to HoldEndpoint (legacy URL format)
72- // The routing repository will cache this for concurrent blob fetches
73- s.mu.Lock()
74- if manifestRecord.HoldDID != "" {
75- // New format: DID reference (preferred)
76- s.lastFetchedHoldDID = manifestRecord.HoldDID
77- } else if manifestRecord.HoldEndpoint != "" {
78- // Legacy format: URL reference - convert to DID
79- s.lastFetchedHoldDID = atproto.ResolveHoldDIDFromURL(manifestRecord.HoldEndpoint)
80- }
81- s.mu.Unlock()
82-83 var ociManifest []byte
8485 // New records: Download blob from ATProto blob storage
86 if manifestRecord.ManifestBlob != nil && manifestRecord.ManifestBlob.Ref.Link != "" {
87- ociManifest, err = s.ctx.ATProtoClient.GetBlob(ctx, manifestRecord.ManifestBlob.Ref.Link)
88 if err != nil {
89 return nil, fmt.Errorf("failed to download manifest blob: %w", err)
90 }
···9293 // Track pull count (increment asynchronously to avoid blocking the response)
94 // Only count GET requests (actual downloads), not HEAD requests (existence checks)
95- if s.ctx.Database != nil {
96 // Check HTTP method from context (distribution library stores it as "http.request.method")
97 if method, ok := ctx.Value("http.request.method").(string); ok && method == "GET" {
98 go func() {
99- if err := s.ctx.Database.IncrementPullCount(s.ctx.DID, s.ctx.Repository); err != nil {
100- slog.Warn("Failed to increment pull count", "did", s.ctx.DID, "repository", s.ctx.Repository, "error", err)
101 }
102 }()
103 }
···124 dgst := digest.FromBytes(payload)
125126 // Upload manifest as blob to PDS
127- blobRef, err := s.ctx.ATProtoClient.UploadBlob(ctx, payload, mediaType)
128 if err != nil {
129 return "", fmt.Errorf("failed to upload manifest blob: %w", err)
130 }
131132 // Create manifest record with structured metadata
133- manifestRecord, err := atproto.NewManifestRecord(s.ctx.Repository, dgst.String(), payload)
134 if err != nil {
135 return "", fmt.Errorf("failed to create manifest record: %w", err)
136 }
137138 // Set the blob reference, hold DID, and hold endpoint
139 manifestRecord.ManifestBlob = blobRef
140- manifestRecord.HoldDID = s.ctx.HoldDID // Primary reference (DID)
141142 // Extract Dockerfile labels from config blob and add to annotations
143 // Only for image manifests (not manifest lists which don't have config blobs)
···167 platform = fmt.Sprintf("%s/%s", ref.Platform.OS, ref.Platform.Architecture)
168 }
169 slog.Warn("Manifest list references non-existent child manifest",
170- "repository", s.ctx.Repository,
171 "missingDigest", ref.Digest,
172 "platform", platform)
173 return "", distribution.ErrManifestBlobUnknown{Digest: refDigest}
···180 if err != nil {
181 // Log error but don't fail the push - labels are optional
182 slog.Warn("Failed to extract config labels", "error", err)
183- } else {
184 // Initialize annotations map if needed
185 if manifestRecord.Annotations == nil {
186 manifestRecord.Annotations = make(map[string]string)
187 }
188189- // Copy labels to annotations (Dockerfile LABELs โ manifest annotations)
190- maps.Copy(manifestRecord.Annotations, labels)
0000000191192- slog.Debug("Extracted labels from config blob", "count", len(labels))
193 }
194 }
195196 // Store manifest record in ATProto
197 rkey := digestToRKey(dgst)
198- _, err = s.ctx.ATProtoClient.PutRecord(ctx, atproto.ManifestCollection, rkey, manifestRecord)
199 if err != nil {
200 return "", fmt.Errorf("failed to store manifest record in ATProto: %w", err)
201 }
202203 // Track push count (increment asynchronously to avoid blocking the response)
204- if s.ctx.Database != nil {
205 go func() {
206- if err := s.ctx.Database.IncrementPushCount(s.ctx.DID, s.ctx.Repository); err != nil {
207- slog.Warn("Failed to increment push count", "did", s.ctx.DID, "repository", s.ctx.Repository, "error", err)
208 }
209 }()
210 }
···214 for _, option := range options {
215 if tagOpt, ok := option.(distribution.WithTagOption); ok {
216 tag = tagOpt.Tag
217- tagRecord := atproto.NewTagRecord(s.ctx.ATProtoClient.DID(), s.ctx.Repository, tag, dgst.String())
218- tagRKey := atproto.RepositoryTagToRKey(s.ctx.Repository, tag)
219- _, err = s.ctx.ATProtoClient.PutRecord(ctx, atproto.TagCollection, tagRKey, tagRecord)
220 if err != nil {
221 return "", fmt.Errorf("failed to store tag in ATProto: %w", err)
222 }
···225226 // Notify hold about manifest upload (for layer tracking and Bluesky posts)
227 // Do this asynchronously to avoid blocking the push
228- if tag != "" && s.ctx.ServiceToken != "" && s.ctx.Handle != "" {
229- go func() {
00230 defer func() {
231 if r := recover(); r != nil {
232 slog.Error("Panic in notifyHoldAboutManifest", "panic", r)
233 }
234 }()
235- if err := s.notifyHoldAboutManifest(context.Background(), manifestRecord, tag, dgst.String()); err != nil {
236 slog.Warn("Failed to notify hold about manifest", "error", err)
237 }
238- }()
239 }
240241 // Create or update repo page asynchronously if manifest has relevant annotations
···255// Delete removes a manifest
256func (s *ManifestStore) Delete(ctx context.Context, dgst digest.Digest) error {
257 rkey := digestToRKey(dgst)
258- return s.ctx.ATProtoClient.DeleteRecord(ctx, atproto.ManifestCollection, rkey)
259}
260261// digestToRKey converts a digest to an ATProto record key
···263func digestToRKey(dgst digest.Digest) string {
264 // Remove the algorithm prefix (e.g., "sha256:")
265 return dgst.Encoded()
266-}
267-268-// GetLastFetchedHoldDID returns the hold DID from the most recently fetched manifest
269-// This is used by the routing repository to cache the hold for blob requests
270-func (s *ManifestStore) GetLastFetchedHoldDID() string {
271- s.mu.RLock()
272- defer s.mu.RUnlock()
273- return s.lastFetchedHoldDID
274}
275276// rawManifest is a simple implementation of distribution.Manifest
···318319// notifyHoldAboutManifest notifies the hold service about a manifest upload
320// This enables the hold to create layer records and Bluesky posts
321-func (s *ManifestStore) notifyHoldAboutManifest(ctx context.Context, manifestRecord *atproto.ManifestRecord, tag, manifestDigest string) error {
322- // Skip if no service token configured (e.g., anonymous pulls)
323- if s.ctx.ServiceToken == "" {
324 return nil
325 }
326327 // Resolve hold DID to HTTP endpoint
328 // For did:web, this is straightforward (e.g., did:web:hold01.atcr.io โ https://hold01.atcr.io)
329- holdEndpoint := atproto.ResolveHoldURL(s.ctx.HoldDID)
330331- // Use service token from middleware (already cached and validated)
332- serviceToken := s.ctx.ServiceToken
333334 // Build notification request
335 manifestData := map[string]any{
···378 }
379380 notifyReq := map[string]any{
381- "repository": s.ctx.Repository,
382 "tag": tag,
383- "userDid": s.ctx.DID,
384- "userHandle": s.ctx.Handle,
385 "manifest": manifestData,
386 }
387···419 // Parse response (optional logging)
420 var notifyResp map[string]any
421 if err := json.NewDecoder(resp.Body).Decode(¬ifyResp); err == nil {
422- slog.Info("Hold notification successful", "repository", s.ctx.Repository, "tag", tag, "response", notifyResp)
423 }
424425 return nil
···430// Only creates a new record if one doesn't exist (doesn't overwrite user's custom content)
431func (s *ManifestStore) ensureRepoPage(ctx context.Context, manifestRecord *atproto.ManifestRecord) {
432 // Check if repo page already exists (don't overwrite user's custom content)
433- rkey := s.ctx.Repository
434- _, err := s.ctx.ATProtoClient.GetRecord(ctx, atproto.RepoPageCollection, rkey)
435 if err == nil {
436 // Record already exists - don't overwrite
437- slog.Debug("Repo page already exists, skipping creation", "did", s.ctx.DID, "repository", s.ctx.Repository)
438 return
439 }
440441 // Only continue if it's a "not found" error - other errors mean we should skip
442 if !errors.Is(err, atproto.ErrRecordNotFound) {
443- slog.Warn("Failed to check for existing repo page", "did", s.ctx.DID, "repository", s.ctx.Repository, "error", err)
444 return
445 }
446···466 }
467468 // Create new repo page record with description and optional avatar
469- repoPage := atproto.NewRepoPageRecord(s.ctx.Repository, description, avatarRef)
470471- slog.Info("Creating repo page from manifest annotations", "did", s.ctx.DID, "repository", s.ctx.Repository, "descriptionLength", len(description), "hasAvatar", avatarRef != nil)
472473- _, err = s.ctx.ATProtoClient.PutRecord(ctx, atproto.RepoPageCollection, rkey, repoPage)
474 if err != nil {
475- slog.Warn("Failed to create repo page", "did", s.ctx.DID, "repository", s.ctx.Repository, "error", err)
476 return
477 }
478479- slog.Info("Repo page created successfully", "did", s.ctx.DID, "repository", s.ctx.Repository)
480}
481482// fetchReadmeContent attempts to fetch README content from external sources
483// Priority: io.atcr.readme annotation > derived from org.opencontainers.image.source
484// Returns the raw markdown content, or empty string if not available
485func (s *ManifestStore) fetchReadmeContent(ctx context.Context, annotations map[string]string) string {
486- if s.ctx.ReadmeFetcher == nil {
487- return ""
488- }
489490 // Create a context with timeout for README fetching (don't block push too long)
491 fetchCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
···632 }
633634 // Upload the icon as a blob to the user's PDS
635- blobRef, err := s.ctx.ATProtoClient.UploadBlob(ctx, iconData, mimeType)
636 if err != nil {
637 slog.Warn("Failed to upload icon blob", "url", iconURL, "error", err)
638 return nil
···3import (
4 "bytes"
5 "context"
6+ "database/sql"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "io"
11 "log/slog"
012 "net/http"
13 "strings"
014 "time"
1516+ "atcr.io/pkg/appview/db"
17 "atcr.io/pkg/appview/readme"
18 "atcr.io/pkg/atproto"
19+ "atcr.io/pkg/auth"
20 "github.com/distribution/distribution/v3"
21 "github.com/opencontainers/go-digest"
22)
···24// ManifestStore implements distribution.ManifestService
25// It stores manifests in ATProto as records
26type ManifestStore struct {
27+ ctx *auth.UserContext // User context with identity, target, permissions
0028 blobStore distribution.BlobStore // Blob store for fetching config during push
29+ sqlDB *sql.DB // Database for pull/push counts
30}
3132// NewManifestStore creates a new ATProto-backed manifest store
33+func NewManifestStore(userCtx *auth.UserContext, blobStore distribution.BlobStore, sqlDB *sql.DB) *ManifestStore {
34 return &ManifestStore{
35+ ctx: userCtx,
36 blobStore: blobStore,
37+ sqlDB: sqlDB,
38 }
39}
4041// Exists checks if a manifest exists by digest
42func (s *ManifestStore) Exists(ctx context.Context, dgst digest.Digest) (bool, error) {
43 rkey := digestToRKey(dgst)
44+ _, err := s.ctx.GetATProtoClient().GetRecord(ctx, atproto.ManifestCollection, rkey)
45 if err != nil {
46 // If not found, return false without error
47 if errors.Is(err, atproto.ErrRecordNotFound) {
···55// Get retrieves a manifest by digest
56func (s *ManifestStore) Get(ctx context.Context, dgst digest.Digest, options ...distribution.ManifestServiceOption) (distribution.Manifest, error) {
57 rkey := digestToRKey(dgst)
58+ record, err := s.ctx.GetATProtoClient().GetRecord(ctx, atproto.ManifestCollection, rkey)
59 if err != nil {
60 return nil, distribution.ErrManifestUnknownRevision{
61+ Name: s.ctx.TargetRepo,
62 Revision: dgst,
63 }
64 }
···68 return nil, fmt.Errorf("failed to unmarshal manifest record: %w", err)
69 }
70000000000000071 var ociManifest []byte
7273 // New records: Download blob from ATProto blob storage
74 if manifestRecord.ManifestBlob != nil && manifestRecord.ManifestBlob.Ref.Link != "" {
75+ ociManifest, err = s.ctx.GetATProtoClient().GetBlob(ctx, manifestRecord.ManifestBlob.Ref.Link)
76 if err != nil {
77 return nil, fmt.Errorf("failed to download manifest blob: %w", err)
78 }
···8081 // Track pull count (increment asynchronously to avoid blocking the response)
82 // Only count GET requests (actual downloads), not HEAD requests (existence checks)
83+ if s.sqlDB != nil {
84 // Check HTTP method from context (distribution library stores it as "http.request.method")
85 if method, ok := ctx.Value("http.request.method").(string); ok && method == "GET" {
86 go func() {
87+ if err := db.IncrementPullCount(s.sqlDB, s.ctx.TargetOwnerDID, s.ctx.TargetRepo); err != nil {
88+ slog.Warn("Failed to increment pull count", "did", s.ctx.TargetOwnerDID, "repository", s.ctx.TargetRepo, "error", err)
89 }
90 }()
91 }
···112 dgst := digest.FromBytes(payload)
113114 // Upload manifest as blob to PDS
115+ blobRef, err := s.ctx.GetATProtoClient().UploadBlob(ctx, payload, mediaType)
116 if err != nil {
117 return "", fmt.Errorf("failed to upload manifest blob: %w", err)
118 }
119120 // Create manifest record with structured metadata
121+ manifestRecord, err := atproto.NewManifestRecord(s.ctx.TargetRepo, dgst.String(), payload)
122 if err != nil {
123 return "", fmt.Errorf("failed to create manifest record: %w", err)
124 }
125126 // Set the blob reference, hold DID, and hold endpoint
127 manifestRecord.ManifestBlob = blobRef
128+ manifestRecord.HoldDID = s.ctx.TargetHoldDID // Primary reference (DID)
129130 // Extract Dockerfile labels from config blob and add to annotations
131 // Only for image manifests (not manifest lists which don't have config blobs)
···155 platform = fmt.Sprintf("%s/%s", ref.Platform.OS, ref.Platform.Architecture)
156 }
157 slog.Warn("Manifest list references non-existent child manifest",
158+ "repository", s.ctx.TargetRepo,
159 "missingDigest", ref.Digest,
160 "platform", platform)
161 return "", distribution.ErrManifestBlobUnknown{Digest: refDigest}
···168 if err != nil {
169 // Log error but don't fail the push - labels are optional
170 slog.Warn("Failed to extract config labels", "error", err)
171+ } else if len(labels) > 0 {
172 // Initialize annotations map if needed
173 if manifestRecord.Annotations == nil {
174 manifestRecord.Annotations = make(map[string]string)
175 }
176177+ // Copy labels to annotations as fallback
178+ // Only set label values for keys NOT already in manifest annotations
179+ // This ensures explicit annotations take precedence over Dockerfile LABELs
180+ // (which may be inherited from base images)
181+ for key, value := range labels {
182+ if _, exists := manifestRecord.Annotations[key]; !exists {
183+ manifestRecord.Annotations[key] = value
184+ }
185+ }
186187+ slog.Debug("Merged labels from config blob", "labelsCount", len(labels), "annotationsCount", len(manifestRecord.Annotations))
188 }
189 }
190191 // Store manifest record in ATProto
192 rkey := digestToRKey(dgst)
193+ _, err = s.ctx.GetATProtoClient().PutRecord(ctx, atproto.ManifestCollection, rkey, manifestRecord)
194 if err != nil {
195 return "", fmt.Errorf("failed to store manifest record in ATProto: %w", err)
196 }
197198 // Track push count (increment asynchronously to avoid blocking the response)
199+ if s.sqlDB != nil {
200 go func() {
201+ if err := db.IncrementPushCount(s.sqlDB, s.ctx.TargetOwnerDID, s.ctx.TargetRepo); err != nil {
202+ slog.Warn("Failed to increment push count", "did", s.ctx.TargetOwnerDID, "repository", s.ctx.TargetRepo, "error", err)
203 }
204 }()
205 }
···209 for _, option := range options {
210 if tagOpt, ok := option.(distribution.WithTagOption); ok {
211 tag = tagOpt.Tag
212+ tagRecord := atproto.NewTagRecord(s.ctx.GetATProtoClient().DID(), s.ctx.TargetRepo, tag, dgst.String())
213+ tagRKey := atproto.RepositoryTagToRKey(s.ctx.TargetRepo, tag)
214+ _, err = s.ctx.GetATProtoClient().PutRecord(ctx, atproto.TagCollection, tagRKey, tagRecord)
215 if err != nil {
216 return "", fmt.Errorf("failed to store tag in ATProto: %w", err)
217 }
···220221 // Notify hold about manifest upload (for layer tracking and Bluesky posts)
222 // Do this asynchronously to avoid blocking the push
223+ // Get service token before goroutine (requires context)
224+ serviceToken, _ := s.ctx.GetServiceToken(ctx)
225+ if tag != "" && serviceToken != "" && s.ctx.TargetOwnerHandle != "" {
226+ go func(serviceToken string) {
227 defer func() {
228 if r := recover(); r != nil {
229 slog.Error("Panic in notifyHoldAboutManifest", "panic", r)
230 }
231 }()
232+ if err := s.notifyHoldAboutManifest(context.Background(), manifestRecord, tag, dgst.String(), serviceToken); err != nil {
233 slog.Warn("Failed to notify hold about manifest", "error", err)
234 }
235+ }(serviceToken)
236 }
237238 // Create or update repo page asynchronously if manifest has relevant annotations
···252// Delete removes a manifest
253func (s *ManifestStore) Delete(ctx context.Context, dgst digest.Digest) error {
254 rkey := digestToRKey(dgst)
255+ return s.ctx.GetATProtoClient().DeleteRecord(ctx, atproto.ManifestCollection, rkey)
256}
257258// digestToRKey converts a digest to an ATProto record key
···260func digestToRKey(dgst digest.Digest) string {
261 // Remove the algorithm prefix (e.g., "sha256:")
262 return dgst.Encoded()
00000000263}
264265// rawManifest is a simple implementation of distribution.Manifest
···307308// notifyHoldAboutManifest notifies the hold service about a manifest upload
309// This enables the hold to create layer records and Bluesky posts
310+func (s *ManifestStore) notifyHoldAboutManifest(ctx context.Context, manifestRecord *atproto.ManifestRecord, tag, manifestDigest, serviceToken string) error {
311+ // Skip if no service token provided
312+ if serviceToken == "" {
313 return nil
314 }
315316 // Resolve hold DID to HTTP endpoint
317 // For did:web, this is straightforward (e.g., did:web:hold01.atcr.io โ https://hold01.atcr.io)
318+ holdEndpoint := atproto.ResolveHoldURL(s.ctx.TargetHoldDID)
319320+ // Service token is passed in (already cached and validated)
0321322 // Build notification request
323 manifestData := map[string]any{
···366 }
367368 notifyReq := map[string]any{
369+ "repository": s.ctx.TargetRepo,
370 "tag": tag,
371+ "userDid": s.ctx.TargetOwnerDID,
372+ "userHandle": s.ctx.TargetOwnerHandle,
373 "manifest": manifestData,
374 }
375···407 // Parse response (optional logging)
408 var notifyResp map[string]any
409 if err := json.NewDecoder(resp.Body).Decode(¬ifyResp); err == nil {
410+ slog.Info("Hold notification successful", "repository", s.ctx.TargetRepo, "tag", tag, "response", notifyResp)
411 }
412413 return nil
···418// Only creates a new record if one doesn't exist (doesn't overwrite user's custom content)
419func (s *ManifestStore) ensureRepoPage(ctx context.Context, manifestRecord *atproto.ManifestRecord) {
420 // Check if repo page already exists (don't overwrite user's custom content)
421+ rkey := s.ctx.TargetRepo
422+ _, err := s.ctx.GetATProtoClient().GetRecord(ctx, atproto.RepoPageCollection, rkey)
423 if err == nil {
424 // Record already exists - don't overwrite
425+ slog.Debug("Repo page already exists, skipping creation", "did", s.ctx.TargetOwnerDID, "repository", s.ctx.TargetRepo)
426 return
427 }
428429 // Only continue if it's a "not found" error - other errors mean we should skip
430 if !errors.Is(err, atproto.ErrRecordNotFound) {
431+ slog.Warn("Failed to check for existing repo page", "did", s.ctx.TargetOwnerDID, "repository", s.ctx.TargetRepo, "error", err)
432 return
433 }
434···454 }
455456 // Create new repo page record with description and optional avatar
457+ repoPage := atproto.NewRepoPageRecord(s.ctx.TargetRepo, description, avatarRef)
458459+ slog.Info("Creating repo page from manifest annotations", "did", s.ctx.TargetOwnerDID, "repository", s.ctx.TargetRepo, "descriptionLength", len(description), "hasAvatar", avatarRef != nil)
460461+ _, err = s.ctx.GetATProtoClient().PutRecord(ctx, atproto.RepoPageCollection, rkey, repoPage)
462 if err != nil {
463+ slog.Warn("Failed to create repo page", "did", s.ctx.TargetOwnerDID, "repository", s.ctx.TargetRepo, "error", err)
464 return
465 }
466467+ slog.Info("Repo page created successfully", "did", s.ctx.TargetOwnerDID, "repository", s.ctx.TargetRepo)
468}
469470// fetchReadmeContent attempts to fetch README content from external sources
471// Priority: io.atcr.readme annotation > derived from org.opencontainers.image.source
472// Returns the raw markdown content, or empty string if not available
473func (s *ManifestStore) fetchReadmeContent(ctx context.Context, annotations map[string]string) string {
000474475 // Create a context with timeout for README fetching (don't block push too long)
476 fetchCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
···617 }
618619 // Upload the icon as a blob to the user's PDS
620+ blobRef, err := s.ctx.GetATProtoClient().UploadBlob(ctx, iconData, mimeType)
621 if err != nil {
622 slog.Warn("Failed to upload icon blob", "url", iconURL, "error", err)
623 return nil
+121-279
pkg/appview/storage/manifest_store_test.go
···8 "net/http"
9 "net/http/httptest"
10 "testing"
11- "time"
1213 "atcr.io/pkg/atproto"
014 "github.com/distribution/distribution/v3"
15 "github.com/opencontainers/go-digest"
16)
17-18-// mockDatabaseMetrics removed - using the one from context_test.go
1920// mockBlobStore is a minimal mock of distribution.BlobStore for testing
21type mockBlobStore struct {
···72 return nil, nil // Not needed for current tests
73}
7475-// mockRegistryContext creates a mock RegistryContext for testing
76-func mockRegistryContext(client *atproto.Client, repository, holdDID, did, handle string, database DatabaseMetrics) *RegistryContext {
77- return &RegistryContext{
78- ATProtoClient: client,
79- Repository: repository,
80- HoldDID: holdDID,
81- DID: did,
82- Handle: handle,
83- Database: database,
84- }
85}
8687// TestDigestToRKey tests digest to record key conversion
···115116// TestNewManifestStore tests creating a new manifest store
117func TestNewManifestStore(t *testing.T) {
118- client := atproto.NewClient("https://pds.example.com", "did:plc:test123", "token")
119 blobStore := newMockBlobStore()
120- db := &mockDatabaseMetrics{}
121-122- ctx := mockRegistryContext(client, "myapp", "did:web:hold.example.com", "did:plc:alice123", "alice.test", db)
123- store := NewManifestStore(ctx, blobStore)
0000124125- if store.ctx.Repository != "myapp" {
126- t.Errorf("repository = %v, want myapp", store.ctx.Repository)
127 }
128- if store.ctx.HoldDID != "did:web:hold.example.com" {
129- t.Errorf("holdDID = %v, want did:web:hold.example.com", store.ctx.HoldDID)
130 }
131- if store.ctx.DID != "did:plc:alice123" {
132- t.Errorf("did = %v, want did:plc:alice123", store.ctx.DID)
133- }
134- if store.ctx.Handle != "alice.test" {
135- t.Errorf("handle = %v, want alice.test", store.ctx.Handle)
136- }
137-}
138-139-// TestManifestStore_GetLastFetchedHoldDID tests tracking last fetched hold DID
140-func TestManifestStore_GetLastFetchedHoldDID(t *testing.T) {
141- tests := []struct {
142- name string
143- manifestHoldDID string
144- manifestHoldURL string
145- expectedLastFetched string
146- }{
147- {
148- name: "prefers HoldDID",
149- manifestHoldDID: "did:web:hold01.atcr.io",
150- manifestHoldURL: "https://hold01.atcr.io",
151- expectedLastFetched: "did:web:hold01.atcr.io",
152- },
153- {
154- name: "falls back to HoldEndpoint URL conversion",
155- manifestHoldDID: "",
156- manifestHoldURL: "https://hold02.atcr.io",
157- expectedLastFetched: "did:web:hold02.atcr.io",
158- },
159- {
160- name: "empty hold references",
161- manifestHoldDID: "",
162- manifestHoldURL: "",
163- expectedLastFetched: "",
164- },
165 }
166-167- for _, tt := range tests {
168- t.Run(tt.name, func(t *testing.T) {
169- client := atproto.NewClient("https://pds.example.com", "did:plc:test123", "token")
170- ctx := mockRegistryContext(client, "myapp", "", "did:plc:test123", "test.handle", nil)
171- store := NewManifestStore(ctx, nil)
172-173- // Simulate what happens in Get() when parsing a manifest record
174- var manifestRecord atproto.ManifestRecord
175- manifestRecord.HoldDID = tt.manifestHoldDID
176- manifestRecord.HoldEndpoint = tt.manifestHoldURL
177-178- // Mimic the hold DID extraction logic from Get()
179- if manifestRecord.HoldDID != "" {
180- store.lastFetchedHoldDID = manifestRecord.HoldDID
181- } else if manifestRecord.HoldEndpoint != "" {
182- store.lastFetchedHoldDID = atproto.ResolveHoldDIDFromURL(manifestRecord.HoldEndpoint)
183- }
184-185- got := store.GetLastFetchedHoldDID()
186- if got != tt.expectedLastFetched {
187- t.Errorf("GetLastFetchedHoldDID() = %v, want %v", got, tt.expectedLastFetched)
188- }
189- })
190 }
191}
192···241 blobStore.blobs[configDigest] = configData
242243 // Create manifest store
244- client := atproto.NewClient("https://pds.example.com", "did:plc:test123", "token")
245- ctx := mockRegistryContext(client, "myapp", "", "did:plc:test123", "test.handle", nil)
246- store := NewManifestStore(ctx, blobStore)
00000247248 // Extract labels
249 labels, err := store.extractConfigLabels(context.Background(), configDigest.String())
···281 configDigest := digest.FromBytes(configData)
282 blobStore.blobs[configDigest] = configData
283284- client := atproto.NewClient("https://pds.example.com", "did:plc:test123", "token")
285- ctx := mockRegistryContext(client, "myapp", "", "did:plc:test123", "test.handle", nil)
286- store := NewManifestStore(ctx, blobStore)
00000287288 labels, err := store.extractConfigLabels(context.Background(), configDigest.String())
289 if err != nil {
···299// TestExtractConfigLabels_InvalidDigest tests error handling for invalid digest
300func TestExtractConfigLabels_InvalidDigest(t *testing.T) {
301 blobStore := newMockBlobStore()
302- client := atproto.NewClient("https://pds.example.com", "did:plc:test123", "token")
303- ctx := mockRegistryContext(client, "myapp", "", "did:plc:test123", "test.handle", nil)
304- store := NewManifestStore(ctx, blobStore)
00000305306 _, err := store.extractConfigLabels(context.Background(), "invalid-digest")
307 if err == nil {
···318 configDigest := digest.FromBytes(configData)
319 blobStore.blobs[configDigest] = configData
320321- client := atproto.NewClient("https://pds.example.com", "did:plc:test123", "token")
322- ctx := mockRegistryContext(client, "myapp", "", "did:plc:test123", "test.handle", nil)
323- store := NewManifestStore(ctx, blobStore)
00000324325 _, err := store.extractConfigLabels(context.Background(), configDigest.String())
326 if err == nil {
···328 }
329}
330331-// TestManifestStore_WithMetrics tests that metrics are tracked
332-func TestManifestStore_WithMetrics(t *testing.T) {
333- db := &mockDatabaseMetrics{}
334- client := atproto.NewClient("https://pds.example.com", "did:plc:test123", "token")
335- ctx := mockRegistryContext(client, "myapp", "did:web:hold.example.com", "did:plc:alice123", "alice.test", db)
336- store := NewManifestStore(ctx, nil)
337-338- if store.ctx.Database != db {
339- t.Error("ManifestStore should store database reference")
340- }
341-342- // Note: Actual metrics tracking happens in Put() and Get() which require
343- // full mock setup. The important thing is that the database is wired up.
344-}
345-346-// TestManifestStore_WithoutMetrics tests that nil database is acceptable
347-func TestManifestStore_WithoutMetrics(t *testing.T) {
348- client := atproto.NewClient("https://pds.example.com", "did:plc:test123", "token")
349- ctx := mockRegistryContext(client, "myapp", "did:web:hold.example.com", "did:plc:alice123", "alice.test", nil)
350- store := NewManifestStore(ctx, nil)
351352- if store.ctx.Database != nil {
353 t.Error("ManifestStore should accept nil database")
354 }
355}
···399 }))
400 defer server.Close()
401402- client := atproto.NewClient(server.URL, "did:plc:test123", "token")
403- ctx := mockRegistryContext(client, "myapp", "did:web:hold.example.com", "did:plc:test123", "test.handle", nil)
404- store := NewManifestStore(ctx, nil)
00000405406 exists, err := store.Exists(context.Background(), tt.digest)
407 if (err != nil) != tt.wantErr {
···517 }))
518 defer server.Close()
519520- client := atproto.NewClient(server.URL, "did:plc:test123", "token")
521- db := &mockDatabaseMetrics{}
522- ctx := mockRegistryContext(client, "myapp", "did:web:hold.example.com", "did:plc:test123", "test.handle", db)
523- store := NewManifestStore(ctx, nil)
0000524525 manifest, err := store.Get(context.Background(), tt.digest)
526 if (err != nil) != tt.wantErr {
···541 }
542}
543544-// TestManifestStore_Get_HoldDIDTracking tests that Get() stores the holdDID
545-func TestManifestStore_Get_HoldDIDTracking(t *testing.T) {
546- ociManifest := []byte(`{"schemaVersion":2}`)
547-548- tests := []struct {
549- name string
550- manifestResp string
551- expectedHoldDID string
552- }{
553- {
554- name: "tracks HoldDID from new format",
555- manifestResp: `{
556- "uri":"at://did:plc:test123/io.atcr.manifest/abc123",
557- "value":{
558- "$type":"io.atcr.manifest",
559- "holdDid":"did:web:hold01.atcr.io",
560- "holdEndpoint":"https://hold01.atcr.io",
561- "mediaType":"application/vnd.oci.image.manifest.v1+json",
562- "manifestBlob":{"ref":{"$link":"bafytest"},"size":100}
563- }
564- }`,
565- expectedHoldDID: "did:web:hold01.atcr.io",
566- },
567- {
568- name: "tracks HoldDID from legacy HoldEndpoint",
569- manifestResp: `{
570- "uri":"at://did:plc:test123/io.atcr.manifest/abc123",
571- "value":{
572- "$type":"io.atcr.manifest",
573- "holdEndpoint":"https://hold02.atcr.io",
574- "mediaType":"application/vnd.oci.image.manifest.v1+json",
575- "manifestBlob":{"ref":{"$link":"bafytest"},"size":100}
576- }
577- }`,
578- expectedHoldDID: "did:web:hold02.atcr.io",
579- },
580- }
581-582- for _, tt := range tests {
583- t.Run(tt.name, func(t *testing.T) {
584- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
585- if r.URL.Path == atproto.SyncGetBlob {
586- w.Write(ociManifest)
587- return
588- }
589- w.Write([]byte(tt.manifestResp))
590- }))
591- defer server.Close()
592-593- client := atproto.NewClient(server.URL, "did:plc:test123", "token")
594- ctx := mockRegistryContext(client, "myapp", "", "did:plc:test123", "test.handle", nil)
595- store := NewManifestStore(ctx, nil)
596-597- _, err := store.Get(context.Background(), "sha256:abc123")
598- if err != nil {
599- t.Fatalf("Get() error = %v", err)
600- }
601-602- gotHoldDID := store.GetLastFetchedHoldDID()
603- if gotHoldDID != tt.expectedHoldDID {
604- t.Errorf("GetLastFetchedHoldDID() = %v, want %v", gotHoldDID, tt.expectedHoldDID)
605- }
606- })
607- }
608-}
609-610-// TestManifestStore_Get_OnlyCountsGETRequests verifies that HEAD requests don't increment pull count
611-func TestManifestStore_Get_OnlyCountsGETRequests(t *testing.T) {
612- ociManifest := []byte(`{"schemaVersion":2}`)
613-614- tests := []struct {
615- name string
616- httpMethod string
617- expectPullIncrement bool
618- }{
619- {
620- name: "GET request increments pull count",
621- httpMethod: "GET",
622- expectPullIncrement: true,
623- },
624- {
625- name: "HEAD request does not increment pull count",
626- httpMethod: "HEAD",
627- expectPullIncrement: false,
628- },
629- {
630- name: "POST request does not increment pull count",
631- httpMethod: "POST",
632- expectPullIncrement: false,
633- },
634- }
635-636- for _, tt := range tests {
637- t.Run(tt.name, func(t *testing.T) {
638- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
639- if r.URL.Path == atproto.SyncGetBlob {
640- w.Write(ociManifest)
641- return
642- }
643- w.Write([]byte(`{
644- "uri": "at://did:plc:test123/io.atcr.manifest/abc123",
645- "value": {
646- "$type":"io.atcr.manifest",
647- "holdDid":"did:web:hold01.atcr.io",
648- "mediaType":"application/vnd.oci.image.manifest.v1+json",
649- "manifestBlob":{"ref":{"$link":"bafytest"},"size":100}
650- }
651- }`))
652- }))
653- defer server.Close()
654-655- client := atproto.NewClient(server.URL, "did:plc:test123", "token")
656- mockDB := &mockDatabaseMetrics{}
657- ctx := mockRegistryContext(client, "myapp", "did:web:hold01.atcr.io", "did:plc:test123", "test.handle", mockDB)
658- store := NewManifestStore(ctx, nil)
659-660- // Create a context with the HTTP method stored (as distribution library does)
661- testCtx := context.WithValue(context.Background(), "http.request.method", tt.httpMethod)
662-663- _, err := store.Get(testCtx, "sha256:abc123")
664- if err != nil {
665- t.Fatalf("Get() error = %v", err)
666- }
667-668- // Wait for async goroutine to complete (metrics are incremented asynchronously)
669- time.Sleep(50 * time.Millisecond)
670-671- if tt.expectPullIncrement {
672- // Check that IncrementPullCount was called
673- if mockDB.getPullCount() == 0 {
674- t.Error("Expected pull count to be incremented for GET request, but it wasn't")
675- }
676- } else {
677- // Check that IncrementPullCount was NOT called
678- if mockDB.getPullCount() > 0 {
679- t.Errorf("Expected pull count NOT to be incremented for %s request, but it was (count=%d)", tt.httpMethod, mockDB.getPullCount())
680- }
681- }
682- })
683- }
684-}
685-686// TestManifestStore_Put tests storing manifests
687func TestManifestStore_Put(t *testing.T) {
688 ociManifest := []byte(`{
···774 }))
775 defer server.Close()
776777- client := atproto.NewClient(server.URL, "did:plc:test123", "token")
778- db := &mockDatabaseMetrics{}
779- ctx := mockRegistryContext(client, "myapp", "did:web:hold.example.com", "did:plc:test123", "test.handle", db)
780- store := NewManifestStore(ctx, nil)
0000781782 dgst, err := store.Put(context.Background(), tt.manifest, tt.options...)
783 if (err != nil) != tt.wantErr {
···826 }))
827 defer server.Close()
828829- client := atproto.NewClient(server.URL, "did:plc:test123", "token")
830- ctx := mockRegistryContext(client, "myapp", "did:web:hold.example.com", "did:plc:test123", "test.handle", nil)
00000831832 // Use config digest in manifest
833 ociManifestWithConfig := []byte(`{
···842 payload: ociManifestWithConfig,
843 }
844845- store := NewManifestStore(ctx, blobStore)
846847 _, err := store.Put(context.Background(), manifest)
848 if err != nil {
···902 }))
903 defer server.Close()
904905- client := atproto.NewClient(server.URL, "did:plc:test123", "token")
906- ctx := mockRegistryContext(client, "myapp", "did:web:hold.example.com", "did:plc:test123", "test.handle", nil)
907- store := NewManifestStore(ctx, nil)
00000908909 err := store.Delete(context.Background(), tt.digest)
910 if (err != nil) != tt.wantErr {
···1058 }))
1059 defer server.Close()
10601061- client := atproto.NewClient(server.URL, "did:plc:test123", "token")
1062- db := &mockDatabaseMetrics{}
1063- ctx := mockRegistryContext(client, "myapp", "did:web:hold.example.com", "did:plc:test123", "test.handle", db)
1064- store := NewManifestStore(ctx, nil)
000010651066 manifest := &rawManifest{
1067 mediaType: "application/vnd.oci.image.index.v1+json",
···1135 }))
1136 defer server.Close()
11371138- client := atproto.NewClient(server.URL, "did:plc:test123", "token")
1139- ctx := mockRegistryContext(client, "myapp", "did:web:hold.example.com", "did:plc:test123", "test.handle", nil)
1140- store := NewManifestStore(ctx, nil)
0000011411142 // Create manifest list with both children
1143 manifestList := []byte(`{
···12 "time"
1314 "atcr.io/pkg/atproto"
015 "github.com/distribution/distribution/v3"
16 "github.com/distribution/distribution/v3/registry/api/errcode"
17 "github.com/opencontainers/go-digest"
···3233// ProxyBlobStore proxies blob requests to an external storage service
34type ProxyBlobStore struct {
35- ctx *RegistryContext // All context and services
36- holdURL string // Resolved HTTP URL for XRPC requests
37 httpClient *http.Client
38}
3940// NewProxyBlobStore creates a new proxy blob store
41-func NewProxyBlobStore(ctx *RegistryContext) *ProxyBlobStore {
42 // Resolve DID to URL once at construction time
43- holdURL := atproto.ResolveHoldURL(ctx.HoldDID)
4445- slog.Debug("NewProxyBlobStore created", "component", "proxy_blob_store", "hold_did", ctx.HoldDID, "hold_url", holdURL, "user_did", ctx.DID, "repo", ctx.Repository)
4647 return &ProxyBlobStore{
48- ctx: ctx,
49 holdURL: holdURL,
50 httpClient: &http.Client{
51 Timeout: 5 * time.Minute, // Timeout for presigned URL requests and uploads
···61}
6263// doAuthenticatedRequest performs an HTTP request with service token authentication
64-// Uses the service token from middleware to authenticate requests to the hold service
65func (p *ProxyBlobStore) doAuthenticatedRequest(ctx context.Context, req *http.Request) (*http.Response, error) {
66- // Use service token that middleware already validated and cached
67- // Middleware fails fast with HTTP 401 if OAuth session is invalid
68- if p.ctx.ServiceToken == "" {
000069 // Should never happen - middleware validates OAuth before handlers run
70 slog.Error("No service token in context", "component", "proxy_blob_store", "did", p.ctx.DID)
71 return nil, fmt.Errorf("no service token available (middleware should have validated)")
72 }
7374 // Add Bearer token to Authorization header
75- req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", p.ctx.ServiceToken))
7677 return p.httpClient.Do(req)
78}
7980// checkReadAccess validates that the user has read access to blobs in this hold
81func (p *ProxyBlobStore) checkReadAccess(ctx context.Context) error {
82- if p.ctx.Authorizer == nil {
83- return nil // No authorization check if authorizer not configured
84- }
85- allowed, err := p.ctx.Authorizer.CheckReadAccess(ctx, p.ctx.HoldDID, p.ctx.DID)
86 if err != nil {
87 return fmt.Errorf("authorization check failed: %w", err)
88 }
89- if !allowed {
90 // Return 403 Forbidden instead of masquerading as missing blob
91 return errcode.ErrorCodeDenied.WithMessage("read access denied")
92 }
···9596// checkWriteAccess validates that the user has write access to blobs in this hold
97func (p *ProxyBlobStore) checkWriteAccess(ctx context.Context) error {
98- if p.ctx.Authorizer == nil {
99- return nil // No authorization check if authorizer not configured
100- }
101-102- slog.Debug("Checking write access", "component", "proxy_blob_store", "user_did", p.ctx.DID, "hold_did", p.ctx.HoldDID)
103- allowed, err := p.ctx.Authorizer.CheckWriteAccess(ctx, p.ctx.HoldDID, p.ctx.DID)
104 if err != nil {
105 slog.Error("Authorization check error", "component", "proxy_blob_store", "error", err)
106 return fmt.Errorf("authorization check failed: %w", err)
107 }
108- if !allowed {
109- slog.Warn("Write access denied", "component", "proxy_blob_store", "user_did", p.ctx.DID, "hold_did", p.ctx.HoldDID)
110- return errcode.ErrorCodeDenied.WithMessage(fmt.Sprintf("write access denied to hold %s", p.ctx.HoldDID))
111 }
112- slog.Debug("Write access allowed", "component", "proxy_blob_store", "user_did", p.ctx.DID, "hold_did", p.ctx.HoldDID)
113 return nil
114}
115···356// getPresignedURL returns the XRPC endpoint URL for blob operations
357func (p *ProxyBlobStore) getPresignedURL(ctx context.Context, operation string, dgst digest.Digest) (string, error) {
358 // Use XRPC endpoint: /xrpc/com.atproto.sync.getBlob?did={userDID}&cid={digest}
359- // The 'did' parameter is the USER's DID (whose blob we're fetching), not the hold service DID
360 // Per migration doc: hold accepts OCI digest directly as cid parameter (checks for sha256: prefix)
361 xrpcURL := fmt.Sprintf("%s%s?did=%s&cid=%s&method=%s",
362- p.holdURL, atproto.SyncGetBlob, p.ctx.DID, dgst.String(), operation)
363364 req, err := http.NewRequestWithContext(ctx, "GET", xrpcURL, nil)
365 if err != nil {
···12 "time"
1314 "atcr.io/pkg/atproto"
15+ "atcr.io/pkg/auth"
16 "github.com/distribution/distribution/v3"
17 "github.com/distribution/distribution/v3/registry/api/errcode"
18 "github.com/opencontainers/go-digest"
···3334// ProxyBlobStore proxies blob requests to an external storage service
35type ProxyBlobStore struct {
36+ ctx *auth.UserContext // User context with identity, target, permissions
37+ holdURL string // Resolved HTTP URL for XRPC requests
38 httpClient *http.Client
39}
4041// NewProxyBlobStore creates a new proxy blob store
42+func NewProxyBlobStore(userCtx *auth.UserContext) *ProxyBlobStore {
43 // Resolve DID to URL once at construction time
44+ holdURL := atproto.ResolveHoldURL(userCtx.TargetHoldDID)
4546+ slog.Debug("NewProxyBlobStore created", "component", "proxy_blob_store", "hold_did", userCtx.TargetHoldDID, "hold_url", holdURL, "user_did", userCtx.TargetOwnerDID, "repo", userCtx.TargetRepo)
4748 return &ProxyBlobStore{
49+ ctx: userCtx,
50 holdURL: holdURL,
51 httpClient: &http.Client{
52 Timeout: 5 * time.Minute, // Timeout for presigned URL requests and uploads
···62}
6364// doAuthenticatedRequest performs an HTTP request with service token authentication
65+// Uses the service token from UserContext to authenticate requests to the hold service
66func (p *ProxyBlobStore) doAuthenticatedRequest(ctx context.Context, req *http.Request) (*http.Response, error) {
67+ // Get service token from UserContext (lazy-loaded and cached per holdDID)
68+ serviceToken, err := p.ctx.GetServiceToken(ctx)
69+ if err != nil {
70+ slog.Error("Failed to get service token", "component", "proxy_blob_store", "did", p.ctx.DID, "error", err)
71+ return nil, fmt.Errorf("failed to get service token: %w", err)
72+ }
73+ if serviceToken == "" {
74 // Should never happen - middleware validates OAuth before handlers run
75 slog.Error("No service token in context", "component", "proxy_blob_store", "did", p.ctx.DID)
76 return nil, fmt.Errorf("no service token available (middleware should have validated)")
77 }
7879 // Add Bearer token to Authorization header
80+ req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", serviceToken))
8182 return p.httpClient.Do(req)
83}
8485// checkReadAccess validates that the user has read access to blobs in this hold
86func (p *ProxyBlobStore) checkReadAccess(ctx context.Context) error {
87+ canRead, err := p.ctx.CanRead(ctx)
00088 if err != nil {
89 return fmt.Errorf("authorization check failed: %w", err)
90 }
91+ if !canRead {
92 // Return 403 Forbidden instead of masquerading as missing blob
93 return errcode.ErrorCodeDenied.WithMessage("read access denied")
94 }
···9798// checkWriteAccess validates that the user has write access to blobs in this hold
99func (p *ProxyBlobStore) checkWriteAccess(ctx context.Context) error {
100+ slog.Debug("Checking write access", "component", "proxy_blob_store", "user_did", p.ctx.DID, "hold_did", p.ctx.TargetHoldDID)
101+ canWrite, err := p.ctx.CanWrite(ctx)
0000102 if err != nil {
103 slog.Error("Authorization check error", "component", "proxy_blob_store", "error", err)
104 return fmt.Errorf("authorization check failed: %w", err)
105 }
106+ if !canWrite {
107+ slog.Warn("Write access denied", "component", "proxy_blob_store", "user_did", p.ctx.DID, "hold_did", p.ctx.TargetHoldDID)
108+ return errcode.ErrorCodeDenied.WithMessage(fmt.Sprintf("write access denied to hold %s", p.ctx.TargetHoldDID))
109 }
110+ slog.Debug("Write access allowed", "component", "proxy_blob_store", "user_did", p.ctx.DID, "hold_did", p.ctx.TargetHoldDID)
111 return nil
112}
113···354// getPresignedURL returns the XRPC endpoint URL for blob operations
355func (p *ProxyBlobStore) getPresignedURL(ctx context.Context, operation string, dgst digest.Digest) (string, error) {
356 // Use XRPC endpoint: /xrpc/com.atproto.sync.getBlob?did={userDID}&cid={digest}
357+ // The 'did' parameter is the TARGET OWNER's DID (whose blob we're fetching), not the hold service DID
358 // Per migration doc: hold accepts OCI digest directly as cid parameter (checks for sha256: prefix)
359 xrpcURL := fmt.Sprintf("%s%s?did=%s&cid=%s&method=%s",
360+ p.holdURL, atproto.SyncGetBlob, p.ctx.TargetOwnerDID, dgst.String(), operation)
361362 req, err := http.NewRequestWithContext(ctx, "GET", xrpcURL, nil)
363 if err != nil {
+78-420
pkg/appview/storage/proxy_blob_store_test.go
···1package storage
23import (
4- "context"
5 "encoding/base64"
6- "encoding/json"
7 "fmt"
8- "net/http"
9- "net/http/httptest"
10 "strings"
11 "testing"
12 "time"
1314 "atcr.io/pkg/atproto"
15- "atcr.io/pkg/auth/token"
16- "github.com/opencontainers/go-digest"
17)
1819-// TestGetServiceToken_CachingLogic tests the token caching mechanism
020func TestGetServiceToken_CachingLogic(t *testing.T) {
21- userDID := "did:plc:test"
22 holdDID := "did:web:hold.example.com"
2324 // Test 1: Empty cache - invalidate any existing token
25- token.InvalidateServiceToken(userDID, holdDID)
26- cachedToken, _ := token.GetServiceToken(userDID, holdDID)
27 if cachedToken != "" {
28 t.Error("Expected empty cache at start")
29 }
3031 // Test 2: Insert token into cache
32 // Create a JWT-like token with exp claim for testing
33- // Format: header.payload.signature where payload has exp claim
34 testPayload := fmt.Sprintf(`{"exp":%d}`, time.Now().Add(50*time.Second).Unix())
35 testToken := "eyJhbGciOiJIUzI1NiJ9." + base64URLEncode(testPayload) + ".signature"
3637- err := token.SetServiceToken(userDID, holdDID, testToken)
38 if err != nil {
39 t.Fatalf("Failed to set service token: %v", err)
40 }
4142 // Test 3: Retrieve from cache
43- cachedToken, expiresAt := token.GetServiceToken(userDID, holdDID)
44 if cachedToken == "" {
45 t.Fatal("Expected token to be in cache")
46 }
···56 // Test 4: Expired token - GetServiceToken automatically removes it
57 expiredPayload := fmt.Sprintf(`{"exp":%d}`, time.Now().Add(-1*time.Hour).Unix())
58 expiredToken := "eyJhbGciOiJIUzI1NiJ9." + base64URLEncode(expiredPayload) + ".signature"
59- token.SetServiceToken(userDID, holdDID, expiredToken)
6061 // GetServiceToken should return empty string for expired token
62- cachedToken, _ = token.GetServiceToken(userDID, holdDID)
63 if cachedToken != "" {
64 t.Error("Expected expired token to be removed from cache")
65 }
···70 return strings.TrimRight(base64.URLEncoding.EncodeToString([]byte(data)), "=")
71}
7273-// TestServiceToken_EmptyInContext tests that operations fail when service token is missing
74-func TestServiceToken_EmptyInContext(t *testing.T) {
75- ctx := &RegistryContext{
76- DID: "did:plc:test",
77- HoldDID: "did:web:hold.example.com",
78- PDSEndpoint: "https://pds.example.com",
79- Repository: "test-repo",
80- ServiceToken: "", // No service token (middleware didn't set it)
81- Refresher: nil,
82- }
8384- store := NewProxyBlobStore(ctx)
08586- // Try a write operation that requires authentication
87- testDigest := digest.FromString("test-content")
88- _, err := store.Stat(context.Background(), testDigest)
8990- // Should fail because no service token is available
91- if err == nil {
92- t.Error("Expected error when service token is empty")
93- }
9495- // Error should indicate authentication issue
96- if !strings.Contains(err.Error(), "UNAUTHORIZED") && !strings.Contains(err.Error(), "authentication") {
97- t.Logf("Got error (acceptable): %v", err)
98- }
99}
100101-// TestDoAuthenticatedRequest_BearerTokenInjection tests that Bearer tokens are added to requests
102-func TestDoAuthenticatedRequest_BearerTokenInjection(t *testing.T) {
103- // This test verifies the Bearer token injection logic
104-105- testToken := "test-bearer-token-xyz"
106-107- // Create a test server to verify the Authorization header
108- var receivedAuthHeader string
109- testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
110- receivedAuthHeader = r.Header.Get("Authorization")
111- w.WriteHeader(http.StatusOK)
112- }))
113- defer testServer.Close()
114-115- // Create ProxyBlobStore with service token in context (set by middleware)
116- ctx := &RegistryContext{
117- DID: "did:plc:bearer-test",
118- HoldDID: "did:web:hold.example.com",
119- PDSEndpoint: "https://pds.example.com",
120- Repository: "test-repo",
121- ServiceToken: testToken, // Service token from middleware
122- Refresher: nil,
123- }
124-125- store := NewProxyBlobStore(ctx)
126-127- // Create request
128- req, err := http.NewRequest(http.MethodGet, testServer.URL+"/test", nil)
129- if err != nil {
130- t.Fatalf("Failed to create request: %v", err)
131- }
132-133- // Do authenticated request
134- resp, err := store.doAuthenticatedRequest(context.Background(), req)
135- if err != nil {
136- t.Fatalf("doAuthenticatedRequest failed: %v", err)
137- }
138- defer resp.Body.Close()
139-140- // Verify Bearer token was added
141- expectedHeader := "Bearer " + testToken
142- if receivedAuthHeader != expectedHeader {
143- t.Errorf("Expected Authorization header %s, got %s", expectedHeader, receivedAuthHeader)
144- }
145}
146147-// TestDoAuthenticatedRequest_ErrorWhenTokenUnavailable tests that authentication failures return proper errors
148-func TestDoAuthenticatedRequest_ErrorWhenTokenUnavailable(t *testing.T) {
149- // Create test server (should not be called since auth fails first)
150- called := false
151- testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
152- called = true
153- w.WriteHeader(http.StatusOK)
154- }))
155- defer testServer.Close()
156-157- // Create ProxyBlobStore without service token (middleware didn't set it)
158- ctx := &RegistryContext{
159- DID: "did:plc:fallback",
160- HoldDID: "did:web:hold.example.com",
161- PDSEndpoint: "https://pds.example.com",
162- Repository: "test-repo",
163- ServiceToken: "", // No service token
164- Refresher: nil,
165- }
166-167- store := NewProxyBlobStore(ctx)
168-169- // Create request
170- req, err := http.NewRequest(http.MethodGet, testServer.URL+"/test", nil)
171- if err != nil {
172- t.Fatalf("Failed to create request: %v", err)
173- }
174-175- // Do authenticated request - should fail when no service token
176- resp, err := store.doAuthenticatedRequest(context.Background(), req)
177- if err == nil {
178- t.Fatal("Expected doAuthenticatedRequest to fail when no service token is available")
179- }
180- if resp != nil {
181- resp.Body.Close()
182- }
183-184- // Verify error indicates authentication/authorization issue
185- errStr := err.Error()
186- if !strings.Contains(errStr, "service token") && !strings.Contains(errStr, "UNAUTHORIZED") {
187- t.Errorf("Expected service token or unauthorized error, got: %v", err)
188- }
189-190- if called {
191- t.Error("Expected request to NOT be made when authentication fails")
192- }
193-}
194-195-// TestResolveHoldURL tests DID to URL conversion
196func TestResolveHoldURL(t *testing.T) {
197 tests := []struct {
198 name string
···200 expected string
201 }{
202 {
203- name: "did:web with http (TEST_MODE)",
204 holdDID: "did:web:localhost:8080",
205 expected: "http://localhost:8080",
206 },
···228229// TestServiceTokenCacheExpiry tests that expired cached tokens are not used
230func TestServiceTokenCacheExpiry(t *testing.T) {
231- userDID := "did:plc:expiry"
232 holdDID := "did:web:hold.example.com"
233234 // Insert expired token
235 expiredPayload := fmt.Sprintf(`{"exp":%d}`, time.Now().Add(-1*time.Hour).Unix())
236 expiredToken := "eyJhbGciOiJIUzI1NiJ9." + base64URLEncode(expiredPayload) + ".signature"
237- token.SetServiceToken(userDID, holdDID, expiredToken)
238239 // GetServiceToken should automatically remove expired tokens
240- cachedToken, expiresAt := token.GetServiceToken(userDID, holdDID)
241242 // Should return empty string for expired token
243 if cachedToken != "" {
···272273// TestNewProxyBlobStore tests ProxyBlobStore creation
274func TestNewProxyBlobStore(t *testing.T) {
275- ctx := &RegistryContext{
276- DID: "did:plc:test",
277- HoldDID: "did:web:hold.example.com",
278- PDSEndpoint: "https://pds.example.com",
279- Repository: "test-repo",
280- }
281282- store := NewProxyBlobStore(ctx)
283284 if store == nil {
285 t.Fatal("Expected non-nil ProxyBlobStore")
286 }
287288- if store.ctx != ctx {
289 t.Error("Expected context to be set")
290 }
291···310311 testPayload := fmt.Sprintf(`{"exp":%d}`, time.Now().Add(50*time.Second).Unix())
312 testTokenStr := "eyJhbGciOiJIUzI1NiJ9." + base64URLEncode(testPayload) + ".signature"
313- token.SetServiceToken(userDID, holdDID, testTokenStr)
314315 for b.Loop() {
316- cachedToken, expiresAt := token.GetServiceToken(userDID, holdDID)
317318 if cachedToken == "" || time.Now().After(expiresAt) {
319 b.Error("Cache miss in benchmark")
···321 }
322}
323324-// TestCompleteMultipartUpload_JSONFormat verifies the JSON request format sent to hold service
325-// This test would have caught the "partNumber" vs "part_number" bug
326-func TestCompleteMultipartUpload_JSONFormat(t *testing.T) {
327- var capturedBody map[string]any
328-329- // Mock hold service that captures the request body
330- holdServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
331- if !strings.Contains(r.URL.Path, atproto.HoldCompleteUpload) {
332- t.Errorf("Wrong endpoint called: %s", r.URL.Path)
333- }
334-335- // Capture request body
336- var body map[string]any
337- if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
338- t.Errorf("Failed to decode request body: %v", err)
339- }
340- capturedBody = body
341-342- w.Header().Set("Content-Type", "application/json")
343- w.WriteHeader(http.StatusOK)
344- w.Write([]byte(`{}`))
345- }))
346- defer holdServer.Close()
347-348- // Create store with mocked hold URL
349- ctx := &RegistryContext{
350- DID: "did:plc:test",
351- HoldDID: "did:web:hold.example.com",
352- PDSEndpoint: "https://pds.example.com",
353- Repository: "test-repo",
354- ServiceToken: "test-service-token", // Service token from middleware
355- }
356- store := NewProxyBlobStore(ctx)
357- store.holdURL = holdServer.URL
358-359- // Call completeMultipartUpload
360- parts := []CompletedPart{
361- {PartNumber: 1, ETag: "etag-1"},
362- {PartNumber: 2, ETag: "etag-2"},
363- }
364- err := store.completeMultipartUpload(context.Background(), "sha256:abc123", "upload-id-xyz", parts)
365- if err != nil {
366- t.Fatalf("completeMultipartUpload failed: %v", err)
367- }
368-369- // Verify JSON format
370- if capturedBody == nil {
371- t.Fatal("No request body was captured")
372- }
373-374- // Check top-level fields
375- if uploadID, ok := capturedBody["uploadId"].(string); !ok || uploadID != "upload-id-xyz" {
376- t.Errorf("Expected uploadId='upload-id-xyz', got %v", capturedBody["uploadId"])
377- }
378- if digest, ok := capturedBody["digest"].(string); !ok || digest != "sha256:abc123" {
379- t.Errorf("Expected digest='sha256:abc123', got %v", capturedBody["digest"])
380- }
381-382- // Check parts array
383- partsArray, ok := capturedBody["parts"].([]any)
384- if !ok {
385- t.Fatalf("Expected parts to be array, got %T", capturedBody["parts"])
386- }
387- if len(partsArray) != 2 {
388- t.Fatalf("Expected 2 parts, got %d", len(partsArray))
389- }
390-391- // Verify first part has "part_number" (not "partNumber")
392- part0, ok := partsArray[0].(map[string]any)
393- if !ok {
394- t.Fatalf("Expected part to be object, got %T", partsArray[0])
395- }
396-397- // THIS IS THE KEY CHECK - would have caught the bug
398- if _, hasPartNumber := part0["partNumber"]; hasPartNumber {
399- t.Error("Found 'partNumber' (camelCase) - should be 'part_number' (snake_case)")
400- }
401- if partNum, ok := part0["part_number"].(float64); !ok || int(partNum) != 1 {
402- t.Errorf("Expected part_number=1, got %v", part0["part_number"])
403- }
404- if etag, ok := part0["etag"].(string); !ok || etag != "etag-1" {
405- t.Errorf("Expected etag='etag-1', got %v", part0["etag"])
406- }
407-}
408409-// TestGet_UsesPresignedURLDirectly verifies that Get() doesn't add auth headers to presigned URLs
410-// This test would have caught the presigned URL authentication bug
411-func TestGet_UsesPresignedURLDirectly(t *testing.T) {
412- blobData := []byte("test blob content")
413- var s3ReceivedAuthHeader string
414-415- // Mock S3 server that rejects requests with Authorization header
416- s3Server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
417- s3ReceivedAuthHeader = r.Header.Get("Authorization")
418-419- // Presigned URLs should NOT have Authorization header
420- if s3ReceivedAuthHeader != "" {
421- t.Errorf("S3 received Authorization header: %s (should be empty for presigned URLs)", s3ReceivedAuthHeader)
422- w.WriteHeader(http.StatusForbidden)
423- w.Write([]byte(`<?xml version="1.0"?><Error><Code>SignatureDoesNotMatch</Code></Error>`))
424- return
425- }
426-427- // Return blob data
428- w.WriteHeader(http.StatusOK)
429- w.Write(blobData)
430- }))
431- defer s3Server.Close()
432-433- // Mock hold service that returns presigned S3 URL
434- holdServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
435- // Return presigned URL pointing to S3 server
436- w.Header().Set("Content-Type", "application/json")
437- w.WriteHeader(http.StatusOK)
438- resp := map[string]string{
439- "url": s3Server.URL + "/blob?X-Amz-Signature=fake-signature",
440- }
441- json.NewEncoder(w).Encode(resp)
442- }))
443- defer holdServer.Close()
444-445- // Create store with service token in context
446- ctx := &RegistryContext{
447- DID: "did:plc:test",
448- HoldDID: "did:web:hold.example.com",
449- PDSEndpoint: "https://pds.example.com",
450- Repository: "test-repo",
451- ServiceToken: "test-service-token", // Service token from middleware
452- }
453- store := NewProxyBlobStore(ctx)
454- store.holdURL = holdServer.URL
455-456- // Call Get()
457- dgst := digest.FromBytes(blobData)
458- retrieved, err := store.Get(context.Background(), dgst)
459 if err != nil {
460- t.Fatalf("Get() failed: %v", err)
461 }
462463- // Verify correct data was retrieved
464- if string(retrieved) != string(blobData) {
465- t.Errorf("Expected data=%s, got %s", string(blobData), string(retrieved))
466- }
467-468- // Verify S3 received NO Authorization header
469- if s3ReceivedAuthHeader != "" {
470- t.Errorf("S3 should not receive Authorization header for presigned URLs, got: %s", s3ReceivedAuthHeader)
471 }
472}
473474-// TestOpen_UsesPresignedURLDirectly verifies that Open() doesn't add auth headers to presigned URLs
475-// This test would have caught the presigned URL authentication bug
476-func TestOpen_UsesPresignedURLDirectly(t *testing.T) {
477- blobData := []byte("test blob stream content")
478- var s3ReceivedAuthHeader string
479-480- // Mock S3 server
481- s3Server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
482- s3ReceivedAuthHeader = r.Header.Get("Authorization")
483-484- // Presigned URLs should NOT have Authorization header
485- if s3ReceivedAuthHeader != "" {
486- t.Errorf("S3 received Authorization header: %s (should be empty)", s3ReceivedAuthHeader)
487- w.WriteHeader(http.StatusForbidden)
488- return
489- }
490-491- w.WriteHeader(http.StatusOK)
492- w.Write(blobData)
493- }))
494- defer s3Server.Close()
495-496- // Mock hold service
497- holdServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
498- w.Header().Set("Content-Type", "application/json")
499- w.WriteHeader(http.StatusOK)
500- json.NewEncoder(w).Encode(map[string]string{
501- "url": s3Server.URL + "/blob?X-Amz-Signature=fake",
502- })
503- }))
504- defer holdServer.Close()
505-506- // Create store with service token in context
507- ctx := &RegistryContext{
508- DID: "did:plc:test",
509- HoldDID: "did:web:hold.example.com",
510- PDSEndpoint: "https://pds.example.com",
511- Repository: "test-repo",
512- ServiceToken: "test-service-token", // Service token from middleware
513- }
514- store := NewProxyBlobStore(ctx)
515- store.holdURL = holdServer.URL
516-517- // Call Open()
518- dgst := digest.FromBytes(blobData)
519- reader, err := store.Open(context.Background(), dgst)
520- if err != nil {
521- t.Fatalf("Open() failed: %v", err)
522- }
523- defer reader.Close()
524-525- // Verify S3 received NO Authorization header
526- if s3ReceivedAuthHeader != "" {
527- t.Errorf("S3 should not receive Authorization header for presigned URLs, got: %s", s3ReceivedAuthHeader)
528- }
529-}
530-531-// TestMultipartEndpoints_CorrectURLs verifies all multipart XRPC endpoints use correct URLs
532-// This would have caught the old com.atproto.repo.uploadBlob vs new io.atcr.hold.* endpoints
533-func TestMultipartEndpoints_CorrectURLs(t *testing.T) {
534 tests := []struct {
535- name string
536- testFunc func(*ProxyBlobStore) error
537- expectedPath string
538 }{
539- {
540- name: "startMultipartUpload",
541- testFunc: func(store *ProxyBlobStore) error {
542- _, err := store.startMultipartUpload(context.Background(), "sha256:test")
543- return err
544- },
545- expectedPath: atproto.HoldInitiateUpload,
546- },
547- {
548- name: "getPartUploadInfo",
549- testFunc: func(store *ProxyBlobStore) error {
550- _, err := store.getPartUploadInfo(context.Background(), "sha256:test", "upload-123", 1)
551- return err
552- },
553- expectedPath: atproto.HoldGetPartUploadURL,
554- },
555- {
556- name: "completeMultipartUpload",
557- testFunc: func(store *ProxyBlobStore) error {
558- parts := []CompletedPart{{PartNumber: 1, ETag: "etag1"}}
559- return store.completeMultipartUpload(context.Background(), "sha256:test", "upload-123", parts)
560- },
561- expectedPath: atproto.HoldCompleteUpload,
562- },
563- {
564- name: "abortMultipartUpload",
565- testFunc: func(store *ProxyBlobStore) error {
566- return store.abortMultipartUpload(context.Background(), "sha256:test", "upload-123")
567- },
568- expectedPath: atproto.HoldAbortUpload,
569- },
570 }
571572 for _, tt := range tests {
573 t.Run(tt.name, func(t *testing.T) {
574- var capturedPath string
575-576- // Mock hold service that captures request path
577- holdServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
578- capturedPath = r.URL.Path
579-580- // Return success response
581- w.Header().Set("Content-Type", "application/json")
582- w.WriteHeader(http.StatusOK)
583- resp := map[string]string{
584- "uploadId": "test-upload-id",
585- "url": "https://s3.example.com/presigned",
586- }
587- json.NewEncoder(w).Encode(resp)
588- }))
589- defer holdServer.Close()
590-591- // Create store with service token in context
592- ctx := &RegistryContext{
593- DID: "did:plc:test",
594- HoldDID: "did:web:hold.example.com",
595- PDSEndpoint: "https://pds.example.com",
596- Repository: "test-repo",
597- ServiceToken: "test-service-token", // Service token from middleware
598- }
599- store := NewProxyBlobStore(ctx)
600- store.holdURL = holdServer.URL
601-602- // Call the function
603- _ = tt.testFunc(store) // Ignore error, we just care about the URL
604-605- // Verify correct endpoint was called
606- if capturedPath != tt.expectedPath {
607- t.Errorf("Expected endpoint %s, got %s", tt.expectedPath, capturedPath)
608- }
609-610- // Verify it's NOT the old endpoint
611- if strings.Contains(capturedPath, "com.atproto.repo.uploadBlob") {
612- t.Error("Still using old com.atproto.repo.uploadBlob endpoint!")
613 }
614 })
615 }
616}
0000000000
···1package storage
23import (
04 "encoding/base64"
05 "fmt"
006 "strings"
7 "testing"
8 "time"
910 "atcr.io/pkg/atproto"
11+ "atcr.io/pkg/auth"
012)
1314+// TestGetServiceToken_CachingLogic tests the global service token caching mechanism
15+// These tests use the global auth cache functions directly
16func TestGetServiceToken_CachingLogic(t *testing.T) {
17+ userDID := "did:plc:cache-test"
18 holdDID := "did:web:hold.example.com"
1920 // Test 1: Empty cache - invalidate any existing token
21+ auth.InvalidateServiceToken(userDID, holdDID)
22+ cachedToken, _ := auth.GetServiceToken(userDID, holdDID)
23 if cachedToken != "" {
24 t.Error("Expected empty cache at start")
25 }
2627 // Test 2: Insert token into cache
28 // Create a JWT-like token with exp claim for testing
029 testPayload := fmt.Sprintf(`{"exp":%d}`, time.Now().Add(50*time.Second).Unix())
30 testToken := "eyJhbGciOiJIUzI1NiJ9." + base64URLEncode(testPayload) + ".signature"
3132+ err := auth.SetServiceToken(userDID, holdDID, testToken)
33 if err != nil {
34 t.Fatalf("Failed to set service token: %v", err)
35 }
3637 // Test 3: Retrieve from cache
38+ cachedToken, expiresAt := auth.GetServiceToken(userDID, holdDID)
39 if cachedToken == "" {
40 t.Fatal("Expected token to be in cache")
41 }
···51 // Test 4: Expired token - GetServiceToken automatically removes it
52 expiredPayload := fmt.Sprintf(`{"exp":%d}`, time.Now().Add(-1*time.Hour).Unix())
53 expiredToken := "eyJhbGciOiJIUzI1NiJ9." + base64URLEncode(expiredPayload) + ".signature"
54+ auth.SetServiceToken(userDID, holdDID, expiredToken)
5556 // GetServiceToken should return empty string for expired token
57+ cachedToken, _ = auth.GetServiceToken(userDID, holdDID)
58 if cachedToken != "" {
59 t.Error("Expected expired token to be removed from cache")
60 }
···65 return strings.TrimRight(base64.URLEncoding.EncodeToString([]byte(data)), "=")
66}
6768+// mockUserContextForProxy creates a mock auth.UserContext for proxy blob store testing.
69+// It sets up both the user identity and target info, and configures test helpers
70+// to bypass network calls.
71+func mockUserContextForProxy(did, holdDID, pdsEndpoint, repository string) *auth.UserContext {
72+ userCtx := auth.NewUserContext(did, "oauth", "PUT", nil)
73+ userCtx.SetTarget(did, "test.handle", pdsEndpoint, repository, holdDID)
00007475+ // Bypass PDS resolution (avoids network calls)
76+ userCtx.SetPDSForTest("test.handle", pdsEndpoint)
7778+ // Set up mock authorizer that allows access
79+ userCtx.SetAuthorizerForTest(auth.NewMockHoldAuthorizer())
08081+ // Set default hold DID for push resolution
82+ userCtx.SetDefaultHoldDIDForTest(holdDID)
008384+ return userCtx
00085}
8687+// mockUserContextForProxyWithToken creates a mock UserContext with a pre-populated service token.
88+func mockUserContextForProxyWithToken(did, holdDID, pdsEndpoint, repository, serviceToken string) *auth.UserContext {
89+ userCtx := mockUserContextForProxy(did, holdDID, pdsEndpoint, repository)
90+ userCtx.SetServiceTokenForTest(holdDID, serviceToken)
91+ return userCtx
00000000000000000000000000000000000000092}
9394+// TestResolveHoldURL tests DID to URL conversion (pure function)
00000000000000000000000000000000000000000000000095func TestResolveHoldURL(t *testing.T) {
96 tests := []struct {
97 name string
···99 expected string
100 }{
101 {
102+ name: "did:web with http (localhost)",
103 holdDID: "did:web:localhost:8080",
104 expected: "http://localhost:8080",
105 },
···127128// TestServiceTokenCacheExpiry tests that expired cached tokens are not used
129func TestServiceTokenCacheExpiry(t *testing.T) {
130+ userDID := "did:plc:expiry-test"
131 holdDID := "did:web:hold.example.com"
132133 // Insert expired token
134 expiredPayload := fmt.Sprintf(`{"exp":%d}`, time.Now().Add(-1*time.Hour).Unix())
135 expiredToken := "eyJhbGciOiJIUzI1NiJ9." + base64URLEncode(expiredPayload) + ".signature"
136+ auth.SetServiceToken(userDID, holdDID, expiredToken)
137138 // GetServiceToken should automatically remove expired tokens
139+ cachedToken, expiresAt := auth.GetServiceToken(userDID, holdDID)
140141 // Should return empty string for expired token
142 if cachedToken != "" {
···171172// TestNewProxyBlobStore tests ProxyBlobStore creation
173func TestNewProxyBlobStore(t *testing.T) {
174+ userCtx := mockUserContextForProxy(
175+ "did:plc:test",
176+ "did:web:hold.example.com",
177+ "https://pds.example.com",
178+ "test-repo",
179+ )
180181+ store := NewProxyBlobStore(userCtx)
182183 if store == nil {
184 t.Fatal("Expected non-nil ProxyBlobStore")
185 }
186187+ if store.ctx != userCtx {
188 t.Error("Expected context to be set")
189 }
190···209210 testPayload := fmt.Sprintf(`{"exp":%d}`, time.Now().Add(50*time.Second).Unix())
211 testTokenStr := "eyJhbGciOiJIUzI1NiJ9." + base64URLEncode(testPayload) + ".signature"
212+ auth.SetServiceToken(userDID, holdDID, testTokenStr)
213214 for b.Loop() {
215+ cachedToken, expiresAt := auth.GetServiceToken(userDID, holdDID)
216217 if cachedToken == "" || time.Now().After(expiresAt) {
218 b.Error("Cache miss in benchmark")
···220 }
221}
222223+// TestParseJWTExpiry tests JWT expiry parsing
224+func TestParseJWTExpiry(t *testing.T) {
225+ // Create a JWT with known expiry
226+ futureTime := time.Now().Add(1 * time.Hour).Unix()
227+ testPayload := fmt.Sprintf(`{"exp":%d}`, futureTime)
228+ testToken := "eyJhbGciOiJIUzI1NiJ9." + base64URLEncode(testPayload) + ".signature"
000000000000000000000000000000000000000000000000000000000000000000000000000000229230+ expiry, err := auth.ParseJWTExpiry(testToken)
0000000000000000000000000000000000000000000000000231 if err != nil {
232+ t.Fatalf("ParseJWTExpiry failed: %v", err)
233 }
234235+ // Verify expiry is close to what we set (within 1 second tolerance)
236+ expectedExpiry := time.Unix(futureTime, 0)
237+ diff := expiry.Sub(expectedExpiry)
238+ if diff < -time.Second || diff > time.Second {
239+ t.Errorf("Expiry mismatch: expected %v, got %v", expectedExpiry, expiry)
000240 }
241}
242243+// TestParseJWTExpiry_InvalidToken tests error handling for invalid tokens
244+func TestParseJWTExpiry_InvalidToken(t *testing.T) {
0000000000000000000000000000000000000000000000000000000000245 tests := []struct {
246+ name string
247+ token string
0248 }{
249+ {"empty token", ""},
250+ {"single part", "header"},
251+ {"two parts", "header.payload"},
252+ {"invalid base64 payload", "header.!!!.signature"},
253+ {"missing exp claim", "eyJhbGciOiJIUzI1NiJ9." + base64URLEncode(`{"sub":"test"}`) + ".sig"},
00000000000000000000000000254 }
255256 for _, tt := range tests {
257 t.Run(tt.name, func(t *testing.T) {
258+ _, err := auth.ParseJWTExpiry(tt.token)
259+ if err == nil {
260+ t.Error("Expected error for invalid token")
000000000000000000000000000000000000261 }
262 })
263 }
264}
265+266+// Note: Tests for doAuthenticatedRequest, Get, Open, completeMultipartUpload, etc.
267+// require complex dependency mocking (OAuth refresher, PDS resolution, HoldAuthorizer).
268+// These should be tested at the integration level with proper infrastructure.
269+//
270+// The current unit tests cover:
271+// - Global service token cache (auth.GetServiceToken, auth.SetServiceToken, etc.)
272+// - URL resolution (atproto.ResolveHoldURL)
273+// - JWT parsing (auth.ParseJWTExpiry)
274+// - Store construction (NewProxyBlobStore)
+39-74
pkg/appview/storage/routing_repository.go
···67import (
8 "context"
09 "log/slog"
10- "sync"
11012 "github.com/distribution/distribution/v3"
013)
1415-// RoutingRepository routes manifests to ATProto and blobs to external hold service
16-// The registry (AppView) is stateless and NEVER stores blobs locally
017type RoutingRepository struct {
18 distribution.Repository
19- Ctx *RegistryContext // All context and services (exported for token updates)
20- mu sync.Mutex // Protects manifestStore and blobStore
21- manifestStore *ManifestStore // Cached manifest store instance
22- blobStore *ProxyBlobStore // Cached blob store instance
23}
2425// NewRoutingRepository creates a new routing repository
26-func NewRoutingRepository(baseRepo distribution.Repository, ctx *RegistryContext) *RoutingRepository {
27 return &RoutingRepository{
28 Repository: baseRepo,
29- Ctx: ctx,
030 }
31}
3233// Manifests returns the ATProto-backed manifest service
34func (r *RoutingRepository) Manifests(ctx context.Context, options ...distribution.ManifestServiceOption) (distribution.ManifestService, error) {
35- r.mu.Lock()
36- // Create or return cached manifest store
37- if r.manifestStore == nil {
38- // Ensure blob store is created first (needed for label extraction during push)
39- // Release lock while calling Blobs to avoid deadlock
40- r.mu.Unlock()
41- blobStore := r.Blobs(ctx)
42- r.mu.Lock()
43-44- // Double-check after reacquiring lock (another goroutine might have set it)
45- if r.manifestStore == nil {
46- r.manifestStore = NewManifestStore(r.Ctx, blobStore)
47- }
48- }
49- manifestStore := r.manifestStore
50- r.mu.Unlock()
51-52- return manifestStore, nil
53}
5455// Blobs returns a proxy blob store that routes to external hold service
56-// The registry (AppView) NEVER stores blobs locally - all blobs go through hold service
57func (r *RoutingRepository) Blobs(ctx context.Context) distribution.BlobStore {
58- r.mu.Lock()
59- // Return cached blob store if available
60- if r.blobStore != nil {
61- blobStore := r.blobStore
62- r.mu.Unlock()
63- slog.Debug("Returning cached blob store", "component", "storage/blobs", "did", r.Ctx.DID, "repo", r.Ctx.Repository)
64- return blobStore
65- }
66-67- // Determine if this is a pull (GET) or push (PUT/POST/HEAD/etc) operation
68- // Pull operations use the historical hold DID from the database (blobs are where they were pushed)
69- // Push operations use the discovery-based hold DID from user's profile/default
70- // This allows users to change their default hold and have new pushes go there
71- isPull := false
72- if method, ok := ctx.Value("http.request.method").(string); ok {
73- isPull = method == "GET"
74- }
75-76- holdDID := r.Ctx.HoldDID // Default to discovery-based DID
77- holdSource := "discovery"
78-79- // Only query database for pull operations
80- if isPull && r.Ctx.Database != nil {
81- // Query database for the latest manifest's hold DID
82- if dbHoldDID, err := r.Ctx.Database.GetLatestHoldDIDForRepo(r.Ctx.DID, r.Ctx.Repository); err == nil && dbHoldDID != "" {
83- // Use hold DID from database (pull case - use historical reference)
84- holdDID = dbHoldDID
85- holdSource = "database"
86- slog.Debug("Using hold from database manifest (pull)", "component", "storage/blobs", "did", r.Ctx.DID, "repo", r.Ctx.Repository, "hold", dbHoldDID)
87- } else if err != nil {
88- // Log error but don't fail - fall back to discovery-based DID
89- slog.Warn("Failed to query database for hold DID", "component", "storage/blobs", "error", err)
90- }
91- // If dbHoldDID is empty (no manifests yet), fall through to use discovery-based DID
92 }
9394 if holdDID == "" {
95- // This should never happen if middleware is configured correctly
96- panic("hold DID not set in RegistryContext - ensure default_hold_did is configured in middleware")
97 }
9899- slog.Debug("Using hold DID for blobs", "component", "storage/blobs", "did", r.Ctx.DID, "repo", r.Ctx.Repository, "hold", holdDID, "source", holdSource)
100-101- // Update context with the correct hold DID (may be from database or discovered)
102- r.Ctx.HoldDID = holdDID
103104- // Create and cache proxy blob store
105- r.blobStore = NewProxyBlobStore(r.Ctx)
106- blobStore := r.blobStore
107- r.mu.Unlock()
108- return blobStore
109}
110111// Tags returns the tag service
112// Tags are stored in ATProto as io.atcr.tag records
113func (r *RoutingRepository) Tags(ctx context.Context) distribution.TagService {
114- return NewTagStore(r.Ctx.ATProtoClient, r.Ctx.Repository)
0000000000000000115}
···67import (
8 "context"
9+ "database/sql"
10 "log/slog"
01112+ "atcr.io/pkg/auth"
13 "github.com/distribution/distribution/v3"
14+ "github.com/distribution/reference"
15)
1617+// RoutingRepository routes manifests to ATProto and blobs to external hold service.
18+// The registry (AppView) is stateless and NEVER stores blobs locally.
19+// A new instance is created per HTTP request - no caching or synchronization needed.
20type RoutingRepository struct {
21 distribution.Repository
22+ userCtx *auth.UserContext
23+ sqlDB *sql.DB
0024}
2526// NewRoutingRepository creates a new routing repository
27+func NewRoutingRepository(baseRepo distribution.Repository, userCtx *auth.UserContext, sqlDB *sql.DB) *RoutingRepository {
28 return &RoutingRepository{
29 Repository: baseRepo,
30+ userCtx: userCtx,
31+ sqlDB: sqlDB,
32 }
33}
3435// Manifests returns the ATProto-backed manifest service
36func (r *RoutingRepository) Manifests(ctx context.Context, options ...distribution.ManifestServiceOption) (distribution.ManifestService, error) {
37+ // blobStore used to fetch labels from th
38+ blobStore := r.Blobs(ctx)
39+ return NewManifestStore(r.userCtx, blobStore, r.sqlDB), nil
00000000000000040}
4142// Blobs returns a proxy blob store that routes to external hold service
043func (r *RoutingRepository) Blobs(ctx context.Context) distribution.BlobStore {
44+ // Resolve hold DID: pull uses DB lookup, push uses profile discovery
45+ holdDID, err := r.userCtx.ResolveHoldDID(ctx, r.sqlDB)
46+ if err != nil {
47+ slog.Warn("Failed to resolve hold DID", "component", "storage/blobs", "error", err)
48+ holdDID = r.userCtx.TargetHoldDID
0000000000000000000000000000049 }
5051 if holdDID == "" {
52+ panic("hold DID not set - ensure default_hold_did is configured in middleware")
053 }
5455+ slog.Debug("Using hold DID for blobs", "component", "storage/blobs", "did", r.userCtx.TargetOwnerDID, "repo", r.userCtx.TargetRepo, "hold", holdDID, "action", r.userCtx.Action.String())
0005657+ return NewProxyBlobStore(r.userCtx)
000058}
5960// Tags returns the tag service
61// Tags are stored in ATProto as io.atcr.tag records
62func (r *RoutingRepository) Tags(ctx context.Context) distribution.TagService {
63+ return NewTagStore(r.userCtx.GetATProtoClient(), r.userCtx.TargetRepo)
64+}
65+66+// Named returns a reference to the repository name.
67+// If the base repository is set, it delegates to the base.
68+// Otherwise, it constructs a name from the user context.
69+func (r *RoutingRepository) Named() reference.Named {
70+ if r.Repository != nil {
71+ return r.Repository.Named()
72+ }
73+ // Construct from user context
74+ name, err := reference.WithName(r.userCtx.TargetRepo)
75+ if err != nil {
76+ // Fallback: return a simple reference
77+ name, _ = reference.WithName("unknown")
78+ }
79+ return name
80}
+179-301
pkg/appview/storage/routing_repository_test.go
···23import (
4 "context"
5- "sync"
6 "testing"
78- "github.com/distribution/distribution/v3"
9 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/require"
1112 "atcr.io/pkg/atproto"
013)
1415-// mockDatabase is a simple mock for testing
16-type mockDatabase struct {
17- holdDID string
18- err error
19-}
02021-func (m *mockDatabase) IncrementPullCount(did, repository string) error {
22- return nil
23-}
000002425-func (m *mockDatabase) IncrementPushCount(did, repository string) error {
26- return nil
27}
2829-func (m *mockDatabase) GetLatestHoldDIDForRepo(did, repository string) (string, error) {
30- if m.err != nil {
31- return "", m.err
32- }
33- return m.holdDID, nil
34}
3536func TestNewRoutingRepository(t *testing.T) {
37- ctx := &RegistryContext{
38- DID: "did:plc:test123",
39- Repository: "debian",
40- HoldDID: "did:web:hold01.atcr.io",
41- ATProtoClient: &atproto.Client{},
42- }
43-44- repo := NewRoutingRepository(nil, ctx)
004546- if repo.Ctx.DID != "did:plc:test123" {
47- t.Errorf("Expected DID %q, got %q", "did:plc:test123", repo.Ctx.DID)
48- }
4950- if repo.Ctx.Repository != "debian" {
51- t.Errorf("Expected repository %q, got %q", "debian", repo.Ctx.Repository)
52 }
5354- if repo.manifestStore != nil {
55- t.Error("Expected manifestStore to be nil initially")
56 }
5758- if repo.blobStore != nil {
59- t.Error("Expected blobStore to be nil initially")
60 }
61}
6263// TestRoutingRepository_Manifests tests the Manifests() method
64func TestRoutingRepository_Manifests(t *testing.T) {
65- ctx := &RegistryContext{
66- DID: "did:plc:test123",
67- Repository: "myapp",
68- HoldDID: "did:web:hold01.atcr.io",
69- ATProtoClient: atproto.NewClient("https://pds.example.com", "did:plc:test123", ""),
70- }
00007172- repo := NewRoutingRepository(nil, ctx)
73 manifestService, err := repo.Manifests(context.Background())
7475 require.NoError(t, err)
76 assert.NotNil(t, manifestService)
77-78- // Verify the manifest store is cached
79- assert.NotNil(t, repo.manifestStore, "manifest store should be cached")
80-81- // Call again and verify we get the same instance
82- manifestService2, err := repo.Manifests(context.Background())
83- require.NoError(t, err)
84- assert.Same(t, manifestService, manifestService2, "should return cached manifest store")
85}
8687-// TestRoutingRepository_ManifestStoreCaching tests that manifest store is cached
88-func TestRoutingRepository_ManifestStoreCaching(t *testing.T) {
89- ctx := &RegistryContext{
90- DID: "did:plc:test123",
91- Repository: "myapp",
92- HoldDID: "did:web:hold01.atcr.io",
93- ATProtoClient: atproto.NewClient("https://pds.example.com", "did:plc:test123", ""),
94- }
95-96- repo := NewRoutingRepository(nil, ctx)
97-98- // First call creates the store
99- store1, err := repo.Manifests(context.Background())
100- require.NoError(t, err)
101- assert.NotNil(t, store1)
102-103- // Second call returns cached store
104- store2, err := repo.Manifests(context.Background())
105- require.NoError(t, err)
106- assert.Same(t, store1, store2, "should return cached manifest store instance")
107-108- // Verify internal cache
109- assert.NotNil(t, repo.manifestStore)
110-}
111-112-// TestRoutingRepository_Blobs_PullUsesDatabase tests that GET (pull) uses database hold DID
113-func TestRoutingRepository_Blobs_PullUsesDatabase(t *testing.T) {
114- dbHoldDID := "did:web:database.hold.io"
115- discoveryHoldDID := "did:web:discovery.hold.io"
116-117- ctx := &RegistryContext{
118- DID: "did:plc:test123",
119- Repository: "myapp",
120- HoldDID: discoveryHoldDID, // Discovery-based hold (should be overridden for pull)
121- ATProtoClient: atproto.NewClient("https://pds.example.com", "did:plc:test123", ""),
122- Database: &mockDatabase{holdDID: dbHoldDID},
123- }
124-125- repo := NewRoutingRepository(nil, ctx)
126-127- // Create context with GET method (pull operation)
128- pullCtx := context.WithValue(context.Background(), "http.request.method", "GET")
129- blobStore := repo.Blobs(pullCtx)
130-131- assert.NotNil(t, blobStore)
132- // Verify the hold DID was updated to use the database value for pull
133- assert.Equal(t, dbHoldDID, repo.Ctx.HoldDID, "pull (GET) should use database hold DID")
134-}
135-136-// TestRoutingRepository_Blobs_PushUsesDiscovery tests that push operations use discovery hold DID
137-func TestRoutingRepository_Blobs_PushUsesDiscovery(t *testing.T) {
138- dbHoldDID := "did:web:database.hold.io"
139- discoveryHoldDID := "did:web:discovery.hold.io"
140-141- testCases := []struct {
142- name string
143- method string
144- }{
145- {"PUT", "PUT"},
146- {"POST", "POST"},
147- {"HEAD", "HEAD"},
148- {"PATCH", "PATCH"},
149- {"DELETE", "DELETE"},
150- }
151-152- for _, tc := range testCases {
153- t.Run(tc.name, func(t *testing.T) {
154- ctx := &RegistryContext{
155- DID: "did:plc:test123",
156- Repository: "myapp-" + tc.method, // Unique repo to avoid caching
157- HoldDID: discoveryHoldDID,
158- ATProtoClient: atproto.NewClient("https://pds.example.com", "did:plc:test123", ""),
159- Database: &mockDatabase{holdDID: dbHoldDID},
160- }
161-162- repo := NewRoutingRepository(nil, ctx)
163-164- // Create context with push method
165- pushCtx := context.WithValue(context.Background(), "http.request.method", tc.method)
166- blobStore := repo.Blobs(pushCtx)
167-168- assert.NotNil(t, blobStore)
169- // Verify the hold DID remains the discovery-based one for push operations
170- assert.Equal(t, discoveryHoldDID, repo.Ctx.HoldDID, "%s should use discovery hold DID, not database", tc.method)
171- })
172- }
173-}
174-175-// TestRoutingRepository_Blobs_NoMethodUsesDiscovery tests that missing method defaults to discovery
176-func TestRoutingRepository_Blobs_NoMethodUsesDiscovery(t *testing.T) {
177- dbHoldDID := "did:web:database.hold.io"
178- discoveryHoldDID := "did:web:discovery.hold.io"
179-180- ctx := &RegistryContext{
181- DID: "did:plc:test123",
182- Repository: "myapp-nomethod",
183- HoldDID: discoveryHoldDID,
184- ATProtoClient: atproto.NewClient("https://pds.example.com", "did:plc:test123", ""),
185- Database: &mockDatabase{holdDID: dbHoldDID},
186- }
187-188- repo := NewRoutingRepository(nil, ctx)
189-190- // Context without HTTP method (shouldn't happen in practice, but test defensive behavior)
191- blobStore := repo.Blobs(context.Background())
192-193- assert.NotNil(t, blobStore)
194- // Without method, should default to discovery (safer for push scenarios)
195- assert.Equal(t, discoveryHoldDID, repo.Ctx.HoldDID, "missing method should use discovery hold DID")
196-}
197-198-// TestRoutingRepository_Blobs_WithoutDatabase tests blob store with discovery-based hold
199-func TestRoutingRepository_Blobs_WithoutDatabase(t *testing.T) {
200- discoveryHoldDID := "did:web:discovery.hold.io"
201-202- ctx := &RegistryContext{
203- DID: "did:plc:nocache456",
204- Repository: "uncached-app",
205- HoldDID: discoveryHoldDID,
206- ATProtoClient: atproto.NewClient("https://pds.example.com", "did:plc:nocache456", ""),
207- Database: nil, // No database
208- }
209-210- repo := NewRoutingRepository(nil, ctx)
211- blobStore := repo.Blobs(context.Background())
212-213- assert.NotNil(t, blobStore)
214- // Verify the hold DID remains the discovery-based one
215- assert.Equal(t, discoveryHoldDID, repo.Ctx.HoldDID, "should use discovery-based hold DID")
216-}
217-218-// TestRoutingRepository_Blobs_DatabaseEmptyFallback tests fallback when database returns empty hold DID
219-func TestRoutingRepository_Blobs_DatabaseEmptyFallback(t *testing.T) {
220- discoveryHoldDID := "did:web:discovery.hold.io"
221-222- ctx := &RegistryContext{
223- DID: "did:plc:test123",
224- Repository: "newapp",
225- HoldDID: discoveryHoldDID,
226- ATProtoClient: atproto.NewClient("https://pds.example.com", "did:plc:test123", ""),
227- Database: &mockDatabase{holdDID: ""}, // Empty string (no manifests yet)
228- }
229230- repo := NewRoutingRepository(nil, ctx)
231 blobStore := repo.Blobs(context.Background())
232233 assert.NotNil(t, blobStore)
234- // Verify the hold DID falls back to discovery-based
235- assert.Equal(t, discoveryHoldDID, repo.Ctx.HoldDID, "should fall back to discovery-based hold DID when database returns empty")
236-}
237-238-// TestRoutingRepository_BlobStoreCaching tests that blob store is cached
239-func TestRoutingRepository_BlobStoreCaching(t *testing.T) {
240- ctx := &RegistryContext{
241- DID: "did:plc:test123",
242- Repository: "myapp",
243- HoldDID: "did:web:hold01.atcr.io",
244- ATProtoClient: atproto.NewClient("https://pds.example.com", "did:plc:test123", ""),
245- }
246-247- repo := NewRoutingRepository(nil, ctx)
248-249- // First call creates the store
250- store1 := repo.Blobs(context.Background())
251- assert.NotNil(t, store1)
252-253- // Second call returns cached store
254- store2 := repo.Blobs(context.Background())
255- assert.Same(t, store1, store2, "should return cached blob store instance")
256-257- // Verify internal cache
258- assert.NotNil(t, repo.blobStore)
259}
260261// TestRoutingRepository_Blobs_PanicOnEmptyHoldDID tests panic when hold DID is empty
262func TestRoutingRepository_Blobs_PanicOnEmptyHoldDID(t *testing.T) {
263- // Use a unique DID/repo to ensure no cache entry exists
264- ctx := &RegistryContext{
265- DID: "did:plc:emptyholdtest999",
266- Repository: "empty-hold-app",
267- HoldDID: "", // Empty hold DID should panic
268- ATProtoClient: atproto.NewClient("https://pds.example.com", "did:plc:emptyholdtest999", ""),
269- }
270271- repo := NewRoutingRepository(nil, ctx)
272273 // Should panic with empty hold DID
274 assert.Panics(t, func() {
···278279// TestRoutingRepository_Tags tests the Tags() method
280func TestRoutingRepository_Tags(t *testing.T) {
281- ctx := &RegistryContext{
282- DID: "did:plc:test123",
283- Repository: "myapp",
284- HoldDID: "did:web:hold01.atcr.io",
285- ATProtoClient: atproto.NewClient("https://pds.example.com", "did:plc:test123", ""),
286- }
0000287288- repo := NewRoutingRepository(nil, ctx)
289 tagService := repo.Tags(context.Background())
290291 assert.NotNil(t, tagService)
292293- // Call again and verify we get a new instance (Tags() doesn't cache)
294 tagService2 := repo.Tags(context.Background())
295 assert.NotNil(t, tagService2)
296- // Tags service is not cached, so each call creates a new instance
297}
298299-// TestRoutingRepository_ConcurrentAccess tests concurrent access to cached stores
300-func TestRoutingRepository_ConcurrentAccess(t *testing.T) {
301- ctx := &RegistryContext{
302- DID: "did:plc:test123",
303- Repository: "myapp",
304- HoldDID: "did:web:hold01.atcr.io",
305- ATProtoClient: atproto.NewClient("https://pds.example.com", "did:plc:test123", ""),
00000306 }
307308- repo := NewRoutingRepository(nil, ctx)
309-310- var wg sync.WaitGroup
311- numGoroutines := 10
00000000312313- // Track all manifest stores returned
314- manifestStores := make([]distribution.ManifestService, numGoroutines)
315- blobStores := make([]distribution.BlobStore, numGoroutines)
316317- // Concurrent access to Manifests()
318- for i := 0; i < numGoroutines; i++ {
319- wg.Add(1)
320- go func(index int) {
321- defer wg.Done()
322- store, err := repo.Manifests(context.Background())
323- require.NoError(t, err)
324- manifestStores[index] = store
325- }(i)
326 }
0327328- wg.Wait()
329-330- // Verify all stores are non-nil (due to race conditions, they may not all be the same instance)
331- for i := 0; i < numGoroutines; i++ {
332- assert.NotNil(t, manifestStores[i], "manifest store should not be nil")
0000333 }
334335- // After concurrent creation, subsequent calls should return the cached instance
336- cachedStore, err := repo.Manifests(context.Background())
337- require.NoError(t, err)
338- assert.NotNil(t, cachedStore)
339-340- // Concurrent access to Blobs()
341- for i := 0; i < numGoroutines; i++ {
342- wg.Add(1)
343- go func(index int) {
344- defer wg.Done()
345- blobStores[index] = repo.Blobs(context.Background())
346- }(i)
347- }
348349- wg.Wait()
0350351- // Verify all stores are non-nil (due to race conditions, they may not all be the same instance)
352- for i := 0; i < numGoroutines; i++ {
353- assert.NotNil(t, blobStores[i], "blob store should not be nil")
354 }
355-356- // After concurrent creation, subsequent calls should return the cached instance
357- cachedBlobStore := repo.Blobs(context.Background())
358- assert.NotNil(t, cachedBlobStore)
359}
360361-// TestRoutingRepository_Blobs_PullPriority tests that database hold DID takes priority for pull (GET)
362-func TestRoutingRepository_Blobs_PullPriority(t *testing.T) {
363- dbHoldDID := "did:web:database.hold.io"
364- discoveryHoldDID := "did:web:discovery.hold.io"
00000000365366- ctx := &RegistryContext{
367- DID: "did:plc:test123",
368- Repository: "myapp-priority",
369- HoldDID: discoveryHoldDID, // Discovery-based hold
370- ATProtoClient: atproto.NewClient("https://pds.example.com", "did:plc:test123", ""),
371- Database: &mockDatabase{holdDID: dbHoldDID}, // Database has a different hold DID
372- }
373374- repo := NewRoutingRepository(nil, ctx)
00375376- // For pull (GET), database should take priority
377- pullCtx := context.WithValue(context.Background(), "http.request.method", "GET")
378- blobStore := repo.Blobs(pullCtx)
0379380- assert.NotNil(t, blobStore)
381- // Database hold DID should take priority over discovery for pull operations
382- assert.Equal(t, dbHoldDID, repo.Ctx.HoldDID, "database hold DID should take priority over discovery for pull (GET)")
0000000000000000000000383}
···23import (
4 "context"
05 "testing"
607 "github.com/stretchr/testify/assert"
8 "github.com/stretchr/testify/require"
910 "atcr.io/pkg/atproto"
11+ "atcr.io/pkg/auth"
12)
1314+// mockUserContext creates a mock auth.UserContext for testing.
15+// It sets up both the user identity and target info, and configures
16+// test helpers to bypass network calls.
17+func mockUserContext(did, authMethod, httpMethod, targetOwnerDID, targetOwnerHandle, targetOwnerPDS, targetRepo, targetHoldDID string) *auth.UserContext {
18+ userCtx := auth.NewUserContext(did, authMethod, httpMethod, nil)
19+ userCtx.SetTarget(targetOwnerDID, targetOwnerHandle, targetOwnerPDS, targetRepo, targetHoldDID)
2021+ // Bypass PDS resolution (avoids network calls)
22+ userCtx.SetPDSForTest(targetOwnerHandle, targetOwnerPDS)
23+24+ // Set up mock authorizer that allows access
25+ userCtx.SetAuthorizerForTest(auth.NewMockHoldAuthorizer())
26+27+ // Set default hold DID for push resolution
28+ userCtx.SetDefaultHoldDIDForTest(targetHoldDID)
2930+ return userCtx
031}
3233+// mockUserContextWithToken creates a mock UserContext with a pre-populated service token.
34+func mockUserContextWithToken(did, authMethod, httpMethod, targetOwnerDID, targetOwnerHandle, targetOwnerPDS, targetRepo, targetHoldDID, serviceToken string) *auth.UserContext {
35+ userCtx := mockUserContext(did, authMethod, httpMethod, targetOwnerDID, targetOwnerHandle, targetOwnerPDS, targetRepo, targetHoldDID)
36+ userCtx.SetServiceTokenForTest(targetHoldDID, serviceToken)
37+ return userCtx
38}
3940func TestNewRoutingRepository(t *testing.T) {
41+ userCtx := mockUserContext(
42+ "did:plc:test123", // authenticated user
43+ "oauth", // auth method
44+ "GET", // HTTP method
45+ "did:plc:test123", // target owner
46+ "test.handle", // target owner handle
47+ "https://pds.example.com", // target owner PDS
48+ "debian", // repository
49+ "did:web:hold01.atcr.io", // hold DID
50+ )
5152+ repo := NewRoutingRepository(nil, userCtx, nil)
005354+ if repo.userCtx.TargetOwnerDID != "did:plc:test123" {
55+ t.Errorf("Expected TargetOwnerDID %q, got %q", "did:plc:test123", repo.userCtx.TargetOwnerDID)
56 }
5758+ if repo.userCtx.TargetRepo != "debian" {
59+ t.Errorf("Expected TargetRepo %q, got %q", "debian", repo.userCtx.TargetRepo)
60 }
6162+ if repo.userCtx.TargetHoldDID != "did:web:hold01.atcr.io" {
63+ t.Errorf("Expected TargetHoldDID %q, got %q", "did:web:hold01.atcr.io", repo.userCtx.TargetHoldDID)
64 }
65}
6667// TestRoutingRepository_Manifests tests the Manifests() method
68func TestRoutingRepository_Manifests(t *testing.T) {
69+ userCtx := mockUserContext(
70+ "did:plc:test123",
71+ "oauth",
72+ "GET",
73+ "did:plc:test123",
74+ "test.handle",
75+ "https://pds.example.com",
76+ "myapp",
77+ "did:web:hold01.atcr.io",
78+ )
7980+ repo := NewRoutingRepository(nil, userCtx, nil)
81 manifestService, err := repo.Manifests(context.Background())
8283 require.NoError(t, err)
84 assert.NotNil(t, manifestService)
0000000085}
8687+// TestRoutingRepository_Blobs tests the Blobs() method
88+func TestRoutingRepository_Blobs(t *testing.T) {
89+ userCtx := mockUserContext(
90+ "did:plc:test123",
91+ "oauth",
92+ "GET",
93+ "did:plc:test123",
94+ "test.handle",
95+ "https://pds.example.com",
96+ "myapp",
97+ "did:web:hold01.atcr.io",
98+ )
000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000099100+ repo := NewRoutingRepository(nil, userCtx, nil)
101 blobStore := repo.Blobs(context.Background())
102103 assert.NotNil(t, blobStore)
0000000000000000000000000104}
105106// TestRoutingRepository_Blobs_PanicOnEmptyHoldDID tests panic when hold DID is empty
107func TestRoutingRepository_Blobs_PanicOnEmptyHoldDID(t *testing.T) {
108+ // Create context without default hold and empty target hold
109+ userCtx := auth.NewUserContext("did:plc:emptyholdtest999", "oauth", "GET", nil)
110+ userCtx.SetTarget("did:plc:emptyholdtest999", "test.handle", "https://pds.example.com", "empty-hold-app", "")
111+ userCtx.SetPDSForTest("test.handle", "https://pds.example.com")
112+ userCtx.SetAuthorizerForTest(auth.NewMockHoldAuthorizer())
113+ // Intentionally NOT setting default hold DID
0114115+ repo := NewRoutingRepository(nil, userCtx, nil)
116117 // Should panic with empty hold DID
118 assert.Panics(t, func() {
···122123// TestRoutingRepository_Tags tests the Tags() method
124func TestRoutingRepository_Tags(t *testing.T) {
125+ userCtx := mockUserContext(
126+ "did:plc:test123",
127+ "oauth",
128+ "GET",
129+ "did:plc:test123",
130+ "test.handle",
131+ "https://pds.example.com",
132+ "myapp",
133+ "did:web:hold01.atcr.io",
134+ )
135136+ repo := NewRoutingRepository(nil, userCtx, nil)
137 tagService := repo.Tags(context.Background())
138139 assert.NotNil(t, tagService)
140141+ // Call again and verify we get a fresh instance (no caching)
142 tagService2 := repo.Tags(context.Background())
143 assert.NotNil(t, tagService2)
0144}
145146+// TestRoutingRepository_UserContext tests that UserContext fields are properly set
147+func TestRoutingRepository_UserContext(t *testing.T) {
148+ testCases := []struct {
149+ name string
150+ httpMethod string
151+ expectedAction auth.RequestAction
152+ }{
153+ {"GET request is pull", "GET", auth.ActionPull},
154+ {"HEAD request is pull", "HEAD", auth.ActionPull},
155+ {"PUT request is push", "PUT", auth.ActionPush},
156+ {"POST request is push", "POST", auth.ActionPush},
157+ {"DELETE request is push", "DELETE", auth.ActionPush},
158 }
159160+ for _, tc := range testCases {
161+ t.Run(tc.name, func(t *testing.T) {
162+ userCtx := mockUserContext(
163+ "did:plc:test123",
164+ "oauth",
165+ tc.httpMethod,
166+ "did:plc:test123",
167+ "test.handle",
168+ "https://pds.example.com",
169+ "myapp",
170+ "did:web:hold01.atcr.io",
171+ )
172173+ repo := NewRoutingRepository(nil, userCtx, nil)
00174175+ assert.Equal(t, tc.expectedAction, repo.userCtx.Action, "action should match HTTP method")
176+ })
0000000177 }
178+}
179180+// TestRoutingRepository_DifferentHoldDIDs tests routing with different hold DIDs
181+func TestRoutingRepository_DifferentHoldDIDs(t *testing.T) {
182+ testCases := []struct {
183+ name string
184+ holdDID string
185+ }{
186+ {"did:web hold", "did:web:hold01.atcr.io"},
187+ {"did:web with port", "did:web:localhost:8080"},
188+ {"did:plc hold", "did:plc:xyz123"},
189 }
190191+ for _, tc := range testCases {
192+ t.Run(tc.name, func(t *testing.T) {
193+ userCtx := mockUserContext(
194+ "did:plc:test123",
195+ "oauth",
196+ "PUT",
197+ "did:plc:test123",
198+ "test.handle",
199+ "https://pds.example.com",
200+ "myapp",
201+ tc.holdDID,
202+ )
0203204+ repo := NewRoutingRepository(nil, userCtx, nil)
205+ blobStore := repo.Blobs(context.Background())
206207+ assert.NotNil(t, blobStore, "should create blob store for %s", tc.holdDID)
208+ })
0209 }
0000210}
211212+// TestRoutingRepository_Named tests the Named() method
213+func TestRoutingRepository_Named(t *testing.T) {
214+ userCtx := mockUserContext(
215+ "did:plc:test123",
216+ "oauth",
217+ "GET",
218+ "did:plc:test123",
219+ "test.handle",
220+ "https://pds.example.com",
221+ "myapp",
222+ "did:web:hold01.atcr.io",
223+ )
224225+ repo := NewRoutingRepository(nil, userCtx, nil)
000000226227+ // Named() returns a reference.Named from the base repository
228+ // Since baseRepo is nil, this tests our implementation handles that case
229+ named := repo.Named()
230231+ // With nil base, Named() should return a name constructed from context
232+ assert.NotNil(t, named)
233+ assert.Contains(t, named.Name(), "myapp")
234+}
235236+// TestATProtoResolveHoldURL tests DID to URL resolution
237+func TestATProtoResolveHoldURL(t *testing.T) {
238+ tests := []struct {
239+ name string
240+ holdDID string
241+ expected string
242+ }{
243+ {
244+ name: "did:web simple domain",
245+ holdDID: "did:web:hold01.atcr.io",
246+ expected: "https://hold01.atcr.io",
247+ },
248+ {
249+ name: "did:web with port (localhost)",
250+ holdDID: "did:web:localhost:8080",
251+ expected: "http://localhost:8080",
252+ },
253+ }
254+255+ for _, tt := range tests {
256+ t.Run(tt.name, func(t *testing.T) {
257+ result := atproto.ResolveHoldURL(tt.holdDID)
258+ assert.Equal(t, tt.expected, result)
259+ })
260+ }
261}
···310 CreatedAt time.Time `json:"createdAt"`
311}
312313-314// SailorProfileRecord represents a user's profile with registry preferences
315// Stored in the user's PDS to configure default hold and other settings
316type SailorProfileRecord struct {
···310 CreatedAt time.Time `json:"createdAt"`
311}
3120313// SailorProfileRecord represents a user's profile with registry preferences
314// Stored in the user's PDS to configure default hold and other settings
315type SailorProfileRecord struct {
···1+// Package token provides service token caching and management for AppView.
2+// Service tokens are JWTs issued by a user's PDS to authorize AppView to
3+// act on their behalf when communicating with hold services. Tokens are
4+// cached with automatic expiry parsing and 10-second safety margins.
5+package auth
6+7+import (
8+ "log/slog"
9+ "sync"
10+ "time"
11+)
12+13+// serviceTokenEntry represents a cached service token
14+type serviceTokenEntry struct {
15+ token string
16+ expiresAt time.Time
17+ err error
18+ once sync.Once
19+}
20+21+// Global cache for service tokens (DID:HoldDID -> token)
22+// Service tokens are JWTs issued by a user's PDS to authorize AppView to act on their behalf
23+// when communicating with hold services. These tokens are scoped to specific holds and have
24+// limited lifetime (typically 60s, can request up to 5min).
25+var (
26+ globalServiceTokens = make(map[string]*serviceTokenEntry)
27+ globalServiceTokensMu sync.RWMutex
28+)
29+30+// GetServiceToken retrieves a cached service token for the given DID and hold DID
31+// Returns empty string if no valid cached token exists
32+func GetServiceToken(did, holdDID string) (token string, expiresAt time.Time) {
33+ cacheKey := did + ":" + holdDID
34+35+ globalServiceTokensMu.RLock()
36+ entry, exists := globalServiceTokens[cacheKey]
37+ globalServiceTokensMu.RUnlock()
38+39+ if !exists {
40+ return "", time.Time{}
41+ }
42+43+ // Check if token is still valid
44+ if time.Now().After(entry.expiresAt) {
45+ // Token expired, remove from cache
46+ globalServiceTokensMu.Lock()
47+ delete(globalServiceTokens, cacheKey)
48+ globalServiceTokensMu.Unlock()
49+ return "", time.Time{}
50+ }
51+52+ return entry.token, entry.expiresAt
53+}
54+55+// SetServiceToken stores a service token in the cache
56+// Automatically parses the JWT to extract the expiry time
57+// Applies a 10-second safety margin (cache expires 10s before actual JWT expiry)
58+func SetServiceToken(did, holdDID, token string) error {
59+ cacheKey := did + ":" + holdDID
60+61+ // Parse JWT to extract expiry (don't verify signature - we trust the PDS)
62+ expiry, err := ParseJWTExpiry(token)
63+ if err != nil {
64+ // If parsing fails, use default 50s TTL (conservative fallback)
65+ slog.Warn("Failed to parse JWT expiry, using default 50s", "error", err, "cacheKey", cacheKey)
66+ expiry = time.Now().Add(50 * time.Second)
67+ } else {
68+ // Apply 10s safety margin to avoid using nearly-expired tokens
69+ expiry = expiry.Add(-10 * time.Second)
70+ }
71+72+ globalServiceTokensMu.Lock()
73+ globalServiceTokens[cacheKey] = &serviceTokenEntry{
74+ token: token,
75+ expiresAt: expiry,
76+ }
77+ globalServiceTokensMu.Unlock()
78+79+ slog.Debug("Cached service token",
80+ "cacheKey", cacheKey,
81+ "expiresIn", time.Until(expiry).Round(time.Second))
82+83+ return nil
84+}
85+86+// InvalidateServiceToken removes a service token from the cache
87+// Used when we detect that a token is invalid or the user's session has expired
88+func InvalidateServiceToken(did, holdDID string) {
89+ cacheKey := did + ":" + holdDID
90+91+ globalServiceTokensMu.Lock()
92+ delete(globalServiceTokens, cacheKey)
93+ globalServiceTokensMu.Unlock()
94+95+ slog.Debug("Invalidated service token", "cacheKey", cacheKey)
96+}
97+98+// GetCacheStats returns statistics about the service token cache for debugging
99+func GetCacheStats() map[string]any {
100+ globalServiceTokensMu.RLock()
101+ defer globalServiceTokensMu.RUnlock()
102+103+ validCount := 0
104+ expiredCount := 0
105+ now := time.Now()
106+107+ for _, entry := range globalServiceTokens {
108+ if now.Before(entry.expiresAt) {
109+ validCount++
110+ } else {
111+ expiredCount++
112+ }
113+ }
114+115+ return map[string]any{
116+ "total_entries": len(globalServiceTokens),
117+ "valid_tokens": validCount,
118+ "expired_tokens": expiredCount,
119+ }
120+}
121+122+// CleanExpiredTokens removes expired tokens from the cache
123+// Can be called periodically to prevent unbounded growth (though expired tokens
124+// are also removed lazily on access)
125+func CleanExpiredTokens() {
126+ globalServiceTokensMu.Lock()
127+ defer globalServiceTokensMu.Unlock()
128+129+ now := time.Now()
130+ removed := 0
131+132+ for key, entry := range globalServiceTokens {
133+ if now.After(entry.expiresAt) {
134+ delete(globalServiceTokens, key)
135+ removed++
136+ }
137+ }
138+139+ if removed > 0 {
140+ slog.Debug("Cleaned expired service tokens", "count", removed)
141+ }
142+}
···5657 return claims.AuthMethod
58}
59+60+// ExtractSubject parses a JWT token string and extracts the Subject claim (the user's DID)
61+// Returns the subject or empty string if not found or token is invalid
62+// This does NOT validate the token - it only parses it to extract the claim
63+func ExtractSubject(tokenString string) string {
64+ // Parse token without validation (we only need the claims, validation is done by distribution library)
65+ parser := jwt.NewParser(jwt.WithoutClaimsValidation())
66+ token, _, err := parser.ParseUnverified(tokenString, &Claims{})
67+ if err != nil {
68+ return "" // Invalid token format
69+ }
70+71+ claims, ok := token.Claims.(*Claims)
72+ if !ok {
73+ return "" // Wrong claims type
74+ }
75+76+ return claims.Subject
77+}
-362
pkg/auth/token/servicetoken.go
···1-package token
2-3-import (
4- "context"
5- "encoding/json"
6- "errors"
7- "fmt"
8- "io"
9- "log/slog"
10- "net/http"
11- "net/url"
12- "time"
13-14- "atcr.io/pkg/atproto"
15- "atcr.io/pkg/auth"
16- "atcr.io/pkg/auth/oauth"
17- "github.com/bluesky-social/indigo/atproto/atclient"
18- indigo_oauth "github.com/bluesky-social/indigo/atproto/auth/oauth"
19-)
20-21-// getErrorHint provides context-specific troubleshooting hints based on API error type
22-func getErrorHint(apiErr *atclient.APIError) string {
23- switch apiErr.Name {
24- case "use_dpop_nonce":
25- return "DPoP nonce mismatch - indigo library should automatically retry with new nonce. If this persists, check for concurrent request issues or PDS session corruption."
26- case "invalid_client":
27- if apiErr.Message != "" && apiErr.Message == "Validation of \"client_assertion\" failed: \"iat\" claim timestamp check failed (it should be in the past)" {
28- return "JWT timestamp validation failed - system clock on AppView may be ahead of PDS clock. Check NTP sync with: timedatectl status"
29- }
30- return "OAuth client authentication failed - check client key configuration and PDS OAuth server status"
31- case "invalid_token", "invalid_grant":
32- return "OAuth tokens expired or invalidated - user will need to re-authenticate via OAuth flow"
33- case "server_error":
34- if apiErr.StatusCode == 500 {
35- return "PDS returned internal server error - this may occur after repeated DPoP nonce failures or other PDS-side issues. Check PDS logs for root cause."
36- }
37- return "PDS server error - check PDS health and logs"
38- case "invalid_dpop_proof":
39- return "DPoP proof validation failed - check system clock sync and DPoP key configuration"
40- default:
41- if apiErr.StatusCode == 401 || apiErr.StatusCode == 403 {
42- return "Authentication/authorization failed - OAuth session may be expired or revoked"
43- }
44- return "PDS rejected the request - see errorName and errorMessage for details"
45- }
46-}
47-48-// GetOrFetchServiceToken gets a service token for hold authentication.
49-// Checks cache first, then fetches from PDS with OAuth/DPoP if needed.
50-// This is the canonical implementation used by both middleware and crew registration.
51-//
52-// IMPORTANT: Uses DoWithSession() to hold a per-DID lock through the entire PDS interaction.
53-// This prevents DPoP nonce race conditions when multiple Docker layers upload concurrently.
54-func GetOrFetchServiceToken(
55- ctx context.Context,
56- refresher *oauth.Refresher,
57- did, holdDID, pdsEndpoint string,
58-) (string, error) {
59- if refresher == nil {
60- return "", fmt.Errorf("refresher is nil (OAuth session required for service tokens)")
61- }
62-63- // Check cache first to avoid unnecessary PDS calls on every request
64- cachedToken, expiresAt := GetServiceToken(did, holdDID)
65-66- // Use cached token if it exists and has > 10s remaining
67- if cachedToken != "" && time.Until(expiresAt) > 10*time.Second {
68- slog.Debug("Using cached service token",
69- "did", did,
70- "expiresIn", time.Until(expiresAt).Round(time.Second))
71- return cachedToken, nil
72- }
73-74- // Cache miss or expiring soon - validate OAuth and get new service token
75- if cachedToken == "" {
76- slog.Debug("Service token cache miss, fetching new token", "did", did)
77- } else {
78- slog.Debug("Service token expiring soon, proactively renewing", "did", did)
79- }
80-81- // Use DoWithSession to hold the lock through the entire PDS interaction.
82- // This prevents DPoP nonce races when multiple goroutines try to fetch service tokens.
83- var serviceToken string
84- var fetchErr error
85-86- err := refresher.DoWithSession(ctx, did, func(session *indigo_oauth.ClientSession) error {
87- // Double-check cache after acquiring lock - another goroutine may have
88- // populated it while we were waiting (classic double-checked locking pattern)
89- cachedToken, expiresAt := GetServiceToken(did, holdDID)
90- if cachedToken != "" && time.Until(expiresAt) > 10*time.Second {
91- slog.Debug("Service token cache hit after lock acquisition",
92- "did", did,
93- "expiresIn", time.Until(expiresAt).Round(time.Second))
94- serviceToken = cachedToken
95- return nil
96- }
97-98- // Cache still empty/expired - proceed with PDS call
99- // Request 5-minute expiry (PDS may grant less)
100- // exp must be absolute Unix timestamp, not relative duration
101- // Note: OAuth scope includes #atcr_hold fragment, but service auth aud must be bare DID
102- expiryTime := time.Now().Unix() + 300 // 5 minutes from now
103- serviceAuthURL := fmt.Sprintf("%s%s?aud=%s&lxm=%s&exp=%d",
104- pdsEndpoint,
105- atproto.ServerGetServiceAuth,
106- url.QueryEscape(holdDID),
107- url.QueryEscape("com.atproto.repo.getRecord"),
108- expiryTime,
109- )
110-111- req, err := http.NewRequestWithContext(ctx, "GET", serviceAuthURL, nil)
112- if err != nil {
113- fetchErr = fmt.Errorf("failed to create service auth request: %w", err)
114- return fetchErr
115- }
116-117- // Use OAuth session to authenticate to PDS (with DPoP)
118- // The lock is held, so DPoP nonce negotiation is serialized per-DID
119- resp, err := session.DoWithAuth(session.Client, req, "com.atproto.server.getServiceAuth")
120- if err != nil {
121- // Auth error - may indicate expired tokens or corrupted session
122- InvalidateServiceToken(did, holdDID)
123-124- // Inspect the error to extract detailed information from indigo's APIError
125- var apiErr *atclient.APIError
126- if errors.As(err, &apiErr) {
127- // Log detailed API error information
128- slog.Error("OAuth authentication failed during service token request",
129- "component", "token/servicetoken",
130- "did", did,
131- "holdDID", holdDID,
132- "pdsEndpoint", pdsEndpoint,
133- "url", serviceAuthURL,
134- "error", err,
135- "httpStatus", apiErr.StatusCode,
136- "errorName", apiErr.Name,
137- "errorMessage", apiErr.Message,
138- "hint", getErrorHint(apiErr))
139- } else {
140- // Fallback for non-API errors (network errors, etc.)
141- slog.Error("OAuth authentication failed during service token request",
142- "component", "token/servicetoken",
143- "did", did,
144- "holdDID", holdDID,
145- "pdsEndpoint", pdsEndpoint,
146- "url", serviceAuthURL,
147- "error", err,
148- "errorType", fmt.Sprintf("%T", err),
149- "hint", "Network error or unexpected failure during OAuth request")
150- }
151-152- fetchErr = fmt.Errorf("OAuth validation failed: %w", err)
153- return fetchErr
154- }
155- defer resp.Body.Close()
156-157- if resp.StatusCode != http.StatusOK {
158- // Service auth failed
159- bodyBytes, _ := io.ReadAll(resp.Body)
160- InvalidateServiceToken(did, holdDID)
161- slog.Error("Service token request returned non-200 status",
162- "component", "token/servicetoken",
163- "did", did,
164- "holdDID", holdDID,
165- "pdsEndpoint", pdsEndpoint,
166- "statusCode", resp.StatusCode,
167- "responseBody", string(bodyBytes),
168- "hint", "PDS rejected the service token request - check PDS logs for details")
169- fetchErr = fmt.Errorf("service auth failed with status %d: %s", resp.StatusCode, string(bodyBytes))
170- return fetchErr
171- }
172-173- // Parse response to get service token
174- var result struct {
175- Token string `json:"token"`
176- }
177- if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
178- fetchErr = fmt.Errorf("failed to decode service auth response: %w", err)
179- return fetchErr
180- }
181-182- if result.Token == "" {
183- fetchErr = fmt.Errorf("empty token in service auth response")
184- return fetchErr
185- }
186-187- serviceToken = result.Token
188- return nil
189- })
190-191- if err != nil {
192- // DoWithSession failed (session load or callback error)
193- InvalidateServiceToken(did, holdDID)
194-195- // Try to extract detailed error information
196- var apiErr *atclient.APIError
197- if errors.As(err, &apiErr) {
198- slog.Error("Failed to get OAuth session for service token",
199- "component", "token/servicetoken",
200- "did", did,
201- "holdDID", holdDID,
202- "pdsEndpoint", pdsEndpoint,
203- "error", err,
204- "httpStatus", apiErr.StatusCode,
205- "errorName", apiErr.Name,
206- "errorMessage", apiErr.Message,
207- "hint", getErrorHint(apiErr))
208- } else if fetchErr == nil {
209- // Session load failed (not a fetch error)
210- slog.Error("Failed to get OAuth session for service token",
211- "component", "token/servicetoken",
212- "did", did,
213- "holdDID", holdDID,
214- "pdsEndpoint", pdsEndpoint,
215- "error", err,
216- "errorType", fmt.Sprintf("%T", err),
217- "hint", "OAuth session not found in database or token refresh failed")
218- }
219-220- // Delete the stale OAuth session to force re-authentication
221- // This also invalidates the UI session automatically
222- if delErr := refresher.DeleteSession(ctx, did); delErr != nil {
223- slog.Warn("Failed to delete stale OAuth session",
224- "component", "token/servicetoken",
225- "did", did,
226- "error", delErr)
227- }
228-229- if fetchErr != nil {
230- return "", fetchErr
231- }
232- return "", fmt.Errorf("failed to get OAuth session: %w", err)
233- }
234-235- // Cache the token (parses JWT to extract actual expiry)
236- if err := SetServiceToken(did, holdDID, serviceToken); err != nil {
237- slog.Warn("Failed to cache service token", "error", err, "did", did, "holdDID", holdDID)
238- // Non-fatal - we have the token, just won't be cached
239- }
240-241- slog.Debug("OAuth validation succeeded, service token obtained", "did", did)
242- return serviceToken, nil
243-}
244-245-// GetOrFetchServiceTokenWithAppPassword gets a service token using app-password Bearer authentication.
246-// Used when auth method is app_password instead of OAuth.
247-func GetOrFetchServiceTokenWithAppPassword(
248- ctx context.Context,
249- did, holdDID, pdsEndpoint string,
250-) (string, error) {
251- // Check cache first to avoid unnecessary PDS calls on every request
252- cachedToken, expiresAt := GetServiceToken(did, holdDID)
253-254- // Use cached token if it exists and has > 10s remaining
255- if cachedToken != "" && time.Until(expiresAt) > 10*time.Second {
256- slog.Debug("Using cached service token (app-password)",
257- "did", did,
258- "expiresIn", time.Until(expiresAt).Round(time.Second))
259- return cachedToken, nil
260- }
261-262- // Cache miss or expiring soon - get app-password token and fetch new service token
263- if cachedToken == "" {
264- slog.Debug("Service token cache miss, fetching new token with app-password", "did", did)
265- } else {
266- slog.Debug("Service token expiring soon, proactively renewing with app-password", "did", did)
267- }
268-269- // Get app-password access token from cache
270- accessToken, ok := auth.GetGlobalTokenCache().Get(did)
271- if !ok {
272- InvalidateServiceToken(did, holdDID)
273- slog.Error("No app-password access token found in cache",
274- "component", "token/servicetoken",
275- "did", did,
276- "holdDID", holdDID,
277- "hint", "User must re-authenticate with docker login")
278- return "", fmt.Errorf("no app-password access token available for DID %s", did)
279- }
280-281- // Call com.atproto.server.getServiceAuth on the user's PDS with Bearer token
282- // Request 5-minute expiry (PDS may grant less)
283- // exp must be absolute Unix timestamp, not relative duration
284- expiryTime := time.Now().Unix() + 300 // 5 minutes from now
285- serviceAuthURL := fmt.Sprintf("%s%s?aud=%s&lxm=%s&exp=%d",
286- pdsEndpoint,
287- atproto.ServerGetServiceAuth,
288- url.QueryEscape(holdDID),
289- url.QueryEscape("com.atproto.repo.getRecord"),
290- expiryTime,
291- )
292-293- req, err := http.NewRequestWithContext(ctx, "GET", serviceAuthURL, nil)
294- if err != nil {
295- return "", fmt.Errorf("failed to create service auth request: %w", err)
296- }
297-298- // Set Bearer token authentication (app-password)
299- req.Header.Set("Authorization", "Bearer "+accessToken)
300-301- // Make request with standard HTTP client
302- resp, err := http.DefaultClient.Do(req)
303- if err != nil {
304- InvalidateServiceToken(did, holdDID)
305- slog.Error("App-password service token request failed",
306- "component", "token/servicetoken",
307- "did", did,
308- "holdDID", holdDID,
309- "pdsEndpoint", pdsEndpoint,
310- "error", err)
311- return "", fmt.Errorf("failed to request service token: %w", err)
312- }
313- defer resp.Body.Close()
314-315- if resp.StatusCode == http.StatusUnauthorized {
316- // App-password token is invalid or expired - clear from cache
317- auth.GetGlobalTokenCache().Delete(did)
318- InvalidateServiceToken(did, holdDID)
319- slog.Error("App-password token rejected by PDS",
320- "component", "token/servicetoken",
321- "did", did,
322- "hint", "User must re-authenticate with docker login")
323- return "", fmt.Errorf("app-password authentication failed: token expired or invalid")
324- }
325-326- if resp.StatusCode != http.StatusOK {
327- // Service auth failed
328- bodyBytes, _ := io.ReadAll(resp.Body)
329- InvalidateServiceToken(did, holdDID)
330- slog.Error("Service token request returned non-200 status (app-password)",
331- "component", "token/servicetoken",
332- "did", did,
333- "holdDID", holdDID,
334- "pdsEndpoint", pdsEndpoint,
335- "statusCode", resp.StatusCode,
336- "responseBody", string(bodyBytes))
337- return "", fmt.Errorf("service auth failed with status %d: %s", resp.StatusCode, string(bodyBytes))
338- }
339-340- // Parse response to get service token
341- var result struct {
342- Token string `json:"token"`
343- }
344- if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
345- return "", fmt.Errorf("failed to decode service auth response: %w", err)
346- }
347-348- if result.Token == "" {
349- return "", fmt.Errorf("empty token in service auth response")
350- }
351-352- serviceToken := result.Token
353-354- // Cache the token (parses JWT to extract actual expiry)
355- if err := SetServiceToken(did, holdDID, serviceToken); err != nil {
356- slog.Warn("Failed to cache service token", "error", err, "did", did, "holdDID", holdDID)
357- // Non-fatal - we have the token, just won't be cached
358- }
359-360- slog.Debug("App-password validation succeeded, service token obtained", "did", did)
361- return serviceToken, nil
362-}