package storage import ( "context" "fmt" "io" "log/slog" "net/http" "time" "atcr.io/pkg/atproto" "atcr.io/pkg/auth" ) // ServiceTokenFetcher returns a hold service token for the resolved holdDID. // Implementations wire to the appropriate auth path (OAuth refresher or // app-password access token). type ServiceTokenFetcher func(ctx context.Context, holdDID string) (string, error) // EnsureCrewMembership attempts to register the user as a crew member on their default hold. // The hold's requestCrew endpoint handles all authorization logic (checking allowAllCrew, // existing membership, etc). On success, warms the approval cache and clears any cached // denial. Best-effort: logs and returns on any error. // // fetchServiceToken is invoked only on cache miss. Pass nil to skip when no auth path // is available (callers that just want the cache short-circuit behavior). func EnsureCrewMembership( ctx context.Context, userDID string, defaultHoldDID string, authorizer auth.HoldAuthorizer, fetchServiceToken ServiceTokenFetcher, ) { if defaultHoldDID == "" { return } // Normalize URL to DID if needed holdDID, err := atproto.ResolveHoldDID(ctx, defaultHoldDID) if err != nil { slog.Warn("failed to resolve hold DID", "defaultHold", defaultHoldDID, "error", err) return } // Short-circuit if we have a recent cached approval. The hold's requestCrew // would just return success for an established crew member, so the round-trip // is pure waste. Cache lookup uses the same key (holdDID, userDID) that // CheckWriteAccess will hit later in the request. if authorizer != nil { if cached, err := authorizer.IsCachedCrewMember(ctx, holdDID, userDID); err == nil && cached { slog.Debug("crew membership cached, skipping requestCrew", "holdDID", holdDID, "userDID", userDID) return } } if fetchServiceToken == nil { slog.Debug("skipping crew registration - no service token fetcher", "holdDID", holdDID, "userDID", userDID) return } holdEndpoint, err := atproto.ResolveHoldURL(ctx, holdDID) if err != nil { slog.Warn("failed to resolve hold URL", "holdDID", holdDID, "error", err) return } serviceToken, err := fetchServiceToken(ctx, holdDID) if err != nil { slog.Warn("failed to get service token", "holdDID", holdDID, "userDID", userDID, "error", err) return } // Call requestCrew endpoint - it handles all the logic: // - Checks allowAllCrew flag // - Checks if already a crew member (returns success if so) // - Creates crew record if authorized if err := requestCrewMembership(ctx, holdEndpoint, serviceToken); err != nil { slog.Warn("failed to request crew membership", "holdDID", holdDID, "userDID", userDID, "error", err) return } slog.Info("successfully registered as crew member", "holdDID", holdDID, "userDID", userDID) if authorizer != nil { // Warm the approval cache so subsequent CheckWriteAccess calls within this // request (e.g. each layer in a multi-layer push) skip the XRPC getRecord. if err := authorizer.RecordCrewApproval(ctx, holdDID, userDID); err != nil { slog.Warn("failed to record crew approval after crew registration", "holdDID", holdDID, "userDID", userDID, "error", err) } // Clear any cached denial to ensure immediate access if err := authorizer.ClearCrewDenial(ctx, holdDID, userDID); err != nil { slog.Warn("failed to clear denial cache after crew registration", "holdDID", holdDID, "userDID", userDID, "error", err) } } } // 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 }