A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
atcr.io
docker
container
atproto
go
1// Package auth provides UserContext for managing authenticated user state
2// throughout request handling in the AppView.
3package auth
4
5import (
6 "context"
7 "database/sql"
8 "encoding/json"
9 "fmt"
10 "io"
11 "log/slog"
12 "net/http"
13 "sync"
14 "time"
15
16 "atcr.io/pkg/appview/db"
17 "atcr.io/pkg/atproto"
18 "atcr.io/pkg/auth/oauth"
19)
20
21// Auth method constants (duplicated from token package to avoid import cycle)
22const (
23 AuthMethodOAuth = "oauth"
24 AuthMethodAppPassword = "app_password"
25)
26
27// RequestAction represents the type of registry operation
28type RequestAction int
29
30const (
31 ActionUnknown RequestAction = iota
32 ActionPull // GET/HEAD - reading from registry
33 ActionPush // PUT/POST/DELETE - writing to registry
34 ActionInspect // Metadata operations only
35)
36
37func (a RequestAction) String() string {
38 switch a {
39 case ActionPull:
40 return "pull"
41 case ActionPush:
42 return "push"
43 case ActionInspect:
44 return "inspect"
45 default:
46 return "unknown"
47 }
48}
49
50// HoldPermissions describes what the user can do on a specific hold
51type HoldPermissions struct {
52 HoldDID string // Hold being checked
53 IsOwner bool // User is captain of this hold
54 IsCrew bool // User is a crew member
55 IsPublic bool // Hold allows public reads
56 CanRead bool // Computed: can user read blobs?
57 CanWrite bool // Computed: can user write blobs?
58 CanAdmin bool // Computed: can user manage crew?
59 Permissions []string // Raw permissions from crew record
60}
61
62// contextKey is unexported to prevent collisions
63type contextKey struct{}
64
65// userContextKey is the context key for UserContext
66var userContextKey = contextKey{}
67
68// userSetupCache tracks which users have had their profile/crew setup ensured
69var userSetupCache sync.Map // did -> time.Time
70
71// userSetupTTL is how long to cache user setup status (1 hour)
72const userSetupTTL = 1 * time.Hour
73
74// Dependencies bundles services needed by UserContext
75type Dependencies struct {
76 Refresher *oauth.Refresher
77 Authorizer HoldAuthorizer
78 DefaultHoldDID string // AppView's default hold DID
79}
80
81// UserContext encapsulates authenticated user state for a request.
82// Built early in the middleware chain and available throughout request processing.
83//
84// Two-phase initialization:
85// 1. Middleware phase: Identity is set (DID, authMethod, action)
86// 2. Repository() phase: Target is set via SetTarget() (owner, repo, holdDID)
87type UserContext struct {
88 // === User Identity (set in middleware) ===
89 DID string // User's DID (empty if unauthenticated)
90 Handle string // User's handle (may be empty)
91 PDSEndpoint string // User's PDS endpoint
92 AuthMethod string // "oauth", "app_password", or ""
93 IsAuthenticated bool
94
95 // === Request Info ===
96 Action RequestAction
97 HTTPMethod string
98
99 // === Target Info (set by SetTarget) ===
100 TargetOwnerDID string // whose repo is being accessed
101 TargetOwnerHandle string
102 TargetOwnerPDS string
103 TargetRepo string // image name (e.g., "quickslice")
104 TargetHoldDID string // hold where blobs live/will live
105
106 // === Dependencies (injected) ===
107 refresher *oauth.Refresher
108 authorizer HoldAuthorizer
109 defaultHoldDID string
110
111 // === Cached State (lazy-loaded) ===
112 serviceTokens sync.Map // holdDID -> *serviceTokenEntry
113 permissions sync.Map // holdDID -> *HoldPermissions
114 pdsResolved bool
115 pdsResolveErr error
116 mu sync.Mutex // protects PDS resolution
117 atprotoClient *atproto.Client
118 atprotoClientOnce sync.Once
119}
120
121// FromContext retrieves UserContext from context.
122// Returns nil if not present (unauthenticated or before middleware).
123func FromContext(ctx context.Context) *UserContext {
124 uc, _ := ctx.Value(userContextKey).(*UserContext)
125 return uc
126}
127
128// WithUserContext adds UserContext to context
129func WithUserContext(ctx context.Context, uc *UserContext) context.Context {
130 return context.WithValue(ctx, userContextKey, uc)
131}
132
133// NewUserContext creates a UserContext from extracted JWT claims.
134// The deps parameter provides access to services needed for lazy operations.
135func NewUserContext(did, authMethod, httpMethod string, deps *Dependencies) *UserContext {
136 action := ActionUnknown
137 switch httpMethod {
138 case "GET", "HEAD":
139 action = ActionPull
140 case "PUT", "POST", "PATCH", "DELETE":
141 action = ActionPush
142 }
143
144 var refresher *oauth.Refresher
145 var authorizer HoldAuthorizer
146 var defaultHoldDID string
147
148 if deps != nil {
149 refresher = deps.Refresher
150 authorizer = deps.Authorizer
151 defaultHoldDID = deps.DefaultHoldDID
152 }
153
154 return &UserContext{
155 DID: did,
156 AuthMethod: authMethod,
157 IsAuthenticated: did != "",
158 Action: action,
159 HTTPMethod: httpMethod,
160 refresher: refresher,
161 authorizer: authorizer,
162 defaultHoldDID: defaultHoldDID,
163 }
164}
165
166// SetPDS sets the user's PDS endpoint directly, bypassing network resolution.
167// Use when PDS is already known (e.g., from previous resolution or client).
168func (uc *UserContext) SetPDS(handle, pdsEndpoint string) {
169 uc.mu.Lock()
170 defer uc.mu.Unlock()
171 uc.Handle = handle
172 uc.PDSEndpoint = pdsEndpoint
173 uc.pdsResolved = true
174 uc.pdsResolveErr = nil
175}
176
177// SetTarget sets the target repository information.
178// Called in Repository() after resolving the owner identity.
179func (uc *UserContext) SetTarget(ownerDID, ownerHandle, ownerPDS, repo, holdDID string) {
180 uc.TargetOwnerDID = ownerDID
181 uc.TargetOwnerHandle = ownerHandle
182 uc.TargetOwnerPDS = ownerPDS
183 uc.TargetRepo = repo
184 uc.TargetHoldDID = holdDID
185}
186
187// ResolvePDS resolves the user's PDS endpoint (lazy, cached).
188// Safe to call multiple times; resolution happens once.
189func (uc *UserContext) ResolvePDS(ctx context.Context) error {
190 if !uc.IsAuthenticated {
191 return nil // Nothing to resolve for anonymous users
192 }
193
194 uc.mu.Lock()
195 defer uc.mu.Unlock()
196
197 if uc.pdsResolved {
198 return uc.pdsResolveErr
199 }
200
201 _, handle, pds, err := atproto.ResolveIdentity(ctx, uc.DID)
202 if err != nil {
203 uc.pdsResolveErr = err
204 uc.pdsResolved = true
205 return err
206 }
207
208 uc.Handle = handle
209 uc.PDSEndpoint = pds
210 uc.pdsResolved = true
211 return nil
212}
213
214// GetServiceToken returns a service token for the target hold.
215// Uses internal caching with sync.Once per holdDID.
216// Requires target to be set via SetTarget().
217func (uc *UserContext) GetServiceToken(ctx context.Context) (string, error) {
218 if uc.TargetHoldDID == "" {
219 return "", fmt.Errorf("target hold not set (call SetTarget first)")
220 }
221 return uc.GetServiceTokenForHold(ctx, uc.TargetHoldDID)
222}
223
224// GetServiceTokenForHold returns a service token for an arbitrary hold.
225// Uses internal caching with sync.Once per holdDID.
226func (uc *UserContext) GetServiceTokenForHold(ctx context.Context, holdDID string) (string, error) {
227 if !uc.IsAuthenticated {
228 return "", fmt.Errorf("cannot get service token: user not authenticated")
229 }
230
231 // Ensure PDS is resolved
232 if err := uc.ResolvePDS(ctx); err != nil {
233 return "", fmt.Errorf("failed to resolve PDS: %w", err)
234 }
235
236 // Load or create cache entry
237 entryVal, _ := uc.serviceTokens.LoadOrStore(holdDID, &serviceTokenEntry{})
238 entry := entryVal.(*serviceTokenEntry)
239
240 entry.once.Do(func() {
241 slog.Debug("Fetching service token",
242 "component", "auth/context",
243 "userDID", uc.DID,
244 "holdDID", holdDID,
245 "authMethod", uc.AuthMethod)
246
247 // Use unified service token function (handles both OAuth and app-password)
248 serviceToken, err := GetOrFetchServiceToken(
249 ctx, uc.AuthMethod, uc.refresher, uc.DID, holdDID, uc.PDSEndpoint,
250 )
251
252 entry.token = serviceToken
253 entry.err = err
254 if err == nil {
255 // Parse JWT to get expiry
256 expiry, parseErr := ParseJWTExpiry(serviceToken)
257 if parseErr == nil {
258 entry.expiresAt = expiry.Add(-10 * time.Second) // Safety margin
259 } else {
260 entry.expiresAt = time.Now().Add(45 * time.Second) // Default fallback
261 }
262 }
263 })
264
265 return entry.token, entry.err
266}
267
268// CanRead checks if user can read blobs from target hold.
269// - Public hold: any user (even anonymous)
270// - Private hold: owner OR crew with blob:read/blob:write
271func (uc *UserContext) CanRead(ctx context.Context) (bool, error) {
272 if uc.TargetHoldDID == "" {
273 return false, fmt.Errorf("target hold not set (call SetTarget first)")
274 }
275
276 if uc.authorizer == nil {
277 return false, fmt.Errorf("authorizer not configured")
278 }
279
280 return uc.authorizer.CheckReadAccess(ctx, uc.TargetHoldDID, uc.DID)
281}
282
283// CanWrite checks if user can write blobs to target hold.
284// - Must be authenticated
285// - Must be owner OR crew with blob:write
286func (uc *UserContext) CanWrite(ctx context.Context) (bool, error) {
287 if uc.TargetHoldDID == "" {
288 return false, fmt.Errorf("target hold not set (call SetTarget first)")
289 }
290
291 if !uc.IsAuthenticated {
292 return false, nil // Anonymous writes never allowed
293 }
294
295 if uc.authorizer == nil {
296 return false, fmt.Errorf("authorizer not configured")
297 }
298
299 return uc.authorizer.CheckWriteAccess(ctx, uc.TargetHoldDID, uc.DID)
300}
301
302// GetPermissions returns detailed permissions for target hold.
303// Lazy-loaded and cached per holdDID.
304func (uc *UserContext) GetPermissions(ctx context.Context) (*HoldPermissions, error) {
305 if uc.TargetHoldDID == "" {
306 return nil, fmt.Errorf("target hold not set (call SetTarget first)")
307 }
308 return uc.GetPermissionsForHold(ctx, uc.TargetHoldDID)
309}
310
311// GetPermissionsForHold returns detailed permissions for an arbitrary hold.
312// Lazy-loaded and cached per holdDID.
313func (uc *UserContext) GetPermissionsForHold(ctx context.Context, holdDID string) (*HoldPermissions, error) {
314 // Check cache first
315 if cached, ok := uc.permissions.Load(holdDID); ok {
316 return cached.(*HoldPermissions), nil
317 }
318
319 if uc.authorizer == nil {
320 return nil, fmt.Errorf("authorizer not configured")
321 }
322
323 // Build permissions by querying authorizer
324 captain, err := uc.authorizer.GetCaptainRecord(ctx, holdDID)
325 if err != nil {
326 return nil, fmt.Errorf("failed to get captain record: %w", err)
327 }
328
329 perms := &HoldPermissions{
330 HoldDID: holdDID,
331 IsPublic: captain.Public,
332 IsOwner: uc.DID != "" && uc.DID == captain.Owner,
333 }
334
335 // Check crew membership if authenticated and not owner
336 if uc.IsAuthenticated && !perms.IsOwner {
337 isCrew, crewErr := uc.authorizer.IsCrewMember(ctx, holdDID, uc.DID)
338 if crewErr != nil {
339 slog.Warn("Failed to check crew membership",
340 "component", "auth/context",
341 "holdDID", holdDID,
342 "userDID", uc.DID,
343 "error", crewErr)
344 }
345 perms.IsCrew = isCrew
346 }
347
348 // Compute permissions based on role
349 if perms.IsOwner {
350 perms.CanRead = true
351 perms.CanWrite = true
352 perms.CanAdmin = true
353 } else if perms.IsCrew {
354 // Crew members can read and write (for now, all crew have blob:write)
355 // TODO: Check specific permissions from crew record
356 perms.CanRead = true
357 perms.CanWrite = true
358 perms.CanAdmin = false
359 } else if perms.IsPublic {
360 // Public hold - anyone can read
361 perms.CanRead = true
362 perms.CanWrite = false
363 perms.CanAdmin = false
364 } else if uc.IsAuthenticated {
365 // Private hold, authenticated non-crew
366 // Per permission matrix: cannot read private holds
367 perms.CanRead = false
368 perms.CanWrite = false
369 perms.CanAdmin = false
370 } else {
371 // Anonymous on private hold
372 perms.CanRead = false
373 perms.CanWrite = false
374 perms.CanAdmin = false
375 }
376
377 // Cache and return
378 uc.permissions.Store(holdDID, perms)
379 return perms, nil
380}
381
382// IsCrewMember checks if user is crew of target hold.
383func (uc *UserContext) IsCrewMember(ctx context.Context) (bool, error) {
384 if uc.TargetHoldDID == "" {
385 return false, fmt.Errorf("target hold not set (call SetTarget first)")
386 }
387
388 if !uc.IsAuthenticated {
389 return false, nil
390 }
391
392 if uc.authorizer == nil {
393 return false, fmt.Errorf("authorizer not configured")
394 }
395
396 return uc.authorizer.IsCrewMember(ctx, uc.TargetHoldDID, uc.DID)
397}
398
399// EnsureCrewMembership is a standalone function to register as crew on a hold.
400// Use this when you don't have a UserContext (e.g., OAuth callback).
401// This is best-effort and logs errors without failing.
402func EnsureCrewMembership(ctx context.Context, did, pdsEndpoint string, refresher *oauth.Refresher, holdDID string) {
403 if holdDID == "" {
404 return
405 }
406
407 // Only works with OAuth (refresher required) - app passwords can't get service tokens
408 if refresher == nil {
409 slog.Debug("skipping crew registration - no OAuth refresher (app password flow)", "holdDID", holdDID)
410 return
411 }
412
413 // Normalize URL to DID if needed
414 if !atproto.IsDID(holdDID) {
415 holdDID = atproto.ResolveHoldDIDFromURL(holdDID)
416 if holdDID == "" {
417 slog.Warn("failed to resolve hold DID", "defaultHold", holdDID)
418 return
419 }
420 }
421
422 // Get service token for the hold (OAuth only at this point)
423 serviceToken, err := GetOrFetchServiceToken(ctx, AuthMethodOAuth, refresher, did, holdDID, pdsEndpoint)
424 if err != nil {
425 slog.Warn("failed to get service token", "holdDID", holdDID, "error", err)
426 return
427 }
428
429 // Resolve hold DID to HTTP endpoint
430 holdEndpoint := atproto.ResolveHoldURL(holdDID)
431 if holdEndpoint == "" {
432 slog.Warn("failed to resolve hold endpoint", "holdDID", holdDID)
433 return
434 }
435
436 // Call requestCrew endpoint
437 if err := requestCrewMembership(ctx, holdEndpoint, serviceToken); err != nil {
438 slog.Warn("failed to request crew membership", "holdDID", holdDID, "error", err)
439 return
440 }
441
442 slog.Info("successfully registered as crew member", "holdDID", holdDID, "userDID", did)
443}
444
445// ensureCrewMembership attempts to register as crew on target hold (UserContext method).
446// Called automatically during first push; idempotent.
447// This is a best-effort operation and logs errors without failing.
448// Requires SetTarget() to be called first.
449func (uc *UserContext) ensureCrewMembership(ctx context.Context) error {
450 if uc.TargetHoldDID == "" {
451 return fmt.Errorf("target hold not set (call SetTarget first)")
452 }
453 return uc.EnsureCrewMembershipForHold(ctx, uc.TargetHoldDID)
454}
455
456// EnsureCrewMembershipForHold attempts to register as crew on the specified hold.
457// This is the core implementation that can be called with any holdDID.
458// Called automatically during first push; idempotent.
459// This is a best-effort operation and logs errors without failing.
460func (uc *UserContext) EnsureCrewMembershipForHold(ctx context.Context, holdDID string) error {
461 if holdDID == "" {
462 return nil // Nothing to do
463 }
464
465 // Normalize URL to DID if needed
466 if !atproto.IsDID(holdDID) {
467 holdDID = atproto.ResolveHoldDIDFromURL(holdDID)
468 if holdDID == "" {
469 return fmt.Errorf("failed to resolve hold DID from URL")
470 }
471 }
472
473 if !uc.IsAuthenticated {
474 return fmt.Errorf("cannot register as crew: user not authenticated")
475 }
476
477 if uc.refresher == nil {
478 return fmt.Errorf("cannot register as crew: OAuth session required")
479 }
480
481 // Get service token for the hold
482 serviceToken, err := uc.GetServiceTokenForHold(ctx, holdDID)
483 if err != nil {
484 return fmt.Errorf("failed to get service token: %w", err)
485 }
486
487 // Resolve hold DID to HTTP endpoint
488 holdEndpoint := atproto.ResolveHoldURL(holdDID)
489 if holdEndpoint == "" {
490 return fmt.Errorf("failed to resolve hold endpoint for %s", holdDID)
491 }
492
493 // Call requestCrew endpoint
494 return requestCrewMembership(ctx, holdEndpoint, serviceToken)
495}
496
497// requestCrewMembership calls the hold's requestCrew endpoint
498// The endpoint handles all authorization and duplicate checking internally
499func requestCrewMembership(ctx context.Context, holdEndpoint, serviceToken string) error {
500 // Add 5 second timeout to prevent hanging on offline holds
501 ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
502 defer cancel()
503
504 url := fmt.Sprintf("%s%s", holdEndpoint, atproto.HoldRequestCrew)
505
506 req, err := http.NewRequestWithContext(ctx, "POST", url, nil)
507 if err != nil {
508 return err
509 }
510
511 req.Header.Set("Authorization", "Bearer "+serviceToken)
512 req.Header.Set("Content-Type", "application/json")
513
514 resp, err := http.DefaultClient.Do(req)
515 if err != nil {
516 return err
517 }
518 defer resp.Body.Close()
519
520 if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
521 // Read response body to capture actual error message from hold
522 body, readErr := io.ReadAll(resp.Body)
523 if readErr != nil {
524 return fmt.Errorf("requestCrew failed with status %d (failed to read error body: %w)", resp.StatusCode, readErr)
525 }
526 return fmt.Errorf("requestCrew failed with status %d: %s", resp.StatusCode, string(body))
527 }
528
529 return nil
530}
531
532// GetUserClient returns an authenticated ATProto client for the user's own PDS.
533// Used for profile operations (reading/writing to user's own repo).
534// Returns nil if not authenticated or PDS not resolved.
535func (uc *UserContext) GetUserClient() *atproto.Client {
536 if !uc.IsAuthenticated || uc.PDSEndpoint == "" {
537 return nil
538 }
539
540 if uc.AuthMethod == AuthMethodOAuth && uc.refresher != nil {
541 return atproto.NewClientWithSessionProvider(uc.PDSEndpoint, uc.DID, uc.refresher)
542 } else if uc.AuthMethod == AuthMethodAppPassword {
543 accessToken, _ := GetGlobalTokenCache().Get(uc.DID)
544 return atproto.NewClient(uc.PDSEndpoint, uc.DID, accessToken)
545 }
546
547 return nil
548}
549
550// EnsureUserSetup ensures the user has a profile and crew membership.
551// Called once per user (cached for userSetupTTL). Runs in background - does not block.
552// Safe to call on every request.
553func (uc *UserContext) EnsureUserSetup() {
554 if !uc.IsAuthenticated || uc.DID == "" {
555 return
556 }
557
558 // Check cache - skip if recently set up
559 if lastSetup, ok := userSetupCache.Load(uc.DID); ok {
560 if time.Since(lastSetup.(time.Time)) < userSetupTTL {
561 return
562 }
563 }
564
565 // Run in background to avoid blocking requests
566 go func() {
567 bgCtx := context.Background()
568
569 // 1. Ensure profile exists
570 if client := uc.GetUserClient(); client != nil {
571 uc.ensureProfile(bgCtx, client)
572 }
573
574 // 2. Ensure crew membership on default hold
575 if uc.defaultHoldDID != "" {
576 EnsureCrewMembership(bgCtx, uc.DID, uc.PDSEndpoint, uc.refresher, uc.defaultHoldDID)
577 }
578
579 // Mark as set up
580 userSetupCache.Store(uc.DID, time.Now())
581 slog.Debug("User setup complete",
582 "component", "auth/usercontext",
583 "did", uc.DID,
584 "defaultHoldDID", uc.defaultHoldDID)
585 }()
586}
587
588// ensureProfile creates sailor profile if it doesn't exist.
589// Inline implementation to avoid circular import with storage package.
590func (uc *UserContext) ensureProfile(ctx context.Context, client *atproto.Client) {
591 // Check if profile already exists
592 profile, err := client.GetRecord(ctx, atproto.SailorProfileCollection, "self")
593 if err == nil && profile != nil {
594 return // Already exists
595 }
596
597 // Create profile with default hold
598 normalizedDID := ""
599 if uc.defaultHoldDID != "" {
600 normalizedDID = atproto.ResolveHoldDIDFromURL(uc.defaultHoldDID)
601 }
602
603 newProfile := atproto.NewSailorProfileRecord(normalizedDID)
604 if _, err := client.PutRecord(ctx, atproto.SailorProfileCollection, "self", newProfile); err != nil {
605 slog.Warn("Failed to create sailor profile",
606 "component", "auth/usercontext",
607 "did", uc.DID,
608 "error", err)
609 return
610 }
611
612 slog.Debug("Created sailor profile",
613 "component", "auth/usercontext",
614 "did", uc.DID,
615 "defaultHold", normalizedDID)
616}
617
618// GetATProtoClient returns a cached ATProto client for the target owner's PDS.
619// Authenticated if user is owner, otherwise anonymous.
620// Cached per-request (uses sync.Once).
621func (uc *UserContext) GetATProtoClient() *atproto.Client {
622 uc.atprotoClientOnce.Do(func() {
623 if uc.TargetOwnerPDS == "" {
624 return
625 }
626
627 // If puller is owner and authenticated, use authenticated client
628 if uc.DID == uc.TargetOwnerDID && uc.IsAuthenticated {
629 if uc.AuthMethod == AuthMethodOAuth && uc.refresher != nil {
630 uc.atprotoClient = atproto.NewClientWithSessionProvider(uc.TargetOwnerPDS, uc.TargetOwnerDID, uc.refresher)
631 return
632 } else if uc.AuthMethod == AuthMethodAppPassword {
633 accessToken, _ := GetGlobalTokenCache().Get(uc.TargetOwnerDID)
634 uc.atprotoClient = atproto.NewClient(uc.TargetOwnerPDS, uc.TargetOwnerDID, accessToken)
635 return
636 }
637 }
638
639 // Anonymous client for reads
640 uc.atprotoClient = atproto.NewClient(uc.TargetOwnerPDS, uc.TargetOwnerDID, "")
641 })
642 return uc.atprotoClient
643}
644
645// ResolveHoldDID finds the hold for the target repository.
646// - Pull: uses database lookup (historical from manifest)
647// - Push: uses discovery (sailor profile → default)
648//
649// Must be called after SetTarget() is called with at least TargetOwnerDID and TargetRepo set.
650// Updates TargetHoldDID on success.
651func (uc *UserContext) ResolveHoldDID(ctx context.Context, sqlDB *sql.DB) (string, error) {
652 if uc.TargetOwnerDID == "" {
653 return "", fmt.Errorf("target owner not set")
654 }
655
656 var holdDID string
657 var err error
658
659 switch uc.Action {
660 case ActionPull:
661 // For pulls, look up historical hold from database
662 holdDID, err = uc.resolveHoldForPull(ctx, sqlDB)
663 case ActionPush:
664 // For pushes, discover hold from owner's profile
665 holdDID, err = uc.resolveHoldForPush(ctx)
666 default:
667 // Default to push discovery
668 holdDID, err = uc.resolveHoldForPush(ctx)
669 }
670
671 if err != nil {
672 return "", err
673 }
674
675 if holdDID == "" {
676 return "", fmt.Errorf("no hold DID found for %s/%s", uc.TargetOwnerDID, uc.TargetRepo)
677 }
678
679 uc.TargetHoldDID = holdDID
680 return holdDID, nil
681}
682
683// resolveHoldForPull looks up the hold from the database (historical reference)
684func (uc *UserContext) resolveHoldForPull(ctx context.Context, sqlDB *sql.DB) (string, error) {
685 // If no database is available, fall back to discovery
686 if sqlDB == nil {
687 return uc.resolveHoldForPush(ctx)
688 }
689
690 // Try database lookup first
691 holdDID, err := db.GetLatestHoldDIDForRepo(sqlDB, uc.TargetOwnerDID, uc.TargetRepo)
692 if err != nil {
693 slog.Debug("Database lookup failed, falling back to discovery",
694 "component", "auth/context",
695 "ownerDID", uc.TargetOwnerDID,
696 "repo", uc.TargetRepo,
697 "error", err)
698 return uc.resolveHoldForPush(ctx)
699 }
700
701 if holdDID != "" {
702 return holdDID, nil
703 }
704
705 // No historical hold found, fall back to discovery
706 return uc.resolveHoldForPush(ctx)
707}
708
709// resolveHoldForPush discovers hold from owner's sailor profile or default
710func (uc *UserContext) resolveHoldForPush(ctx context.Context) (string, error) {
711 // Create anonymous client to query owner's profile
712 client := atproto.NewClient(uc.TargetOwnerPDS, uc.TargetOwnerDID, "")
713
714 // Try to get owner's sailor profile
715 record, err := client.GetRecord(ctx, atproto.SailorProfileCollection, "self")
716 if err == nil && record != nil {
717 var profile atproto.SailorProfileRecord
718 if jsonErr := json.Unmarshal(record.Value, &profile); jsonErr == nil {
719 if profile.DefaultHold != "" {
720 // Normalize to DID if needed
721 holdDID := profile.DefaultHold
722 if !atproto.IsDID(holdDID) {
723 holdDID = atproto.ResolveHoldDIDFromURL(holdDID)
724 }
725 slog.Debug("Found hold from owner's profile",
726 "component", "auth/context",
727 "ownerDID", uc.TargetOwnerDID,
728 "holdDID", holdDID)
729 return holdDID, nil
730 }
731 }
732 }
733
734 // Fall back to default hold
735 if uc.defaultHoldDID != "" {
736 slog.Debug("Using default hold",
737 "component", "auth/context",
738 "ownerDID", uc.TargetOwnerDID,
739 "defaultHoldDID", uc.defaultHoldDID)
740 return uc.defaultHoldDID, nil
741 }
742
743 return "", fmt.Errorf("no hold configured for %s and no default hold set", uc.TargetOwnerDID)
744}
745
746// =============================================================================
747// Test Helper Methods
748// =============================================================================
749// These methods are designed to make UserContext testable by allowing tests
750// to bypass network-dependent code paths (PDS resolution, OAuth token fetching).
751// Only use these in tests - they are not intended for production use.
752
753// SetPDSForTest sets the PDS endpoint directly, bypassing ResolvePDS network calls.
754// This allows tests to skip DID resolution which would make network requests.
755// Deprecated: Use SetPDS instead.
756func (uc *UserContext) SetPDSForTest(handle, pdsEndpoint string) {
757 uc.SetPDS(handle, pdsEndpoint)
758}
759
760// SetServiceTokenForTest pre-populates a service token for the given holdDID,
761// bypassing the sync.Once and OAuth/app-password fetching logic.
762// The token will appear as if it was already fetched and cached.
763func (uc *UserContext) SetServiceTokenForTest(holdDID, token string) {
764 entry := &serviceTokenEntry{
765 token: token,
766 expiresAt: time.Now().Add(5 * time.Minute),
767 err: nil,
768 }
769 // Mark the sync.Once as done so real fetch won't happen
770 entry.once.Do(func() {})
771 uc.serviceTokens.Store(holdDID, entry)
772}
773
774// SetAuthorizerForTest sets the authorizer for permission checks.
775// Use with MockHoldAuthorizer to control CanRead/CanWrite behavior in tests.
776func (uc *UserContext) SetAuthorizerForTest(authorizer HoldAuthorizer) {
777 uc.authorizer = authorizer
778}
779
780// SetDefaultHoldDIDForTest sets the default hold DID for tests.
781// This is used as fallback when resolving hold for push operations.
782func (uc *UserContext) SetDefaultHoldDIDForTest(holdDID string) {
783 uc.defaultHoldDID = holdDID
784}