···194194195195 // Create routing repository - routes manifests to ATProto, blobs to hold service
196196 // The registry is stateless - no local storage is used
197197- // Pass hold DID, user DID, and authorizer as parameters (can't use context as it gets lost)
198198- routingRepo := storage.NewRoutingRepository(repo, atprotoClient, repositoryName, holdDID, did, globalDatabase, globalAuthorizer)
197197+ // Pass hold DID, user DID, authorizer, and refresher as parameters (can't use context as it gets lost)
198198+ routingRepo := storage.NewRoutingRepository(repo, atprotoClient, repositoryName, holdDID, did, globalDatabase, globalAuthorizer, globalRefresher)
199199200200 // Cache the repository
201201 nr.repositories.Store(cacheKey, routingRepo)
+34-5
pkg/appview/storage/proxy_blob_store.go
···1212 "time"
13131414 "atcr.io/pkg/auth"
1515+ "atcr.io/pkg/auth/oauth"
1516 "github.com/distribution/distribution/v3"
1617 "github.com/opencontainers/go-digest"
1718)
···3839 database DatabaseMetrics
3940 repository string
4041 authorizer auth.HoldAuthorizer
4242+ refresher *oauth.Refresher // OAuth refresher for authenticating to hold service
4143}
42444345// NewProxyBlobStore creates a new proxy blob store
4444-func NewProxyBlobStore(holdDID, did string, database DatabaseMetrics, repository string, authorizer auth.HoldAuthorizer) *ProxyBlobStore {
4646+func NewProxyBlobStore(holdDID, did string, database DatabaseMetrics, repository string, authorizer auth.HoldAuthorizer, refresher *oauth.Refresher) *ProxyBlobStore {
4547 // Resolve DID to URL once at construction time
4648 holdURL := resolveHoldURL(holdDID)
4749···6567 database: database,
6668 repository: repository,
6769 authorizer: authorizer,
7070+ refresher: refresher,
6871 }
7272+}
7373+7474+// doAuthenticatedRequest performs an HTTP request with OAuth authentication (DPoP)
7575+// If OAuth session is available, uses session.DoWithAuth for DPoP headers
7676+// Otherwise, uses the default httpClient without authentication
7777+func (p *ProxyBlobStore) doAuthenticatedRequest(ctx context.Context, req *http.Request) (*http.Response, error) {
7878+ // Try to get OAuth session for DPoP authentication
7979+ if p.refresher != nil {
8080+ session, err := p.refresher.GetSession(ctx, p.did)
8181+ if err != nil {
8282+ fmt.Printf("DEBUG [proxy_blob_store]: Failed to get OAuth session for DID=%s: %v, will attempt without auth\n", p.did, err)
8383+ } else {
8484+ // Use session's DoWithAuth method (adds Authorization + DPoP headers)
8585+ fmt.Printf("DEBUG [proxy_blob_store]: Using OAuth session for hold service request, DID=%s\n", p.did)
8686+ // The endpoint parameter is not used for DPoP signing, just token refresh validation
8787+ // For hold service XRPC requests, we can pass "com.atproto.repo.uploadBlob"
8888+ return session.DoWithAuth(session.Client, req, "com.atproto.repo.uploadBlob")
8989+ }
9090+ }
9191+9292+ // Fall back to non-authenticated client
9393+ return p.httpClient.Do(req)
6994}
70957196// resolveHoldURL converts a hold DID to an HTTP URL for XRPC requests
···403428 }
404429 req.Header.Set("Content-Type", "application/json")
405430406406- resp, err := p.httpClient.Do(req)
431431+ // Use authenticated request (OAuth with DPoP)
432432+ resp, err := p.doAuthenticatedRequest(ctx, req)
407433 if err != nil {
408434 return "", err
409435 }
···453479 }
454480 req.Header.Set("Content-Type", "application/json")
455481456456- resp, err := p.httpClient.Do(req)
482482+ // Use authenticated request (OAuth with DPoP)
483483+ resp, err := p.doAuthenticatedRequest(ctx, req)
457484 if err != nil {
458485 return nil, err
459486 }
···503530 }
504531 req.Header.Set("Content-Type", "application/json")
505532506506- resp, err := p.httpClient.Do(req)
533533+ // Use authenticated request (OAuth with DPoP)
534534+ resp, err := p.doAuthenticatedRequest(ctx, req)
507535 if err != nil {
508536 return err
509537 }
···537565 }
538566 req.Header.Set("Content-Type", "application/json")
539567540540- resp, err := p.httpClient.Do(req)
568568+ // Use authenticated request (OAuth with DPoP)
569569+ resp, err := p.doAuthenticatedRequest(ctx, req)
541570 if err != nil {
542571 return err
543572 }
+6-2
pkg/appview/storage/routing_repository.go
···7788 "atcr.io/pkg/atproto"
99 "atcr.io/pkg/auth"
1010+ "atcr.io/pkg/auth/oauth"
1011 "github.com/distribution/distribution/v3"
1112)
1213···2829 blobStore *ProxyBlobStore // Cached blob store instance
2930 database DatabaseMetrics // Database for metrics tracking
3031 authorizer auth.HoldAuthorizer // Authorization for hold access
3232+ refresher *oauth.Refresher // OAuth refresher for authenticating to hold service
3133}
32343335// NewRoutingRepository creates a new routing repository
···3941 did string,
4042 database DatabaseMetrics,
4143 authorizer auth.HoldAuthorizer,
4444+ refresher *oauth.Refresher,
4245) *RoutingRepository {
4346 return &RoutingRepository{
4447 Repository: baseRepo,
···4851 did: did,
4952 database: database,
5053 authorizer: authorizer,
5454+ refresher: refresher,
5155 }
5256}
5357···108112 panic("hold DID not set in RoutingRepository - ensure default_hold_did is configured in middleware")
109113 }
110114111111- // Create and cache proxy blob store with authorization
112112- r.blobStore = NewProxyBlobStore(holdDID, r.did, r.database, r.repositoryName, r.authorizer)
115115+ // Create and cache proxy blob store with authorization and OAuth refresher
116116+ r.blobStore = NewProxyBlobStore(holdDID, r.did, r.database, r.repositoryName, r.authorizer, r.refresher)
113117 return r.blobStore
114118}
115119
+20-6
pkg/auth/hold_remote.go
···2424 cacheTTL time.Duration // TTL for captain record cache
2525 recentDenials sync.Map // In-memory cache for first denials (10s backoff)
2626 stopCleanup chan struct{} // Signal to stop cleanup goroutine
2727+ testMode bool // If true, use HTTP for local DIDs
2728}
28292930// denialEntry stores timestamp for in-memory first denials
···3233}
33343435// NewRemoteHoldAuthorizer creates a new remote authorizer for AppView
3535-func NewRemoteHoldAuthorizer(db *sql.DB) HoldAuthorizer {
3636+func NewRemoteHoldAuthorizer(db *sql.DB, testMode bool) HoldAuthorizer {
3637 a := &RemoteHoldAuthorizer{
3738 db: db,
3839 httpClient: &http.Client{
···4041 },
4142 cacheTTL: 1 * time.Hour, // 1 hour cache TTL
4243 stopCleanup: make(chan struct{}),
4444+ testMode: testMode,
4345 }
44464547 // Start cleanup goroutine for in-memory denials
···192194// fetchCaptainRecordFromXRPC queries the hold's XRPC endpoint for captain record
193195func (a *RemoteHoldAuthorizer) fetchCaptainRecordFromXRPC(ctx context.Context, holdDID string) (*atproto.CaptainRecord, error) {
194196 // Resolve DID to URL
195195- holdURL, err := resolveDIDToURL(holdDID)
197197+ holdURL, err := a.resolveDIDToURL(holdDID)
196198 if err != nil {
197199 return nil, fmt.Errorf("failed to resolve hold DID: %w", err)
198200 }
···293295// isCrewMemberNoCache queries XRPC without caching (internal helper)
294296func (a *RemoteHoldAuthorizer) isCrewMemberNoCache(ctx context.Context, holdDID, userDID string) (bool, error) {
295297 // Resolve DID to URL
296296- holdURL, err := resolveDIDToURL(holdDID)
298298+ holdURL, err := a.resolveDIDToURL(holdDID)
297299 if err != nil {
298300 return false, fmt.Errorf("failed to resolve hold DID: %w", err)
299301 }
···374376 return CheckWriteAccessWithCaptain(captain, userDID, isCrew), nil
375377}
376378377377-// resolveDIDToURL converts a did:web DID to an HTTPS URL
379379+// resolveDIDToURL converts a did:web DID to an HTTP/HTTPS URL
378380// Example: did:web:hold01.atcr.io → https://hold01.atcr.io
379379-func resolveDIDToURL(did string) (string, error) {
381381+// Example (test mode): did:web:172.28.0.3:8080 → http://172.28.0.3:8080
382382+func (a *RemoteHoldAuthorizer) resolveDIDToURL(did string) (string, error) {
380383 // Handle did:web format
381384 if !strings.HasPrefix(did, "did:web:") {
382385 return "", fmt.Errorf("only did:web is supported, got: %s", did)
···385388 // Extract hostname from did:web:hostname
386389 hostname := strings.TrimPrefix(did, "did:web:")
387390388388- // Convert to HTTPS URL
391391+ // In test mode OR for local addresses, use HTTP instead of HTTPS
392392+ // This matches the logic in pkg/appview/storage/proxy_blob_store.go:resolveHoldURL
393393+ if a.testMode ||
394394+ strings.Contains(hostname, ":") ||
395395+ strings.Contains(hostname, "127.0.0.1") ||
396396+ strings.Contains(hostname, "localhost") ||
397397+ // Check if it's an IP address (contains only digits and dots)
398398+ (len(hostname) > 0 && (hostname[0] >= '0' && hostname[0] <= '9')) {
399399+ return "http://" + hostname, nil
400400+ }
401401+402402+ // Convert to HTTPS URL for production domains
389403 return "https://" + hostname, nil
390404}
391405