Files
at-container-registry/pkg/auth/usercontext.go
2025-12-29 17:02:07 -06:00

785 lines
24 KiB
Go

// Package auth provides UserContext for managing authenticated user state
// throughout request handling in the AppView.
package auth
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"sync"
"time"
"atcr.io/pkg/appview/db"
"atcr.io/pkg/atproto"
"atcr.io/pkg/auth/oauth"
)
// Auth method constants (duplicated from token package to avoid import cycle)
const (
AuthMethodOAuth = "oauth"
AuthMethodAppPassword = "app_password"
)
// RequestAction represents the type of registry operation
type RequestAction int
const (
ActionUnknown RequestAction = iota
ActionPull // GET/HEAD - reading from registry
ActionPush // PUT/POST/DELETE - writing to registry
ActionInspect // Metadata operations only
)
func (a RequestAction) String() string {
switch a {
case ActionPull:
return "pull"
case ActionPush:
return "push"
case ActionInspect:
return "inspect"
default:
return "unknown"
}
}
// HoldPermissions describes what the user can do on a specific hold
type HoldPermissions struct {
HoldDID string // Hold being checked
IsOwner bool // User is captain of this hold
IsCrew bool // User is a crew member
IsPublic bool // Hold allows public reads
CanRead bool // Computed: can user read blobs?
CanWrite bool // Computed: can user write blobs?
CanAdmin bool // Computed: can user manage crew?
Permissions []string // Raw permissions from crew record
}
// contextKey is unexported to prevent collisions
type contextKey struct{}
// userContextKey is the context key for UserContext
var userContextKey = contextKey{}
// userSetupCache tracks which users have had their profile/crew setup ensured
var userSetupCache sync.Map // did -> time.Time
// userSetupTTL is how long to cache user setup status (1 hour)
const userSetupTTL = 1 * time.Hour
// Dependencies bundles services needed by UserContext
type Dependencies struct {
Refresher *oauth.Refresher
Authorizer HoldAuthorizer
DefaultHoldDID string // AppView's default hold DID
}
// UserContext encapsulates authenticated user state for a request.
// Built early in the middleware chain and available throughout request processing.
//
// Two-phase initialization:
// 1. Middleware phase: Identity is set (DID, authMethod, action)
// 2. Repository() phase: Target is set via SetTarget() (owner, repo, holdDID)
type UserContext struct {
// === User Identity (set in middleware) ===
DID string // User's DID (empty if unauthenticated)
Handle string // User's handle (may be empty)
PDSEndpoint string // User's PDS endpoint
AuthMethod string // "oauth", "app_password", or ""
IsAuthenticated bool
// === Request Info ===
Action RequestAction
HTTPMethod string
// === Target Info (set by SetTarget) ===
TargetOwnerDID string // whose repo is being accessed
TargetOwnerHandle string
TargetOwnerPDS string
TargetRepo string // image name (e.g., "quickslice")
TargetHoldDID string // hold where blobs live/will live
// === Dependencies (injected) ===
refresher *oauth.Refresher
authorizer HoldAuthorizer
defaultHoldDID string
// === Cached State (lazy-loaded) ===
serviceTokens sync.Map // holdDID -> *serviceTokenEntry
permissions sync.Map // holdDID -> *HoldPermissions
pdsResolved bool
pdsResolveErr error
mu sync.Mutex // protects PDS resolution
atprotoClient *atproto.Client
atprotoClientOnce sync.Once
}
// FromContext retrieves UserContext from context.
// Returns nil if not present (unauthenticated or before middleware).
func FromContext(ctx context.Context) *UserContext {
uc, _ := ctx.Value(userContextKey).(*UserContext)
return uc
}
// WithUserContext adds UserContext to context
func WithUserContext(ctx context.Context, uc *UserContext) context.Context {
return context.WithValue(ctx, userContextKey, uc)
}
// NewUserContext creates a UserContext from extracted JWT claims.
// The deps parameter provides access to services needed for lazy operations.
func NewUserContext(did, authMethod, httpMethod string, deps *Dependencies) *UserContext {
action := ActionUnknown
switch httpMethod {
case "GET", "HEAD":
action = ActionPull
case "PUT", "POST", "PATCH", "DELETE":
action = ActionPush
}
var refresher *oauth.Refresher
var authorizer HoldAuthorizer
var defaultHoldDID string
if deps != nil {
refresher = deps.Refresher
authorizer = deps.Authorizer
defaultHoldDID = deps.DefaultHoldDID
}
return &UserContext{
DID: did,
AuthMethod: authMethod,
IsAuthenticated: did != "",
Action: action,
HTTPMethod: httpMethod,
refresher: refresher,
authorizer: authorizer,
defaultHoldDID: defaultHoldDID,
}
}
// SetPDS sets the user's PDS endpoint directly, bypassing network resolution.
// Use when PDS is already known (e.g., from previous resolution or client).
func (uc *UserContext) SetPDS(handle, pdsEndpoint string) {
uc.mu.Lock()
defer uc.mu.Unlock()
uc.Handle = handle
uc.PDSEndpoint = pdsEndpoint
uc.pdsResolved = true
uc.pdsResolveErr = nil
}
// SetTarget sets the target repository information.
// Called in Repository() after resolving the owner identity.
func (uc *UserContext) SetTarget(ownerDID, ownerHandle, ownerPDS, repo, holdDID string) {
uc.TargetOwnerDID = ownerDID
uc.TargetOwnerHandle = ownerHandle
uc.TargetOwnerPDS = ownerPDS
uc.TargetRepo = repo
uc.TargetHoldDID = holdDID
}
// ResolvePDS resolves the user's PDS endpoint (lazy, cached).
// Safe to call multiple times; resolution happens once.
func (uc *UserContext) ResolvePDS(ctx context.Context) error {
if !uc.IsAuthenticated {
return nil // Nothing to resolve for anonymous users
}
uc.mu.Lock()
defer uc.mu.Unlock()
if uc.pdsResolved {
return uc.pdsResolveErr
}
_, handle, pds, err := atproto.ResolveIdentity(ctx, uc.DID)
if err != nil {
uc.pdsResolveErr = err
uc.pdsResolved = true
return err
}
uc.Handle = handle
uc.PDSEndpoint = pds
uc.pdsResolved = true
return nil
}
// GetServiceToken returns a service token for the target hold.
// Uses internal caching with sync.Once per holdDID.
// Requires target to be set via SetTarget().
func (uc *UserContext) GetServiceToken(ctx context.Context) (string, error) {
if uc.TargetHoldDID == "" {
return "", fmt.Errorf("target hold not set (call SetTarget first)")
}
return uc.GetServiceTokenForHold(ctx, uc.TargetHoldDID)
}
// GetServiceTokenForHold returns a service token for an arbitrary hold.
// Uses internal caching with sync.Once per holdDID.
func (uc *UserContext) GetServiceTokenForHold(ctx context.Context, holdDID string) (string, error) {
if !uc.IsAuthenticated {
return "", fmt.Errorf("cannot get service token: user not authenticated")
}
// Ensure PDS is resolved
if err := uc.ResolvePDS(ctx); err != nil {
return "", fmt.Errorf("failed to resolve PDS: %w", err)
}
// Load or create cache entry
entryVal, _ := uc.serviceTokens.LoadOrStore(holdDID, &serviceTokenEntry{})
entry := entryVal.(*serviceTokenEntry)
entry.once.Do(func() {
slog.Debug("Fetching service token",
"component", "auth/context",
"userDID", uc.DID,
"holdDID", holdDID,
"authMethod", uc.AuthMethod)
// Use unified service token function (handles both OAuth and app-password)
serviceToken, err := GetOrFetchServiceToken(
ctx, uc.AuthMethod, uc.refresher, uc.DID, holdDID, uc.PDSEndpoint,
)
entry.token = serviceToken
entry.err = err
if err == nil {
// Parse JWT to get expiry
expiry, parseErr := ParseJWTExpiry(serviceToken)
if parseErr == nil {
entry.expiresAt = expiry.Add(-10 * time.Second) // Safety margin
} else {
entry.expiresAt = time.Now().Add(45 * time.Second) // Default fallback
}
}
})
return entry.token, entry.err
}
// CanRead checks if user can read blobs from target hold.
// - Public hold: any user (even anonymous)
// - Private hold: owner OR crew with blob:read/blob:write
func (uc *UserContext) CanRead(ctx context.Context) (bool, error) {
if uc.TargetHoldDID == "" {
return false, fmt.Errorf("target hold not set (call SetTarget first)")
}
if uc.authorizer == nil {
return false, fmt.Errorf("authorizer not configured")
}
return uc.authorizer.CheckReadAccess(ctx, uc.TargetHoldDID, uc.DID)
}
// CanWrite checks if user can write blobs to target hold.
// - Must be authenticated
// - Must be owner OR crew with blob:write
func (uc *UserContext) CanWrite(ctx context.Context) (bool, error) {
if uc.TargetHoldDID == "" {
return false, fmt.Errorf("target hold not set (call SetTarget first)")
}
if !uc.IsAuthenticated {
return false, nil // Anonymous writes never allowed
}
if uc.authorizer == nil {
return false, fmt.Errorf("authorizer not configured")
}
return uc.authorizer.CheckWriteAccess(ctx, uc.TargetHoldDID, uc.DID)
}
// GetPermissions returns detailed permissions for target hold.
// Lazy-loaded and cached per holdDID.
func (uc *UserContext) GetPermissions(ctx context.Context) (*HoldPermissions, error) {
if uc.TargetHoldDID == "" {
return nil, fmt.Errorf("target hold not set (call SetTarget first)")
}
return uc.GetPermissionsForHold(ctx, uc.TargetHoldDID)
}
// GetPermissionsForHold returns detailed permissions for an arbitrary hold.
// Lazy-loaded and cached per holdDID.
func (uc *UserContext) GetPermissionsForHold(ctx context.Context, holdDID string) (*HoldPermissions, error) {
// Check cache first
if cached, ok := uc.permissions.Load(holdDID); ok {
return cached.(*HoldPermissions), nil
}
if uc.authorizer == nil {
return nil, fmt.Errorf("authorizer not configured")
}
// Build permissions by querying authorizer
captain, err := uc.authorizer.GetCaptainRecord(ctx, holdDID)
if err != nil {
return nil, fmt.Errorf("failed to get captain record: %w", err)
}
perms := &HoldPermissions{
HoldDID: holdDID,
IsPublic: captain.Public,
IsOwner: uc.DID != "" && uc.DID == captain.Owner,
}
// Check crew membership if authenticated and not owner
if uc.IsAuthenticated && !perms.IsOwner {
isCrew, crewErr := uc.authorizer.IsCrewMember(ctx, holdDID, uc.DID)
if crewErr != nil {
slog.Warn("Failed to check crew membership",
"component", "auth/context",
"holdDID", holdDID,
"userDID", uc.DID,
"error", crewErr)
}
perms.IsCrew = isCrew
}
// Compute permissions based on role
if perms.IsOwner {
perms.CanRead = true
perms.CanWrite = true
perms.CanAdmin = true
} else if perms.IsCrew {
// Crew members can read and write (for now, all crew have blob:write)
// TODO: Check specific permissions from crew record
perms.CanRead = true
perms.CanWrite = true
perms.CanAdmin = false
} else if perms.IsPublic {
// Public hold - anyone can read
perms.CanRead = true
perms.CanWrite = false
perms.CanAdmin = false
} else if uc.IsAuthenticated {
// Private hold, authenticated non-crew
// Per permission matrix: cannot read private holds
perms.CanRead = false
perms.CanWrite = false
perms.CanAdmin = false
} else {
// Anonymous on private hold
perms.CanRead = false
perms.CanWrite = false
perms.CanAdmin = false
}
// Cache and return
uc.permissions.Store(holdDID, perms)
return perms, nil
}
// IsCrewMember checks if user is crew of target hold.
func (uc *UserContext) IsCrewMember(ctx context.Context) (bool, error) {
if uc.TargetHoldDID == "" {
return false, fmt.Errorf("target hold not set (call SetTarget first)")
}
if !uc.IsAuthenticated {
return false, nil
}
if uc.authorizer == nil {
return false, fmt.Errorf("authorizer not configured")
}
return uc.authorizer.IsCrewMember(ctx, uc.TargetHoldDID, uc.DID)
}
// EnsureCrewMembership is a standalone function to register as crew on a hold.
// Use this when you don't have a UserContext (e.g., OAuth callback).
// This is best-effort and logs errors without failing.
func EnsureCrewMembership(ctx context.Context, did, pdsEndpoint string, refresher *oauth.Refresher, holdDID string) {
if holdDID == "" {
return
}
// Only works with OAuth (refresher required) - app passwords can't get service tokens
if refresher == nil {
slog.Debug("skipping crew registration - no OAuth refresher (app password flow)", "holdDID", holdDID)
return
}
// Normalize URL to DID if needed
if !atproto.IsDID(holdDID) {
holdDID = atproto.ResolveHoldDIDFromURL(holdDID)
if holdDID == "" {
slog.Warn("failed to resolve hold DID", "defaultHold", holdDID)
return
}
}
// Get service token for the hold (OAuth only at this point)
serviceToken, err := GetOrFetchServiceToken(ctx, AuthMethodOAuth, refresher, did, holdDID, pdsEndpoint)
if err != nil {
slog.Warn("failed to get service token", "holdDID", holdDID, "error", err)
return
}
// Resolve hold DID to HTTP endpoint
holdEndpoint := atproto.ResolveHoldURL(holdDID)
if holdEndpoint == "" {
slog.Warn("failed to resolve hold endpoint", "holdDID", holdDID)
return
}
// Call requestCrew endpoint
if err := requestCrewMembership(ctx, holdEndpoint, serviceToken); err != nil {
slog.Warn("failed to request crew membership", "holdDID", holdDID, "error", err)
return
}
slog.Info("successfully registered as crew member", "holdDID", holdDID, "userDID", did)
}
// ensureCrewMembership attempts to register as crew on target hold (UserContext method).
// Called automatically during first push; idempotent.
// This is a best-effort operation and logs errors without failing.
// Requires SetTarget() to be called first.
func (uc *UserContext) ensureCrewMembership(ctx context.Context) error {
if uc.TargetHoldDID == "" {
return fmt.Errorf("target hold not set (call SetTarget first)")
}
return uc.EnsureCrewMembershipForHold(ctx, uc.TargetHoldDID)
}
// EnsureCrewMembershipForHold attempts to register as crew on the specified hold.
// This is the core implementation that can be called with any holdDID.
// Called automatically during first push; idempotent.
// This is a best-effort operation and logs errors without failing.
func (uc *UserContext) EnsureCrewMembershipForHold(ctx context.Context, holdDID string) error {
if holdDID == "" {
return nil // Nothing to do
}
// Normalize URL to DID if needed
if !atproto.IsDID(holdDID) {
holdDID = atproto.ResolveHoldDIDFromURL(holdDID)
if holdDID == "" {
return fmt.Errorf("failed to resolve hold DID from URL")
}
}
if !uc.IsAuthenticated {
return fmt.Errorf("cannot register as crew: user not authenticated")
}
if uc.refresher == nil {
return fmt.Errorf("cannot register as crew: OAuth session required")
}
// Get service token for the hold
serviceToken, err := uc.GetServiceTokenForHold(ctx, holdDID)
if err != nil {
return fmt.Errorf("failed to get service token: %w", err)
}
// Resolve hold DID to HTTP endpoint
holdEndpoint := atproto.ResolveHoldURL(holdDID)
if holdEndpoint == "" {
return fmt.Errorf("failed to resolve hold endpoint for %s", holdDID)
}
// Call requestCrew endpoint
return requestCrewMembership(ctx, holdEndpoint, serviceToken)
}
// requestCrewMembership calls the hold's requestCrew endpoint
// The endpoint handles all authorization and duplicate checking internally
func requestCrewMembership(ctx context.Context, holdEndpoint, serviceToken string) error {
// Add 5 second timeout to prevent hanging on offline holds
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
url := fmt.Sprintf("%s%s", holdEndpoint, atproto.HoldRequestCrew)
req, err := http.NewRequestWithContext(ctx, "POST", url, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+serviceToken)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
// Read response body to capture actual error message from hold
body, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return fmt.Errorf("requestCrew failed with status %d (failed to read error body: %w)", resp.StatusCode, readErr)
}
return fmt.Errorf("requestCrew failed with status %d: %s", resp.StatusCode, string(body))
}
return nil
}
// GetUserClient returns an authenticated ATProto client for the user's own PDS.
// Used for profile operations (reading/writing to user's own repo).
// Returns nil if not authenticated or PDS not resolved.
func (uc *UserContext) GetUserClient() *atproto.Client {
if !uc.IsAuthenticated || uc.PDSEndpoint == "" {
return nil
}
if uc.AuthMethod == AuthMethodOAuth && uc.refresher != nil {
return atproto.NewClientWithSessionProvider(uc.PDSEndpoint, uc.DID, uc.refresher)
} else if uc.AuthMethod == AuthMethodAppPassword {
accessToken, _ := GetGlobalTokenCache().Get(uc.DID)
return atproto.NewClient(uc.PDSEndpoint, uc.DID, accessToken)
}
return nil
}
// EnsureUserSetup ensures the user has a profile and crew membership.
// Called once per user (cached for userSetupTTL). Runs in background - does not block.
// Safe to call on every request.
func (uc *UserContext) EnsureUserSetup() {
if !uc.IsAuthenticated || uc.DID == "" {
return
}
// Check cache - skip if recently set up
if lastSetup, ok := userSetupCache.Load(uc.DID); ok {
if time.Since(lastSetup.(time.Time)) < userSetupTTL {
return
}
}
// Run in background to avoid blocking requests
go func() {
bgCtx := context.Background()
// 1. Ensure profile exists
if client := uc.GetUserClient(); client != nil {
uc.ensureProfile(bgCtx, client)
}
// 2. Ensure crew membership on default hold
if uc.defaultHoldDID != "" {
EnsureCrewMembership(bgCtx, uc.DID, uc.PDSEndpoint, uc.refresher, uc.defaultHoldDID)
}
// Mark as set up
userSetupCache.Store(uc.DID, time.Now())
slog.Debug("User setup complete",
"component", "auth/usercontext",
"did", uc.DID,
"defaultHoldDID", uc.defaultHoldDID)
}()
}
// ensureProfile creates sailor profile if it doesn't exist.
// Inline implementation to avoid circular import with storage package.
func (uc *UserContext) ensureProfile(ctx context.Context, client *atproto.Client) {
// Check if profile already exists
profile, err := client.GetRecord(ctx, atproto.SailorProfileCollection, "self")
if err == nil && profile != nil {
return // Already exists
}
// Create profile with default hold
normalizedDID := ""
if uc.defaultHoldDID != "" {
normalizedDID = atproto.ResolveHoldDIDFromURL(uc.defaultHoldDID)
}
newProfile := atproto.NewSailorProfileRecord(normalizedDID)
if _, err := client.PutRecord(ctx, atproto.SailorProfileCollection, "self", newProfile); err != nil {
slog.Warn("Failed to create sailor profile",
"component", "auth/usercontext",
"did", uc.DID,
"error", err)
return
}
slog.Debug("Created sailor profile",
"component", "auth/usercontext",
"did", uc.DID,
"defaultHold", normalizedDID)
}
// GetATProtoClient returns a cached ATProto client for the target owner's PDS.
// Authenticated if user is owner, otherwise anonymous.
// Cached per-request (uses sync.Once).
func (uc *UserContext) GetATProtoClient() *atproto.Client {
uc.atprotoClientOnce.Do(func() {
if uc.TargetOwnerPDS == "" {
return
}
// If puller is owner and authenticated, use authenticated client
if uc.DID == uc.TargetOwnerDID && uc.IsAuthenticated {
if uc.AuthMethod == AuthMethodOAuth && uc.refresher != nil {
uc.atprotoClient = atproto.NewClientWithSessionProvider(uc.TargetOwnerPDS, uc.TargetOwnerDID, uc.refresher)
return
} else if uc.AuthMethod == AuthMethodAppPassword {
accessToken, _ := GetGlobalTokenCache().Get(uc.TargetOwnerDID)
uc.atprotoClient = atproto.NewClient(uc.TargetOwnerPDS, uc.TargetOwnerDID, accessToken)
return
}
}
// Anonymous client for reads
uc.atprotoClient = atproto.NewClient(uc.TargetOwnerPDS, uc.TargetOwnerDID, "")
})
return uc.atprotoClient
}
// ResolveHoldDID finds the hold for the target repository.
// - Pull: uses database lookup (historical from manifest)
// - Push: uses discovery (sailor profile → default)
//
// Must be called after SetTarget() is called with at least TargetOwnerDID and TargetRepo set.
// Updates TargetHoldDID on success.
func (uc *UserContext) ResolveHoldDID(ctx context.Context, sqlDB *sql.DB) (string, error) {
if uc.TargetOwnerDID == "" {
return "", fmt.Errorf("target owner not set")
}
var holdDID string
var err error
switch uc.Action {
case ActionPull:
// For pulls, look up historical hold from database
holdDID, err = uc.resolveHoldForPull(ctx, sqlDB)
case ActionPush:
// For pushes, discover hold from owner's profile
holdDID, err = uc.resolveHoldForPush(ctx)
default:
// Default to push discovery
holdDID, err = uc.resolveHoldForPush(ctx)
}
if err != nil {
return "", err
}
if holdDID == "" {
return "", fmt.Errorf("no hold DID found for %s/%s", uc.TargetOwnerDID, uc.TargetRepo)
}
uc.TargetHoldDID = holdDID
return holdDID, nil
}
// resolveHoldForPull looks up the hold from the database (historical reference)
func (uc *UserContext) resolveHoldForPull(ctx context.Context, sqlDB *sql.DB) (string, error) {
// If no database is available, fall back to discovery
if sqlDB == nil {
return uc.resolveHoldForPush(ctx)
}
// Try database lookup first
holdDID, err := db.GetLatestHoldDIDForRepo(sqlDB, uc.TargetOwnerDID, uc.TargetRepo)
if err != nil {
slog.Debug("Database lookup failed, falling back to discovery",
"component", "auth/context",
"ownerDID", uc.TargetOwnerDID,
"repo", uc.TargetRepo,
"error", err)
return uc.resolveHoldForPush(ctx)
}
if holdDID != "" {
return holdDID, nil
}
// No historical hold found, fall back to discovery
return uc.resolveHoldForPush(ctx)
}
// resolveHoldForPush discovers hold from owner's sailor profile or default
func (uc *UserContext) resolveHoldForPush(ctx context.Context) (string, error) {
// Create anonymous client to query owner's profile
client := atproto.NewClient(uc.TargetOwnerPDS, uc.TargetOwnerDID, "")
// Try to get owner's sailor profile
record, err := client.GetRecord(ctx, atproto.SailorProfileCollection, "self")
if err == nil && record != nil {
var profile atproto.SailorProfileRecord
if jsonErr := json.Unmarshal(record.Value, &profile); jsonErr == nil {
if profile.DefaultHold != "" {
// Normalize to DID if needed
holdDID := profile.DefaultHold
if !atproto.IsDID(holdDID) {
holdDID = atproto.ResolveHoldDIDFromURL(holdDID)
}
slog.Debug("Found hold from owner's profile",
"component", "auth/context",
"ownerDID", uc.TargetOwnerDID,
"holdDID", holdDID)
return holdDID, nil
}
}
}
// Fall back to default hold
if uc.defaultHoldDID != "" {
slog.Debug("Using default hold",
"component", "auth/context",
"ownerDID", uc.TargetOwnerDID,
"defaultHoldDID", uc.defaultHoldDID)
return uc.defaultHoldDID, nil
}
return "", fmt.Errorf("no hold configured for %s and no default hold set", uc.TargetOwnerDID)
}
// =============================================================================
// Test Helper Methods
// =============================================================================
// These methods are designed to make UserContext testable by allowing tests
// to bypass network-dependent code paths (PDS resolution, OAuth token fetching).
// Only use these in tests - they are not intended for production use.
// SetPDSForTest sets the PDS endpoint directly, bypassing ResolvePDS network calls.
// This allows tests to skip DID resolution which would make network requests.
// Deprecated: Use SetPDS instead.
func (uc *UserContext) SetPDSForTest(handle, pdsEndpoint string) {
uc.SetPDS(handle, pdsEndpoint)
}
// SetServiceTokenForTest pre-populates a service token for the given holdDID,
// bypassing the sync.Once and OAuth/app-password fetching logic.
// The token will appear as if it was already fetched and cached.
func (uc *UserContext) SetServiceTokenForTest(holdDID, token string) {
entry := &serviceTokenEntry{
token: token,
expiresAt: time.Now().Add(5 * time.Minute),
err: nil,
}
// Mark the sync.Once as done so real fetch won't happen
entry.once.Do(func() {})
uc.serviceTokens.Store(holdDID, entry)
}
// SetAuthorizerForTest sets the authorizer for permission checks.
// Use with MockHoldAuthorizer to control CanRead/CanWrite behavior in tests.
func (uc *UserContext) SetAuthorizerForTest(authorizer HoldAuthorizer) {
uc.authorizer = authorizer
}
// SetDefaultHoldDIDForTest sets the default hold DID for tests.
// This is used as fallback when resolving hold for push operations.
func (uc *UserContext) SetDefaultHoldDIDForTest(holdDID string) {
uc.defaultHoldDID = holdDID
}