Repository: prebid/prebid-server
Pull request #2445 (2022-11-02 21:29:34)

FLEDGE server support
- Request must include `imp[].ext.ae=1`
- Bidders receive `ExtImpBidder.AuctionEnvironment` 
- Bidders return `BidResponse.FledgeAuctionConfigs` as a `[]FledgeAuctionConfig{ImpId, Bidder, Config}`
- Adaptor code copies it to `pbsOrtbSeatBid.fledgeAuctionConfigs` field
- exchange bid postprocessing in `getAllBids` splits fledgeAuctionConfigs from bids and merges them into what will become `ext.prebid.fledge.auctionconfigs[]`

Potential nice to haves ? listed for tracking and discussion but they don't hold back the functionality implemented.
- [x] change to `imp.ext.ae`
- [ ] validation on impid matching
- [ ] validation that imp request had ae: 1
- [x] bidderName validation or override
- [x] handle no-bids with fledge response, as they are currently discarded
- [ ] dedupe responses from same bidder for same impid ? not sure how
- [x] support stored responses with fledge payload

PBS-Java PR: https://github.com/prebid/prebid-server-java/pull/2092

bsardo: @laurb9 if you don't mind, can you please open a separate PR for your adapter changes so that they are separate from the PBS Core changes when this is ready for review?
laurb9: > if you don't mind, can you please open a separate PR for your adapter changes so that they are separate from the PBS Core changes when this is ready for review?

will do! 
bretg: related to issue https://github.com/prebid/prebid-server/issues/2411
hhhjort: One possible deduping solution would be to choose the "first" or "last" config seen for an seat/ImpID pair, and log a warning that the extra fledge config(s) were dropped. First and last are in quotes, as I don't think there is any guarantee on what order the fledge configs would be processed in.

In addition, it may be an idea to track bidder/seatID/ImpID, as one source of duplication could be two bidders both bidding into the same seat ID. This will allow for a descriptive warning that would pinpoint the source of the issue (one bidder providing duplicate fledges vs. two bidders trying to use the same seat)
hhhjort: The three unchecked items. Are you planning on doing them in a separate PR, or are you still planning on handling them in this PR?
laurb9: > The three unchecked items. Are you planning on doing them in a separate PR, or are you still planning on handling them in this PR?

I considered leaving them out of this PR and address later if we encounter issues in actual use. Open for discussion of course. The thought process leading to this is that FLEDGE is an emerging proposal, supporting it minimally enables changes to happen faster. There's no harm at the moment if PBS returns all responses as-is and lets upstream make sense of them. Prebid.js might have to do the same type of work while processing responses from PBS and the other bidders anyway. Thoughts ?
laurb9: Found through manual integration that `validateImpExt` now rejects imp.ext.ae as unknown bidder  (_request.imp[0].ext.prebid.bidder contains unkown bidder: ae_). Looking into it and should have a fix shortly.
bsardo: @laurb9 it looks like you'll need to merge with master to resolve conflicts. We can give this another look after that.

Patch:
diff --git a/adapters/bidder.go b/adapters/bidder.go
index 952ed20f..9f8bd7fa 100644
--- a/adapters/bidder.go
+++ b/adapters/bidder.go
@@ -60,8 +60,9 @@ type TimeoutBidder interface {
 // From the bid response, the bidder accepts a list of valid currencies for the bid.
 // The currency is the same across all bids.
 type BidderResponse struct {
-	Currency string
-	Bids     []*TypedBid
+	Currency             string
+	Bids                 []*TypedBid
+	FledgeAuctionConfigs []*openrtb_ext.FledgeAuctionConfig
 }
 
 // NewBidderResponseWithBidsCapacity create a new BidderResponse initialising the bids array capacity and the default currency value
@@ -134,6 +135,8 @@ type ExtImpBidder struct {
 	// Bidder implementations may safely assume that this JSON has been validated by their
 	// static/bidder-params/{bidder}.json file.
 	Bidder json.RawMessage `json:"bidder"`
+
+	AuctionEnvironment openrtb_ext.AuctionEnvironmentType `json:"ae,omitempty"`
 }
 
 func (r *RequestData) SetBasicAuth(username string, password string) {
diff --git a/endpoints/openrtb2/auction.go b/endpoints/openrtb2/auction.go
index 44005a7d..f8c4aecd 100644
--- a/endpoints/openrtb2/auction.go
+++ b/endpoints/openrtb2/auction.go
@@ -1447,6 +1447,8 @@ func isPossibleBidder(bidder string) bool {
 		return false
 	case openrtb_ext.BidderReservedTID:
 		return false
+	case openrtb_ext.BidderReservedAE:
+		return false
 	default:
 		return true
 	}
diff --git a/exchange/bidder.go b/exchange/bidder.go
index 969b1887..125a1714 100644
--- a/exchange/bidder.go
+++ b/exchange/bidder.go
@@ -275,6 +275,15 @@ func (bidder *bidderAdapter) requestBid(ctx context.Context, bidderRequest Bidde
 					}
 				}
 
+				// FLEDGE auctionconfig responses are sent separate from bids
+				if bidResponse.FledgeAuctionConfigs != nil {
+					if fledgeAuctionConfigs := seatBidMap[bidderRequest.BidderName].FledgeAuctionConfigs; fledgeAuctionConfigs != nil {
+						seatBidMap[bidderRequest.BidderName].FledgeAuctionConfigs = append(fledgeAuctionConfigs, bidResponse.FledgeAuctionConfigs...)
+					} else {
+						seatBidMap[bidderRequest.BidderName].FledgeAuctionConfigs = bidResponse.FledgeAuctionConfigs
+					}
+				}
+
 				if len(bidderRequest.BidderStoredResponses) > 0 {
 					//set imp ids back to response for bids with stored responses
 					for i := 0; i < len(bidResponse.Bids); i++ {
diff --git a/exchange/entities/entities.go b/exchange/entities/entities.go
index f038d6b2..d535af00 100644
--- a/exchange/entities/entities.go
+++ b/exchange/entities/entities.go
@@ -14,6 +14,9 @@ type PbsOrtbSeatBid struct {
 	// Currency is the Currency in which the Bids are made.
 	// Should be a valid Currency ISO code.
 	Currency string
+	// fledgeAuctionConfigs is quasi-opaque data passed back for in-browser interest group auction.
+	// if exists, it should be passed through even if bids[] is empty.
+	FledgeAuctionConfigs []*openrtb_ext.FledgeAuctionConfig
 	// HttpCalls is the list of debugging info. It should only be populated if the request.test == 1.
 	// This will become response.ext.debug.httpcalls.{bidder} on the final Response.
 	HttpCalls []*openrtb_ext.ExtHttpCall
diff --git a/exchange/exchange.go b/exchange/exchange.go
index 9686fc48..2c85b024 100644
--- a/exchange/exchange.go
+++ b/exchange/exchange.go
@@ -89,6 +89,7 @@ type bidResponseWrapper struct {
 	adapterSeatBids []*entities.PbsOrtbSeatBid
 	adapterExtra    *seatResponseExtra
 	bidder          openrtb_ext.BidderName
+	adapter         openrtb_ext.BidderName
 }
 
 type BidIDGenerator interface {
@@ -286,13 +287,14 @@ func (e *exchange) HoldAuction(ctx context.Context, r AuctionRequest, debugLog *
 
 	var adapterBids map[openrtb_ext.BidderName]*entities.PbsOrtbSeatBid
 	var adapterExtra map[openrtb_ext.BidderName]*seatResponseExtra
+	var fledge *openrtb_ext.Fledge
 	var anyBidsReturned bool
 
 	// List of bidders we have requests for.
 	var liveAdapters []openrtb_ext.BidderName
 
 	if len(r.StoredAuctionResponses) > 0 {
-		adapterBids, liveAdapters, err = buildStoredAuctionResponse(r.StoredAuctionResponses)
+		adapterBids, fledge, liveAdapters, err = buildStoredAuctionResponse(r.StoredAuctionResponses)
 		if err != nil {
 			return nil, err
 		}
@@ -310,7 +312,7 @@ func (e *exchange) HoldAuction(ctx context.Context, r AuctionRequest, debugLog *
 			alternateBidderCodes = *r.Account.AlternateBidderCodes
 		}
 
-		adapterBids, adapterExtra, anyBidsReturned = e.getAllBids(auctionCtx, bidderRequests, bidAdjustmentFactors, conversions, accountDebugAllow, r.GlobalPrivacyControlHeader, debugLog.DebugOverride, alternateBidderCodes, requestExt.Prebid.Experiment, r.HookExecutor)
+		adapterBids, adapterExtra, fledge, anyBidsReturned = e.getAllBids(auctionCtx, bidderRequests, bidAdjustmentFactors, conversions, accountDebugAllow, r.GlobalPrivacyControlHeader, debugLog.DebugOverride, alternateBidderCodes, requestExt.Prebid.Experiment, r.HookExecutor)
 	}
 
 	var auc *auction
@@ -357,7 +359,7 @@ func (e *exchange) HoldAuction(ctx context.Context, r AuctionRequest, debugLog *
 				errs = append(errs, dealErrs...)
 			}
 
-			bidResponseExt = e.makeExtBidResponse(adapterBids, adapterExtra, r, responseDebugAllow, requestExt.Prebid.Passthrough, errs)
+			bidResponseExt = e.makeExtBidResponse(adapterBids, adapterExtra, r, responseDebugAllow, requestExt.Prebid.Passthrough, fledge, errs)
 			if debugLog.DebugEnabledOrOverridden {
 				if bidRespExtBytes, err := json.Marshal(bidResponseExt); err == nil {
 					debugLog.Data.Response = string(bidRespExtBytes)
@@ -375,9 +377,9 @@ func (e *exchange) HoldAuction(ctx context.Context, r AuctionRequest, debugLog *
 			targData.setTargeting(auc, r.BidRequestWrapper.BidRequest.App != nil, bidCategory, r.Account.TruncateTargetAttribute)
 
 		}
-		bidResponseExt = e.makeExtBidResponse(adapterBids, adapterExtra, r, responseDebugAllow, requestExt.Prebid.Passthrough, errs)
+		bidResponseExt = e.makeExtBidResponse(adapterBids, adapterExtra, r, responseDebugAllow, requestExt.Prebid.Passthrough, fledge, errs)
 	} else {
-		bidResponseExt = e.makeExtBidResponse(adapterBids, adapterExtra, r, responseDebugAllow, requestExt.Prebid.Passthrough, errs)
+		bidResponseExt = e.makeExtBidResponse(adapterBids, adapterExtra, r, responseDebugAllow, requestExt.Prebid.Passthrough, fledge, errs)
 
 		if debugLog.DebugEnabledOrOverridden {
 
@@ -527,7 +529,9 @@ func (e *exchange) getAllBids(
 	experiment *openrtb_ext.Experiment,
 	hookExecutor hookexecution.StageExecutor) (
 	map[openrtb_ext.BidderName]*entities.PbsOrtbSeatBid,
-	map[openrtb_ext.BidderName]*seatResponseExtra, bool) {
+	map[openrtb_ext.BidderName]*seatResponseExtra,
+	*openrtb_ext.Fledge,
+	bool) {
 	// Set up pointers to the bid results
 	adapterBids := make(map[openrtb_ext.BidderName]*entities.PbsOrtbSeatBid, len(bidderRequests))
 	adapterExtra := make(map[openrtb_ext.BidderName]*seatResponseExtra, len(bidderRequests))
@@ -544,6 +548,7 @@ func (e *exchange) getAllBids(
 			}
 			brw := new(bidResponseWrapper)
 			brw.bidder = bidderRequest.BidderName
+			brw.adapter = bidderRequest.BidderCoreName
 			// Defer basic metrics to insure we capture them after all the values have been set
 			defer func() {
 				e.me.RecordAdapterRequest(bidderRequest.BidderLabels)
@@ -593,18 +598,25 @@ func (e *exchange) getAllBids(
 		}, chBids)
 		go bidderRunner(bidder, conversions)
 	}
+	var fledge *openrtb_ext.Fledge
+
 	// Wait for the bidders to do their thing
 	for i := 0; i < len(bidderRequests); i++ {
 		brw := <-chBids
 
 		//if bidder returned no bids back - remove bidder from further processing
 		for _, seatBid := range brw.adapterSeatBids {
-			if seatBid != nil && len(seatBid.Bids) != 0 {
-				if _, ok := adapterBids[openrtb_ext.BidderName(seatBid.Seat)]; ok {
-					adapterBids[openrtb_ext.BidderName(seatBid.Seat)].Bids = append(adapterBids[openrtb_ext.BidderName(seatBid.Seat)].Bids, seatBid.Bids...)
-				} else {
-					adapterBids[openrtb_ext.BidderName(seatBid.Seat)] = seatBid
+			if seatBid != nil {
+				bidderName := openrtb_ext.BidderName(seatBid.Seat)
+				if len(seatBid.Bids) != 0 {
+					if val, ok := adapterBids[bidderName]; ok {
+						adapterBids[bidderName].Bids = append(val.Bids, seatBid.Bids...)
+					} else {
+						adapterBids[bidderName] = seatBid
+					}
 				}
+				// collect fledgeAuctionConfigs separately from bids, as empty seatBids may be discarded
+				fledge = collectFledgeFromSeatBid(fledge, bidderName, brw.adapter, seatBid)
 			}
 		}
 		//but we need to add all bidders data to adapterExtra to have metrics and other metadata
@@ -615,7 +627,26 @@ func (e *exchange) getAllBids(
 		}
 	}
 
-	return adapterBids, adapterExtra, bidsFound
+	return adapterBids, adapterExtra, fledge, bidsFound
+}
+
+func collectFledgeFromSeatBid(fledge *openrtb_ext.Fledge, bidderName openrtb_ext.BidderName, adapterName openrtb_ext.BidderName, seatBid *entities.PbsOrtbSeatBid) *openrtb_ext.Fledge {
+	if seatBid.FledgeAuctionConfigs != nil {
+		if fledge == nil {
+			fledge = &openrtb_ext.Fledge{
+				AuctionConfigs: make([]*openrtb_ext.FledgeAuctionConfig, 0, len(seatBid.FledgeAuctionConfigs)),
+			}
+		}
+		for _, config := range seatBid.FledgeAuctionConfigs {
+			fledge.AuctionConfigs = append(fledge.AuctionConfigs, &openrtb_ext.FledgeAuctionConfig{
+				Bidder:  bidderName.String(),
+				Adapter: config.Bidder,
+				ImpId:   config.ImpId,
+				Config:  config.Config,
+			})
+		}
+	}
+	return fledge
 }
 
 func (e *exchange) recoverSafely(bidderRequests []BidderRequest,
@@ -974,7 +1005,7 @@ func getPrimaryAdServer(adServerId int) (string, error) {
 }
 
 // Extract all the data from the SeatBids and build the ExtBidResponse
-func (e *exchange) makeExtBidResponse(adapterBids map[openrtb_ext.BidderName]*entities.PbsOrtbSeatBid, adapterExtra map[openrtb_ext.BidderName]*seatResponseExtra, r AuctionRequest, debugInfo bool, passthrough json.RawMessage, errList []error) *openrtb_ext.ExtBidResponse {
+func (e *exchange) makeExtBidResponse(adapterBids map[openrtb_ext.BidderName]*entities.PbsOrtbSeatBid, adapterExtra map[openrtb_ext.BidderName]*seatResponseExtra, r AuctionRequest, debugInfo bool, passthrough json.RawMessage, fledge *openrtb_ext.Fledge, errList []error) *openrtb_ext.ExtBidResponse {
 	bidResponseExt := &openrtb_ext.ExtBidResponse{
 		Errors:               make(map[openrtb_ext.BidderName][]openrtb_ext.ExtBidderMessage, len(adapterBids)),
 		Warnings:             make(map[openrtb_ext.BidderName][]openrtb_ext.ExtBidderMessage, len(adapterBids)),
@@ -987,18 +1018,20 @@ func (e *exchange) makeExtBidResponse(adapterBids map[openrtb_ext.BidderName]*en
 			ResolvedRequest: r.ResolvedBidRequest,
 		}
 	}
+
+	var auctionTimestamp int64
 	if !r.StartTime.IsZero() {
-		// auctiontimestamp is the only response.ext.prebid attribute we may emit
-		bidResponseExt.Prebid = &openrtb_ext.ExtResponsePrebid{
-			AuctionTimestamp: r.StartTime.UnixNano() / 1e+6,
-		}
+		auctionTimestamp = r.StartTime.UnixMilli()
 	}
 
-	if passthrough != nil {
-		if bidResponseExt.Prebid == nil {
-			bidResponseExt.Prebid = &openrtb_ext.ExtResponsePrebid{}
+	if auctionTimestamp > 0 ||
+		passthrough != nil ||
+		fledge != nil {
+		bidResponseExt.Prebid = &openrtb_ext.ExtResponsePrebid{
+			AuctionTimestamp: auctionTimestamp,
+			Passthrough:      passthrough,
+			Fledge:           fledge,
 		}
-		bidResponseExt.Prebid.Passthrough = passthrough
 	}
 
 	for bidderName, responseExtra := range adapterExtra {
@@ -1234,15 +1267,20 @@ func listBiddersWithRequests(bidderRequests []BidderRequest) []openrtb_ext.Bidde
 	return liveAdapters
 }
 
-func buildStoredAuctionResponse(storedAuctionResponses map[string]json.RawMessage) (map[openrtb_ext.BidderName]*entities.PbsOrtbSeatBid, []openrtb_ext.BidderName, error) {
+func buildStoredAuctionResponse(storedAuctionResponses map[string]json.RawMessage) (
+	map[openrtb_ext.BidderName]*entities.PbsOrtbSeatBid,
+	*openrtb_ext.Fledge,
+	[]openrtb_ext.BidderName,
+	error) {
 
 	adapterBids := make(map[openrtb_ext.BidderName]*entities.PbsOrtbSeatBid, 0)
+	var fledge *openrtb_ext.Fledge
 	liveAdapters := make([]openrtb_ext.BidderName, 0)
 	for impId, storedResp := range storedAuctionResponses {
 		var seatBids []openrtb2.SeatBid
 
 		if err := json.Unmarshal(storedResp, &seatBids); err != nil {
-			return nil, nil, err
+			return nil, nil, nil, err
 		}
 		for _, seat := range seatBids {
 			var bidsToAdd []*entities.PbsOrtbBid
@@ -1254,20 +1292,51 @@ func buildStoredAuctionResponse(storedAuctionResponses map[string]json.RawMessag
 
 			bidderName := openrtb_ext.BidderName(seat.Seat)
 
+			if seat.Ext != nil {
+				var seatExt openrtb_ext.ExtBidResponse
+				if err := json.Unmarshal(seat.Ext, &seatExt); err != nil {
+					return nil, nil, nil, err
+				}
+				// add in FLEDGE response with impId substituted
+				if seatExt.Prebid != nil &&
+					seatExt.Prebid.Fledge != nil &&
+					seatExt.Prebid.Fledge.AuctionConfigs != nil {
+					auctionConfigs := seatExt.Prebid.Fledge.AuctionConfigs
+					if fledge == nil {
+						fledge = &openrtb_ext.Fledge{
+							AuctionConfigs: make([]*openrtb_ext.FledgeAuctionConfig, 0, len(auctionConfigs)),
+						}
+					}
+					for _, config := range auctionConfigs {
+						newConfig := &openrtb_ext.FledgeAuctionConfig{
+							ImpId:   impId,
+							Bidder:  string(bidderName),
+							Adapter: string(bidderName),
+							Config:  config.Config,
+						}
+						fledge.AuctionConfigs = append(fledge.AuctionConfigs, newConfig)
+					}
+				}
+			}
+
 			if _, ok := adapterBids[bidderName]; ok {
 				adapterBids[bidderName].Bids = append(adapterBids[bidderName].Bids, bidsToAdd...)
 
 			} else {
 				//create new seat bid and add it to live adapters
 				liveAdapters = append(liveAdapters, bidderName)
-				newSeatBid := entities.PbsOrtbSeatBid{bidsToAdd, "", nil, ""}
+				newSeatBid := entities.PbsOrtbSeatBid{
+					Bids:     bidsToAdd,
+					Currency: "",
+					Seat:     "",
+				}
 				adapterBids[bidderName] = &newSeatBid
 
 			}
 		}
 	}
 
-	return adapterBids, liveAdapters, nil
+	return adapterBids, fledge, liveAdapters, nil
 }
 
 func isAdsCertEnabled(experiment *openrtb_ext.Experiment, info config.BidderInfo) bool {
diff --git a/exchange/utils.go b/exchange/utils.go
index 962afe40..3453769d 100644
--- a/exchange/utils.go
+++ b/exchange/utils.go
@@ -450,17 +450,29 @@ func splitImps(imps []openrtb2.Imp) (map[string][]openrtb2.Imp, error) {
 	return bidderImps, nil
 }
 
+var allowedImpExtFields = map[string]interface{}{
+	openrtb_ext.AuctionEnvironmentKey:       struct{}{},
+	openrtb_ext.FirstPartyDataExtKey:        struct{}{},
+	openrtb_ext.FirstPartyDataContextExtKey: struct{}{},
+	openrtb_ext.GPIDKey:                     struct{}{},
+	openrtb_ext.SKAdNExtKey:                 struct{}{},
+	openrtb_ext.TIDKey:                      struct{}{},
+}
+
+var allowedImpExtPrebidFields = map[string]interface{}{
+	openrtb_ext.IsRewardedInventoryKey: struct{}{},
+	openrtb_ext.OptionsKey:             struct{}{},
+}
+
 func createSanitizedImpExt(impExt, impExtPrebid map[string]json.RawMessage) (map[string]json.RawMessage, error) {
 	sanitizedImpExt := make(map[string]json.RawMessage, 6)
 	sanitizedImpPrebidExt := make(map[string]json.RawMessage, 2)
 
 	// copy allowed imp[].ext.prebid fields
-	if v, exists := impExtPrebid["is_rewarded_inventory"]; exists {
-		sanitizedImpPrebidExt["is_rewarded_inventory"] = v
-	}
-
-	if v, exists := impExtPrebid["options"]; exists {
-		sanitizedImpPrebidExt["options"] = v
+	for k := range allowedImpExtPrebidFields {
+		if v, exists := impExtPrebid[k]; exists {
+			sanitizedImpPrebidExt[k] = v
+		}
 	}
 
 	// marshal sanitized imp[].ext.prebid
@@ -473,38 +485,15 @@ func createSanitizedImpExt(impExt, impExtPrebid map[string]json.RawMessage) (map
 	}
 
 	// copy reserved imp[].ext fields known to not be bidder names
-	if v, exists := impExt[openrtb_ext.FirstPartyDataExtKey]; exists {
-		sanitizedImpExt[openrtb_ext.FirstPartyDataExtKey] = v
-	}
-
-	if v, exists := impExt[openrtb_ext.FirstPartyDataContextExtKey]; exists {
-		sanitizedImpExt[openrtb_ext.FirstPartyDataContextExtKey] = v
-	}
-
-	if v, exists := impExt[openrtb_ext.SKAdNExtKey]; exists {
-		sanitizedImpExt[openrtb_ext.SKAdNExtKey] = v
-	}
-
-	if v, exists := impExt[string(openrtb_ext.GPIDKey)]; exists {
-		sanitizedImpExt[openrtb_ext.GPIDKey] = v
-	}
-
-	if v, exists := impExt[string(openrtb_ext.TIDKey)]; exists {
-		sanitizedImpExt[openrtb_ext.TIDKey] = v
+	for k := range allowedImpExtFields {
+		if v, exists := impExt[k]; exists {
+			sanitizedImpExt[k] = v
+		}
 	}
 
 	return sanitizedImpExt, nil
 }
 
-func isSpecialField(bidder string) bool {
-	return bidder == openrtb_ext.FirstPartyDataContextExtKey ||
-		bidder == openrtb_ext.FirstPartyDataExtKey ||
-		bidder == openrtb_ext.SKAdNExtKey ||
-		bidder == openrtb_ext.GPIDKey ||
-		bidder == openrtb_ext.PrebidExtKey ||
-		bidder == openrtb_ext.TIDKey
-}
-
 // prepareUser changes req.User so that it's ready for the given bidder.
 // This *will* mutate the request, but will *not* mutate any objects nested inside it.
 //
diff --git a/hooks/hookstage/bidderrequest_mutations.go b/hooks/hookstage/bidderrequest_mutations.go
new file mode 100644
index 00000000..b240b0c0
--- /dev/null
+++ b/hooks/hookstage/bidderrequest_mutations.go
@@ -0,0 +1,98 @@
+package hookstage
+
+import (
+	"errors"
+
+	"github.com/prebid/openrtb/v17/adcom1"
+	"github.com/prebid/openrtb/v17/openrtb2"
+)
+
+func (c *ChangeSet[T]) BidderRequest() ChangeSetBidderRequest[T] {
+	return ChangeSetBidderRequest[T]{changeSet: c}
+}
+
+type ChangeSetBidderRequest[T any] struct {
+	changeSet *ChangeSet[T]
+}
+
+func (c ChangeSetBidderRequest[T]) BAdv() ChangeSetBAdv[T] {
+	return ChangeSetBAdv[T]{changeSetBidderRequest: c}
+}
+
+func (c ChangeSetBidderRequest[T]) BCat() ChangeSetBCat[T] {
+	return ChangeSetBCat[T]{changeSetBidderRequest: c}
+}
+
+func (c ChangeSetBidderRequest[T]) CatTax() ChangeSetCatTax[T] {
+	return ChangeSetCatTax[T]{changeSetBidderRequest: c}
+}
+
+func (c ChangeSetBidderRequest[T]) BApp() ChangeSetBApp[T] {
+	return ChangeSetBApp[T]{changeSetBidderRequest: c}
+}
+
+func (c ChangeSetBidderRequest[T]) castPayload(p T) (*openrtb2.BidRequest, error) {
+	if payload, ok := any(p).(BidderRequestPayload); ok {
+		if payload.BidRequest == nil {
+			return nil, errors.New("empty BidRequest provided")
+		}
+		return payload.BidRequest, nil
+	}
+	return nil, errors.New("failed to cast BidderRequestPayload")
+}
+
+type ChangeSetBAdv[T any] struct {
+	changeSetBidderRequest ChangeSetBidderRequest[T]
+}
+
+func (c ChangeSetBAdv[T]) Update(badv []string) {
+	c.changeSetBidderRequest.changeSet.AddMutation(func(p T) (T, error) {
+		bidRequest, err := c.changeSetBidderRequest.castPayload(p)
+		if err == nil {
+			bidRequest.BAdv = badv
+		}
+		return p, err
+	}, MutationUpdate, "bidrequest", "badv")
+}
+
+type ChangeSetBCat[T any] struct {
+	changeSetBidderRequest ChangeSetBidderRequest[T]
+}
+
+func (c ChangeSetBCat[T]) Update(bcat []string) {
+	c.changeSetBidderRequest.changeSet.AddMutation(func(p T) (T, error) {
+		bidRequest, err := c.changeSetBidderRequest.castPayload(p)
+		if err == nil {
+			bidRequest.BCat = bcat
+		}
+		return p, err
+	}, MutationUpdate, "bidrequest", "bcat")
+}
+
+type ChangeSetCatTax[T any] struct {
+	changeSetBidderRequest ChangeSetBidderRequest[T]
+}
+
+func (c ChangeSetCatTax[T]) Update(cattax adcom1.CategoryTaxonomy) {
+	c.changeSetBidderRequest.changeSet.AddMutation(func(p T) (T, error) {
+		bidRequest, err := c.changeSetBidderRequest.castPayload(p)
+		if err == nil {
+			bidRequest.CatTax = cattax
+		}
+		return p, err
+	}, MutationUpdate, "bidrequest", "cattax")
+}
+
+type ChangeSetBApp[T any] struct {
+	changeSetBidderRequest ChangeSetBidderRequest[T]
+}
+
+func (c ChangeSetBApp[T]) Update(bapp []string) {
+	c.changeSetBidderRequest.changeSet.AddMutation(func(p T) (T, error) {
+		bidRequest, err := c.changeSetBidderRequest.castPayload(p)
+		if err == nil {
+			bidRequest.BApp = bapp
+		}
+		return p, err
+	}, MutationUpdate, "bidrequest", "bapp")
+}
diff --git a/hooks/hookstage/mutation.go b/hooks/hookstage/mutation.go
index 53d63336..5378c4e5 100644
--- a/hooks/hookstage/mutation.go
+++ b/hooks/hookstage/mutation.go
@@ -20,12 +20,12 @@ func (mt MutationType) String() string {
 	return "unknown"
 }
 
-type mutationFunc[T any] func(T) (T, error)
+type MutationFunc[T any] func(T) (T, error)
 
 type Mutation[T any] struct {
 	mutType MutationType
 	key     []string        // key indicates path to the modified field
-	fn      mutationFunc[T] // fn actual function that makes changes to payload
+	fn      MutationFunc[T] // fn actual function that makes changes to payload
 }
 
 func (m Mutation[T]) Type() MutationType {
@@ -48,7 +48,7 @@ func (c *ChangeSet[T]) Mutations() []Mutation[T] {
 	return c.muts
 }
 
-func (c *ChangeSet[T]) AddMutation(fn mutationFunc[T], t MutationType, k ...string) *ChangeSet[T] {
+func (c *ChangeSet[T]) AddMutation(fn MutationFunc[T], t MutationType, k ...string) *ChangeSet[T] {
 	c.muts = append(c.muts, Mutation[T]{fn: fn, mutType: t, key: k})
 	return c
 }
diff --git a/modules/prebid/ortb2blocking/hook_bidderrequest.go b/modules/prebid/ortb2blocking/hook_bidderrequest.go
new file mode 100644
index 00000000..1f99cc8a
--- /dev/null
+++ b/modules/prebid/ortb2blocking/hook_bidderrequest.go
@@ -0,0 +1,420 @@
+package ortb2blocking
+
+import (
+	"errors"
+	"fmt"
+	"strings"
+
+	"github.com/prebid/openrtb/v17/adcom1"
+	"github.com/prebid/openrtb/v17/openrtb2"
+	"github.com/prebid/prebid-server/hooks/hookexecution"
+	"github.com/prebid/prebid-server/hooks/hookstage"
+	"github.com/prebid/prebid-server/openrtb_ext"
+)
+
+func handleBidderRequestHook(
+	cfg config,
+	payload hookstage.BidderRequestPayload,
+) (result hookstage.HookResult[hookstage.BidderRequestPayload], err error) {
+	if payload.BidRequest == nil {
+		return result, hookexecution.NewFailure("empty BidRequest provided")
+	}
+
+	mediaTypes := mediaTypesFrom(payload.BidRequest)
+	changeSet := hookstage.ChangeSet[hookstage.BidderRequestPayload]{}
+	blockingAttributes := blockingAttributes{}
+
+	if err = updateBAdv(cfg, payload, mediaTypes, &blockingAttributes, &result, &changeSet); err != nil {
+		return result, hookexecution.NewFailure("failed to update badv field: %s", err)
+	}
+
+	if err = updateBApp(cfg, payload, mediaTypes, &blockingAttributes, &result, &changeSet); err != nil {
+		return result, hookexecution.NewFailure("failed to update bapp field: %s", err)
+	}
+
+	if err = updateBCat(cfg, payload, mediaTypes, &blockingAttributes, &result, &changeSet); err != nil {
+		return result, hookexecution.NewFailure("failed to update bcat field: %s", err)
+	}
+
+	if err = updateBType(cfg, payload, &blockingAttributes, &result, &changeSet); err != nil {
+		return result, hookexecution.NewFailure("failed to update btype field: %s", err)
+	}
+
+	if err = updateBAttr(cfg, payload, &blockingAttributes, &result, &changeSet); err != nil {
+		return result, hookexecution.NewFailure("failed to update battr field: %s", err)
+	}
+
+	updateCatTax(cfg, payload, &blockingAttributes, &changeSet)
+
+	result.ChangeSet = changeSet
+	result.ModuleContext = hookstage.ModuleContext{payload.Bidder: blockingAttributes}
+
+	return result, nil
+}
+
+func updateBAdv(
+	cfg config,
+	payload hookstage.BidderRequestPayload,
+	mediaTypes mediaTypes,
+	attributes *blockingAttributes,
+	result *hookstage.HookResult[hookstage.BidderRequestPayload],
+	changeSet *hookstage.ChangeSet[hookstage.BidderRequestPayload],
+) (err error) {
+	if len(payload.BidRequest.BAdv) > 0 {
+		return nil
+	}
+
+	var message string
+	badv := cfg.Attributes.Badv.BlockedAdomain
+	actionOverrides := cfg.Attributes.Badv.ActionOverrides.BlockedAdomain
+
+	attributes.bAdv, message, err = firstOrDefaultOverride(payload.Bidder, mediaTypes, getNames, actionOverrides, badv)
+	result.Warnings = mergeStrings(result.Warnings, message)
+	if err != nil {
+		return fmt.Errorf("failed to get override for badv.blocked_adomain: %s", err)
+	} else if len(attributes.bAdv) > 0 {
+		changeSet.BidderRequest().BAdv().Update(attributes.bAdv)
+	}
+
+	return nil
+}
+
+func updateBApp(
+	cfg config,
+	payload hookstage.BidderRequestPayload,
+	mediaTypes mediaTypes,
+	attributes *blockingAttributes,
+	result *hookstage.HookResult[hookstage.BidderRequestPayload],
+	changeSet *hookstage.ChangeSet[hookstage.BidderRequestPayload],
+) (err error) {
+	if len(payload.BidRequest.BApp) > 0 {
+		return nil
+	}
+
+	var message string
+	bapp := cfg.Attributes.Bapp.BlockedApp
+	actionOverrides := cfg.Attributes.Bapp.ActionOverrides.BlockedApp
+
+	attributes.bApp, message, err = firstOrDefaultOverride(payload.Bidder, mediaTypes, getNames, actionOverrides, bapp)
+	result.Warnings = mergeStrings(result.Warnings, message)
+	if err != nil {
+		return fmt.Errorf("failed to get override for bapp.blocked_app: %s", err)
+	} else if len(attributes.bApp) > 0 {
+		changeSet.BidderRequest().BApp().Update(attributes.bApp)
+	}
+
+	return nil
+}
+
+func updateBCat(
+	cfg config,
+	payload hookstage.BidderRequestPayload,
+	mediaTypes mediaTypes,
+	attributes *blockingAttributes,
+	result *hookstage.HookResult[hookstage.BidderRequestPayload],
+	changeSet *hookstage.ChangeSet[hookstage.BidderRequestPayload],
+) (err error) {
+	if len(payload.BidRequest.BCat) > 0 {
+		return nil
+	}
+
+	var message string
+	bcat := cfg.Attributes.Bcat.BlockedAdvCat
+	actionOverrides := cfg.Attributes.Bcat.ActionOverrides.BlockedAdvCat
+
+	attributes.bCat, message, err = firstOrDefaultOverride(payload.Bidder, mediaTypes, getNames, actionOverrides, bcat)
+	result.Warnings = mergeStrings(result.Warnings, message)
+	if err != nil {
+		return fmt.Errorf("failed to get override for bcat.blocked_adv_cat: %s", err)
+	} else if len(attributes.bCat) > 0 {
+		changeSet.BidderRequest().BCat().Update(attributes.bCat)
+	}
+
+	return nil
+}
+
+func updateBType(
+	cfg config,
+	payload hookstage.BidderRequestPayload,
+	attributes *blockingAttributes,
+	result *hookstage.HookResult[hookstage.BidderRequestPayload],
+	changeSet *hookstage.ChangeSet[hookstage.BidderRequestPayload],
+) (err error) {
+	var messages []string
+	btype := cfg.Attributes.Btype.BlockedBannerType
+	actionOverrides := cfg.Attributes.Btype.ActionOverrides.BlockedBannerType
+	checkAttrExistence := func(imp openrtb2.Imp) bool {
+		return imp.Banner != nil && len(imp.Banner.BType) > 0
+	}
+
+	attributes.bType, messages, err = findImpressionOverrides(payload, actionOverrides, btype, checkAttrExistence)
+	result.Warnings = mergeStrings(result.Warnings, messages...)
+	if err != nil {
+		return fmt.Errorf("failed to get override for imp.*.banner.btype: %s", err)
+	} else if len(attributes.bType) > 0 {
+		mutation := bTypeMutation(attributes.bType)
+		changeSet.AddMutation(mutation, hookstage.MutationUpdate, "bidrequest", "imp", "banner", "btype")
+	}
+
+	return nil
+}
+
+func updateBAttr(
+	cfg config,
+	payload hookstage.BidderRequestPayload,
+	attributes *blockingAttributes,
+	result *hookstage.HookResult[hookstage.BidderRequestPayload],
+	changeSet *hookstage.ChangeSet[hookstage.BidderRequestPayload],
+) (err error) {
+	var messages []string
+	battr := cfg.Attributes.Battr.BlockedBannerAttr
+	actionOverrides := cfg.Attributes.Battr.ActionOverrides.BlockedBannerAttr
+	checkAttrExistence := func(imp openrtb2.Imp) bool {
+		return imp.Banner != nil && len(imp.Banner.BAttr) > 0
+	}
+
+	attributes.bAttr, messages, err = findImpressionOverrides(payload, actionOverrides, battr, checkAttrExistence)
+	result.Warnings = mergeStrings(result.Warnings, messages...)
+	if err != nil {
+		return fmt.Errorf("failed to get override for imp.*.banner.battr: %s", err)
+	} else if len(attributes.bAttr) > 0 {
+		mutation := bAttrMutation(attributes.bAttr)
+		changeSet.AddMutation(mutation, hookstage.MutationUpdate, "bidrequest", "imp", "banner", "battr")
+	}
+
+	return nil
+}
+
+func updateCatTax(
+	cfg config,
+	payload hookstage.BidderRequestPayload,
+	attributes *blockingAttributes,
+	changeSet *hookstage.ChangeSet[hookstage.BidderRequestPayload],
+) {
+	if payload.BidRequest.CatTax > 0 {
+		return
+	}
+
+	attributes.catTax = cfg.Attributes.Bcat.CategoryTaxonomy
+	changeSet.BidderRequest().CatTax().Update(attributes.catTax)
+}
+
+func bTypeMutation(bTypeByImp map[string][]int) hookstage.MutationFunc[hookstage.BidderRequestPayload] {
+	return mutationForImp(bTypeByImp, func(imp openrtb2.Imp, btype []int) openrtb2.Imp {
+		imp.Banner.BType = make([]openrtb2.BannerAdType, len(btype))
+		for i := range btype {
+			imp.Banner.BType[i] = openrtb2.BannerAdType(btype[i])
+		}
+		return imp
+	})
+}
+
+func bAttrMutation(bAttrByImp map[string][]int) hookstage.MutationFunc[hookstage.BidderRequestPayload] {
+	return mutationForImp(bAttrByImp, func(imp openrtb2.Imp, battr []int) openrtb2.Imp {
+		imp.Banner.BAttr = make([]adcom1.CreativeAttribute, len(battr))
+		for i := range battr {
+			imp.Banner.BAttr[i] = adcom1.CreativeAttribute(battr[i])
+		}
+		return imp
+	})
+}
+
+type impUpdateFunc func(imp openrtb2.Imp, values []int) openrtb2.Imp
+
+func mutationForImp(
+	valuesByImp map[string][]int,
+	impUpdater impUpdateFunc,
+) hookstage.MutationFunc[hookstage.BidderRequestPayload] {
+	return func(payload hookstage.BidderRequestPayload) (hookstage.BidderRequestPayload, error) {
+		for i, imp := range payload.BidRequest.Imp {
+			if values, ok := valuesByImp[imp.ID]; ok {
+				if len(values) == 0 {
+					continue
+				}
+
+				if imp.Banner == nil {
+					imp.Banner = &openrtb2.Banner{}
+				}
+
+				payload.BidRequest.Imp[i] = impUpdater(imp, values)
+			}
+		}
+		return payload, nil
+	}
+}
+
+// firstOrDefaultOverride searches for matching override based on conditions.
+// Returns only first found override. Override for specific bidder has higher priority
+// than override matching all bidders. If no override found, the defaultOverride returned.
+func firstOrDefaultOverride[T any](
+	bidder string,
+	requestMediaTypes mediaTypes,
+	overrideGetter overrideGetterFn[T],
+	actionOverrides []ActionOverride,
+	defaultOverride T,
+) (override T, message string, err error) {
+	var allOverrides []T
+	var specificOverrides []T
+
+	for _, action := range actionOverrides {
+		if err = validateCondition(action.Conditions); err != nil {
+			return override, message, err
+		}
+
+		matchAllBidders := action.Conditions.Bidders == nil
+		matchesBidder := matchAllBidders || hasMatches(action.Conditions.Bidders, bidder)
+		matchesMedia := action.Conditions.MediaTypes == nil || requestMediaTypes.intersects(action.Conditions.MediaTypes)
+
+		if matchesBidder && matchesMedia {
+			actionOverride, err := overrideGetter(action.Override)
+			if err != nil {
+				return override, message, err
+			}
+
+			if matchAllBidders {
+				allOverrides = append(allOverrides, actionOverride)
+			} else {
+				specificOverrides = append(specificOverrides, actionOverride)
+			}
+		}
+	}
+
+	if len(specificOverrides)+len(allOverrides) > 1 {
+		message = fmt.Sprintf(
+			"More than one condition matches request. Bidder: %s, request media types: %s",
+			bidder,
+			requestMediaTypes,
+		)
+	}
+
+	if len(specificOverrides) > 0 {
+		override = specificOverrides[0]
+	} else if len(allOverrides) > 0 {
+		override = allOverrides[0]
+	} else {
+		override = defaultOverride
+	}
+
+	return override, message, nil
+}
+
+// findImpressionOverrides returns overrides for each of the BidRequest impressions.
+// Overrides returned in format map[ImpressionID][]int.
+func findImpressionOverrides(
+	payload hookstage.BidderRequestPayload,
+	actionOverrides []ActionOverride,
+	defaultOverride []int,
+	isAttrPresent func(imp openrtb2.Imp) bool,
+) (map[string][]int, []string, error) {
+	bidder := payload.Bidder
+	overrides := map[string][]int{}
+	messages := []string{}
+
+	for _, imp := range payload.BidRequest.Imp {
+		// do not add override for attribute if it already exists in request
+		if isAttrPresent(imp) {
+			continue
+		}
+
+		mediaTypes := mediaTypesFromImp(imp)
+		override, message, err := firstOrDefaultOverride(bidder, mediaTypes, getIds, actionOverrides, defaultOverride)
+		messages = mergeStrings(messages, message)
+		if err != nil {
+			return nil, messages, err
+		} else if len(override) > 0 {
+			overrides[imp.ID] = override
+		}
+	}
+
+	return overrides, messages, nil
+}
+
+type overrideGetterFn[T any] func(override Override) (T, error)
+
+func getNames(override Override) ([]string, error) {
+	if len(override.Names) == 0 {
+		return nil, errors.New("empty override field")
+	}
+	return override.Names, nil
+}
+
+func getIds(override Override) ([]int, error) {
+	if len(override.Ids) == 0 {
+		return nil, errors.New("empty override field")
+	}
+	return override.Ids, nil
+}
+
+type mediaTypes map[string]struct{}
+
+func (m mediaTypes) String() string {
+	var builder strings.Builder
+	// keep media_types in sorted order
+	for i, mediaType := range [4]string{
+		string(openrtb_ext.BidTypeAudio),
+		string(openrtb_ext.BidTypeBanner),
+		string(openrtb_ext.BidTypeNative),
+		string(openrtb_ext.BidTypeVideo),
+	} {
+		if _, ok := m[mediaType]; ok {
+			if i > 0 {
+				builder.WriteString(", ")
+			}
+			builder.WriteString(mediaType)
+		}
+	}
+
+	return builder.String()
+}
+
+func (m mediaTypes) intersects(mediaTypes []string) bool {
+	for _, mediaType := range mediaTypes {
+		if _, ok := m[strings.ToLower(mediaType)]; ok {
+			return true
+		}
+	}
+	return false
+}
+
+func mediaTypesFrom(request *openrtb2.BidRequest) mediaTypes {
+	mediaTypes := mediaTypes{}
+	for _, imp := range request.Imp {
+		if len(mediaTypes) == 4 {
+			break
+		}
+
+		for mt, val := range mediaTypesFromImp(imp) {
+			mediaTypes[mt] = val
+		}
+	}
+
+	return mediaTypes
+}
+
+func mediaTypesFromImp(imp openrtb2.Imp) mediaTypes {
+	mediaTypes := mediaTypes{}
+	if imp.Audio != nil {
+		mediaTypes[string(openrtb_ext.BidTypeAudio)] = struct{}{}
+	}
+
+	if imp.Banner != nil {
+		mediaTypes[string(openrtb_ext.BidTypeBanner)] = struct{}{}
+	}
+
+	if imp.Native != nil {
+		mediaTypes[string(openrtb_ext.BidTypeNative)] = struct{}{}
+	}
+
+	if imp.Video != nil {
+		mediaTypes[string(openrtb_ext.BidTypeVideo)] = struct{}{}
+	}
+
+	return mediaTypes
+}
+
+func validateCondition(conditions Conditions) error {
+	if conditions.Bidders == nil && conditions.MediaTypes == nil {
+		return errors.New("bidders and media_types absent from conditions, at least one of the fields must be present")
+	}
+	return nil
+}
diff --git a/modules/prebid/ortb2blocking/module.go b/modules/prebid/ortb2blocking/module.go
index f0ef80cc..8f8c2968 100644
--- a/modules/prebid/ortb2blocking/module.go
+++ b/modules/prebid/ortb2blocking/module.go
@@ -1,8 +1,11 @@
 package ortb2blocking
 
 import (
+	"context"
 	"encoding/json"
 
+	"github.com/prebid/openrtb/v17/adcom1"
+	"github.com/prebid/prebid-server/hooks/hookstage"
 	"github.com/prebid/prebid-server/modules/moduledeps"
 )
 
@@ -11,3 +14,32 @@ func Builder(_ json.RawMessage, _ moduledeps.ModuleDeps) (interface{}, error) {
 }
 
 type Module struct{}
+
+// HandleBidderRequestHook updates blocking fields on the openrtb2.BidRequest.
+// Fields are updated only if request satisfies conditions provided by the module config.
+func (m Module) HandleBidderRequestHook(
+	_ context.Context,
+	miCtx hookstage.ModuleInvocationContext,
+	payload hookstage.BidderRequestPayload,
+) (hookstage.HookResult[hookstage.BidderRequestPayload], error) {
+	result := hookstage.HookResult[hookstage.BidderRequestPayload]{}
+	if len(miCtx.AccountConfig) == 0 {
+		return result, nil
+	}
+
+	cfg, err := newConfig(miCtx.AccountConfig)
+	if err != nil {
+		return result, err
+	}
+
+	return handleBidderRequestHook(cfg, payload)
+}
+
+type blockingAttributes struct {
+	bAdv   []string
+	bApp   []string
+	bCat   []string
+	bType  map[string][]int
+	bAttr  map[string][]int
+	catTax adcom1.CategoryTaxonomy
+}
diff --git a/modules/prebid/ortb2blocking/utils.go b/modules/prebid/ortb2blocking/utils.go
new file mode 100644
index 00000000..affd5a47
--- /dev/null
+++ b/modules/prebid/ortb2blocking/utils.go
@@ -0,0 +1,22 @@
+package ortb2blocking
+
+import "strings"
+
+func mergeStrings(messages []string, newMessages ...string) []string {
+	for _, msg := range newMessages {
+		if msg == "" {
+			continue
+		}
+		messages = append(messages, msg)
+	}
+	return messages
+}
+
+func hasMatches(list []string, s string) bool {
+	for _, val := range list {
+		if strings.EqualFold(val, s) {
+			return true
+		}
+	}
+	return false
+}
diff --git a/openrtb_ext/bidders.go b/openrtb_ext/bidders.go
index fe128f2e..76236224 100644
--- a/openrtb_ext/bidders.go
+++ b/openrtb_ext/bidders.go
@@ -36,6 +36,7 @@ const (
 	BidderReservedPrebid  BidderName = "prebid"  // Reserved for Prebid Server configuration.
 	BidderReservedSKAdN   BidderName = "skadn"   // Reserved for Apple's SKAdNetwork OpenRTB extension.
 	BidderReservedTID     BidderName = "tid"     // Reserved for Per-Impression Transactions IDs for Multi-Impression Bid Requests.
+	BidderReservedAE      BidderName = "ae"      // Reserved for FLEDGE Auction Environment
 )
 
 // IsBidderNameReserved returns true if the specified name is a case insensitive match for a reserved bidder name.
@@ -72,6 +73,10 @@ func IsBidderNameReserved(name string) bool {
 		return true
 	}
 
+	if strings.EqualFold(name, string(BidderReservedAE)) {
+		return true
+	}
+
 	return false
 }
 
diff --git a/openrtb_ext/imp.go b/openrtb_ext/imp.go
index 9e62a97f..e0fdd2d7 100644
--- a/openrtb_ext/imp.go
+++ b/openrtb_ext/imp.go
@@ -4,6 +4,24 @@ import (
 	"encoding/json"
 )
 
+// AuctionEnvironmentType is a Google Privacy Sandbox flag indicating where the auction may take place
+type AuctionEnvironmentType int8
+
+const (
+	// 0 Standard server-side auction
+	ServerSideAuction AuctionEnvironmentType = 0
+	// 1 On-device interest group auction (FLEDGE)
+	OnDeviceIGAuctionFledge AuctionEnvironmentType = 1
+	// 2 Server-side with interest group simulation
+	ServerSideWithIGSimulation AuctionEnvironmentType = 2
+)
+
+// IsRewardedInventoryKey is the json key for ExtImpPrebid.IsRewardedInventory
+const IsRewardedInventoryKey = "is_rewarded_inventory"
+
+// OptionsKey is the json key for ExtImpPrebid.Options
+const OptionsKey = "options"
+
 // ExtImpPrebid defines the contract for bidrequest.imp[i].ext.prebid
 type ExtImpPrebid struct {
 	// StoredRequest specifies which stored impression to use, if any.
diff --git a/openrtb_ext/request.go b/openrtb_ext/request.go
index 22f6b127..6559e632 100644
--- a/openrtb_ext/request.go
+++ b/openrtb_ext/request.go
@@ -22,6 +22,9 @@ const GPIDKey = "gpid"
 // TIDKey reserved for Per-Impression Transactions IDs for Multi-Impression Bid Requests.
 const TIDKey = "tid"
 
+// AuctionEnvironmentKey is the json key under imp[].ext for ExtImp.AuctionEnvironment
+const AuctionEnvironmentKey = string(BidderReservedAE)
+
 // NativeExchangeSpecificLowerBound defines the lower threshold of exchange specific types for native ads. There is no upper bound.
 const NativeExchangeSpecificLowerBound = 500
 
diff --git a/openrtb_ext/response.go b/openrtb_ext/response.go
index 68097504..e920ee58 100644
--- a/openrtb_ext/response.go
+++ b/openrtb_ext/response.go
@@ -42,6 +42,20 @@ type ExtResponsePrebid struct {
 	AuctionTimestamp int64           `json:"auctiontimestamp,omitempty"`
 	Passthrough      json.RawMessage `json:"passthrough,omitempty"`
 	Modules          json.RawMessage `json:"modules,omitempty"`
+	Fledge           *Fledge         `json:"fledge,omitempty"`
+}
+
+// FledgeResponse defines the contract for bidresponse.ext.fledge
+type Fledge struct {
+	AuctionConfigs []*FledgeAuctionConfig `json:"auctionconfigs,omitempty"`
+}
+
+// FledgeAuctionConfig defines the container for bidresponse.ext.fledge.auctionconfigs[]
+type FledgeAuctionConfig struct {
+	ImpId   string          `json:"impid"`
+	Bidder  string          `json:"bidder,omitempty"`
+	Adapter string          `json:"adapter,omitempty"`
+	Config  json.RawMessage `json:"config"`
 }
 
 // ExtUserSync defines the contract for bidresponse.ext.usersync.{bidder}.syncs[i]


Test patch:
diff --git a/adapters/adapterstest/test_json.go b/adapters/adapterstest/test_json.go
index 6eab9a48..7381fc68 100644
--- a/adapters/adapterstest/test_json.go
+++ b/adapters/adapterstest/test_json.go
@@ -198,8 +198,9 @@ func (resp *httpResponse) ToResponseData(t *testing.T) *adapters.ResponseData {
 }
 
 type expectedBidResponse struct {
-	Bids     []expectedBid `json:"bids"`
-	Currency string        `json:"currency"`
+	Bids                 []expectedBid   `json:"bids"`
+	Currency             string          `json:"currency"`
+	FledgeAuctionConfigs json.RawMessage `json:"fledgeauctionconfigs,omitempty"`
 }
 
 type expectedBid struct {
@@ -255,11 +256,11 @@ func assertErrorList(t *testing.T, description string, actual []error, expected
 	}
 }
 
-func assertMakeBidsOutput(t *testing.T, filename string, bidderResponse *adapters.BidderResponse, expected []expectedBid) {
+func assertMakeBidsOutput(t *testing.T, filename string, bidderResponse *adapters.BidderResponse, expected expectedBidResponse) {
 	t.Helper()
 
-	if (bidderResponse == nil || len(bidderResponse.Bids) == 0) != (len(expected) == 0) {
-		if len(expected) == 0 {
+	if (bidderResponse == nil || len(bidderResponse.Bids) == 0) != (len(expected.Bids) == 0) {
+		if len(expected.Bids) == 0 {
 			t.Fatalf("%s: expectedBidResponses indicated a nil response, but mockResponses supplied a non-nil response", filename)
 		}
 
@@ -271,11 +272,19 @@ func assertMakeBidsOutput(t *testing.T, filename string, bidderResponse *adapter
 		bidderResponse = new(adapters.BidderResponse)
 	}
 
-	if len(bidderResponse.Bids) != len(expected) {
-		t.Fatalf("%s: MakeBids returned wrong bid count. Expected %d, got %d", filename, len(expected), len(bidderResponse.Bids))
+	if len(bidderResponse.Bids) != len(expected.Bids) {
+		t.Fatalf("%s: MakeBids returned wrong bid count. Expected %d, got %d", filename, len(expected.Bids), len(bidderResponse.Bids))
 	}
 	for i := 0; i < len(bidderResponse.Bids); i++ {
-		diffBids(t, fmt.Sprintf("%s:  typedBid[%d]", filename, i), bidderResponse.Bids[i], &(expected[i]))
+		diffBids(t, fmt.Sprintf("%s:  typedBid[%d]", filename, i), bidderResponse.Bids[i], &(expected.Bids[i]))
+	}
+	if expected.FledgeAuctionConfigs != nil {
+		assert.NotNilf(t, bidderResponse.FledgeAuctionConfigs, "%s: expected fledgeauctionconfigs in bidderResponse", filename)
+		fledgeAuctionConfigsJson, err := json.Marshal(bidderResponse.FledgeAuctionConfigs)
+		assert.NoErrorf(t, err, "%s: failed to marshal actual FledgeAuctionConfig response into JSON.", filename)
+		assert.JSONEqf(t, string(expected.FledgeAuctionConfigs), string(fledgeAuctionConfigsJson), "%s: incorrect fledgeauctionconfig", filename)
+	} else {
+		assert.Nilf(t, bidderResponse.FledgeAuctionConfigs, "%s: unexpected fledgeauctionconfigs in bidderResponse", filename)
 	}
 }
 
@@ -435,6 +444,6 @@ func testMakeBidsImpl(t *testing.T, filename string, spec *testSpec, bidder adap
 
 	// Assert MakeBids implementation BidResponses with expected JSON-defined spec.BidResponses[i].Bids
 	for i := 0; i < len(spec.BidResponses); i++ {
-		assertMakeBidsOutput(t, filename, bidResponses[i], spec.BidResponses[i].Bids)
+		assertMakeBidsOutput(t, filename, bidResponses[i], spec.BidResponses[i])
 	}
 }
diff --git a/exchange/bidder_test.go b/exchange/bidder_test.go
index dbe88169..32057c50 100644
--- a/exchange/bidder_test.go
+++ b/exchange/bidder_test.go
@@ -1668,6 +1668,117 @@ func TestRequestBidsStoredBidResponses(t *testing.T) {
 
 }
 
+// TestFledge verifies that fledge responses from bidders are collected.
+func TestFledge(t *testing.T) {
+	respStatus := 200
+	respBody := "{\"bid\":false}"
+	server := httptest.NewServer(mockHandler(respStatus, "getBody", respBody))
+	defer server.Close()
+
+	fledgeAuctionConfig1 := &openrtb_ext.FledgeAuctionConfig{
+		ImpId:  "imp-id-1",
+		Config: json.RawMessage("[1,2,3]"),
+		Bidder: "openx",
+	}
+	fledgeAuctionConfig2 := &openrtb_ext.FledgeAuctionConfig{
+		ImpId:  "imp-id-2",
+		Config: json.RawMessage("[3,2,1]"),
+		Bidder: "openx",
+	}
+
+	testCases := []struct {
+		mockBidderResponse []*adapters.BidderResponse
+		expectedFledge     []*openrtb_ext.FledgeAuctionConfig
+		description        string
+	}{
+		{
+			mockBidderResponse: []*adapters.BidderResponse{
+				{
+					Bids:                 []*adapters.TypedBid{},
+					FledgeAuctionConfigs: []*openrtb_ext.FledgeAuctionConfig{fledgeAuctionConfig1, fledgeAuctionConfig2},
+				},
+				nil,
+			},
+			expectedFledge: []*openrtb_ext.FledgeAuctionConfig{fledgeAuctionConfig1, fledgeAuctionConfig2},
+			description:    "Collects FLEDGE auction configs from single bidder response",
+		},
+		{
+			mockBidderResponse: []*adapters.BidderResponse{
+				{
+					Bids:                 []*adapters.TypedBid{},
+					FledgeAuctionConfigs: []*openrtb_ext.FledgeAuctionConfig{fledgeAuctionConfig2},
+				},
+				{
+					Bids:                 []*adapters.TypedBid{},
+					FledgeAuctionConfigs: []*openrtb_ext.FledgeAuctionConfig{fledgeAuctionConfig1},
+				},
+			},
+			expectedFledge: []*openrtb_ext.FledgeAuctionConfig{fledgeAuctionConfig1, fledgeAuctionConfig2},
+			description:    "Collects FLEDGE auction configs from multiple bidder response",
+		},
+	}
+
+	for _, tc := range testCases {
+		bidderImpl := &goodMultiHTTPCallsBidder{
+			httpRequest: []*adapters.RequestData{
+				{
+					Method:  "POST",
+					Uri:     server.URL,
+					Body:    []byte("{\"key\":\"val1\"}"),
+					Headers: http.Header{},
+				},
+				{
+					Method:  "POST",
+					Uri:     server.URL,
+					Body:    []byte("{\"key\":\"val2\"}"),
+					Headers: http.Header{},
+				},
+			},
+			bidResponses: tc.mockBidderResponse,
+		}
+		bidder := AdaptBidder(bidderImpl, server.Client(), &config.Configuration{}, &metricsConfig.NilMetricsEngine{}, openrtb_ext.BidderOpenx, nil, "")
+		currencyConverter := currency.NewRateConverter(&http.Client{}, "", time.Duration(0))
+
+		bidderReq := BidderRequest{
+			BidRequest: &openrtb2.BidRequest{
+				Imp: []openrtb2.Imp{
+					{
+						ID:     "imp-id-1",
+						Banner: &openrtb2.Banner{},
+						Ext:    json.RawMessage(`{"ae": 1}`),
+					},
+					{
+						ID:     "imp-id-2",
+						Banner: &openrtb2.Banner{},
+						Ext:    json.RawMessage(`{"ae": 1}`),
+					},
+				},
+			},
+			BidderName: "openx",
+		}
+		seatBids, _ := bidder.requestBid(
+			context.Background(),
+			bidderReq,
+			currencyConverter.Rates(),
+			&adapters.ExtraRequestInfo{},
+			&adscert.NilSigner{},
+			bidRequestOptions{
+				accountDebugAllowed: true,
+				headerDebugAllowed:  true,
+				addCallSignHeader:   false,
+				bidAdjustments:      map[string]float64{"test": 1.0},
+			},
+			openrtb_ext.ExtAlternateBidderCodes{},
+			&hookexecution.EmptyHookExecutor{},
+		)
+		assert.Len(t, seatBids, 1)
+		assert.NotNil(t, seatBids[0].FledgeAuctionConfigs)
+		assert.Len(t, seatBids[0].FledgeAuctionConfigs, len(tc.expectedFledge))
+
+		assert.ElementsMatch(t, seatBids[0].FledgeAuctionConfigs, tc.expectedFledge)
+	}
+}
+
 func TestErrorReporting(t *testing.T) {
 	bidder := AdaptBidder(&bidRejector{}, nil, &config.Configuration{}, &metricsConfig.NilMetricsEngine{}, openrtb_ext.BidderAppnexus, nil, "")
 	currencyConverter := currency.NewRateConverter(&http.Client{}, "", time.Duration(0))
diff --git a/exchange/exchange_test.go b/exchange/exchange_test.go
index e53ba7b4..8bf8427c 100644
--- a/exchange/exchange_test.go
+++ b/exchange/exchange_test.go
@@ -2299,6 +2299,10 @@ func runSpec(t *testing.T, filename string, spec *exchangeSpec) {
 
 	}
 
+	if spec.FledgeEnabled {
+		assert.JSONEq(t, string(spec.Response.Ext), string(bid.Ext), "ext mismatch")
+	}
+
 	if spec.HostConfigBidValidation.BannerCreativeMaxSize == config.ValidationEnforce || spec.HostConfigBidValidation.SecureMarkup == config.ValidationEnforce {
 		actualBidRespExt := &openrtb_ext.ExtBidResponse{}
 		expectedBidRespExt := &openrtb_ext.ExtBidResponse{}
@@ -3926,6 +3930,7 @@ func TestBuildStoredAuctionResponses(t *testing.T) {
 	}
 	type testResults struct {
 		adapterBids  map[openrtb_ext.BidderName]*entities.PbsOrtbSeatBid
+		fledge       *openrtb_ext.Fledge
 		liveAdapters []openrtb_ext.BidderName
 	}
 
@@ -4056,13 +4061,40 @@ func TestBuildStoredAuctionResponses(t *testing.T) {
 				liveAdapters: []openrtb_ext.BidderName{openrtb_ext.BidderName("appnexus"), openrtb_ext.BidderName("rubicon")},
 			},
 		},
+		{
+			desc: "Fledge in stored response bid",
+			in: testIn{
+				StoredAuctionResponses: map[string]json.RawMessage{
+					"impression-id": json.RawMessage(`[{"bid": [],"seat": "openx", "ext": {"prebid": {"fledge": {"auctionconfigs": [{"impid": "1", "bidder": "openx", "adapter": "openx", "config": [1,2,3]}]}}}}]`),
+				},
+			},
+			expected: testResults{
+				adapterBids: map[openrtb_ext.BidderName]*entities.PbsOrtbSeatBid{
+					openrtb_ext.BidderName("openx"): {
+						Bids: []*entities.PbsOrtbBid{},
+					},
+				},
+				liveAdapters: []openrtb_ext.BidderName{openrtb_ext.BidderName("openx")},
+				fledge: &openrtb_ext.Fledge{
+					AuctionConfigs: []*openrtb_ext.FledgeAuctionConfig{
+						{
+							ImpId:   "impression-id",
+							Bidder:  "openx",
+							Adapter: "openx",
+							Config:  json.RawMessage("[1,2,3]"),
+						},
+					},
+				},
+			},
+		},
 	}
 	for _, test := range testCases {
 
-		bids, adapters, err := buildStoredAuctionResponse(test.in.StoredAuctionResponses)
+		bids, fledge, adapters, err := buildStoredAuctionResponse(test.in.StoredAuctionResponses)
 		assert.NoErrorf(t, err, "%s. HoldAuction error: %v \n", test.desc, err)
 
 		assert.ElementsMatch(t, test.expected.liveAdapters, adapters, "Incorrect adapter list")
+		assert.Equal(t, fledge, test.expected.fledge, "Incorrect FLEDGE response")
 
 		for _, bidderName := range test.expected.liveAdapters {
 			assert.ElementsMatch(t, test.expected.adapterBids[bidderName].Bids, bids[bidderName].Bids, "Incorrect bids")
@@ -4676,6 +4708,7 @@ type exchangeSpec struct {
 	HostSChainFlag             bool                   `json:"host_schain_flag,omitempty"`
 	HostConfigBidValidation    config.Validations     `json:"host_bid_validations"`
 	AccountConfigBidValidation config.Validations     `json:"account_bid_validations"`
+	FledgeEnabled              bool                   `json:"fledge_enabled,omitempty"`
 }
 
 type exchangeRequest struct {
@@ -4710,8 +4743,9 @@ type bidderResponse struct {
 // The only real reason I'm not reusing that type is because I don't want people to think that the
 // JSON property tags on those types are contracts in prod.
 type bidderSeatBid struct {
-	Bids []bidderBid `json:"pbsBids,omitempty"`
-	Seat string      `json:"seat"`
+	Bids                 []bidderBid                        `json:"pbsBids,omitempty"`
+	Seat                 string                             `json:"seat"`
+	FledgeAuctionConfigs []*openrtb_ext.FledgeAuctionConfig `json:"fledgeAuctionConfigs,omitempty"`
 }
 
 // bidderBid is basically a subset of entities.PbsOrtbBid from exchange/bidder.go.
@@ -4773,9 +4807,10 @@ func (b *validatingBidder) requestBid(ctx context.Context, bidderRequest BidderR
 				}
 
 				seatBids = append(seatBids, &entities.PbsOrtbSeatBid{
-					Bids:      bids,
-					HttpCalls: mockResponse.HttpCalls,
-					Seat:      mockSeatBid.Seat,
+					Bids:                 bids,
+					HttpCalls:            mockResponse.HttpCalls,
+					Seat:                 mockSeatBid.Seat,
+					FledgeAuctionConfigs: mockSeatBid.FledgeAuctionConfigs,
 				})
 			}
 		} else {
diff --git a/exchange/exchangetest/fledge-no-bids.json b/exchange/exchangetest/fledge-no-bids.json
new file mode 100644
index 00000000..d941fadd
--- /dev/null
+++ b/exchange/exchangetest/fledge-no-bids.json
@@ -0,0 +1,117 @@
+{
+    "description": "FLEDGE request/response when no contextual bids exist",
+    "fledge_enabled": true,
+    "debuglog": {
+        "enabled": false,
+        "debug_override": false,
+        "debug_enabled_or_overridden": false
+    },
+    "incomingRequest": {
+        "ortbRequest": {
+            "id": "some-request-id",
+            "site": {
+                "page": "test.somepage.com"
+            },
+            "imp": [
+                {
+                    "id": "my-imp-id",
+                    "banner": {
+                        "format": [{"w": 728, "h": 90}]
+                    },
+                    "ext": {
+                        "ae": 1,
+                        "prebid": {
+                            "bidder": {
+                                "openx": {
+                                    "unit": "539439964",
+                                    "delDomain": "se-demo-d.openx.net"
+                                }
+                            }
+                        }
+                    }
+                }
+            ]
+        }
+    },
+    "outgoingRequests": {
+        "openx": {
+            "expectRequest": {
+                "ortbRequest": {
+                    "id": "some-request-id",
+                    "site": {
+                        "page": "test.somepage.com"
+                    },
+                    "imp": [{
+                        "id": "my-imp-id",
+                        "banner": {
+                            "format": [{"w": 728, "h": 90}]
+                        },
+                        "ext": {
+                            "ae": 1,
+                            "bidder": {
+                                "unit": "539439964",
+                                "delDomain": "se-demo-d.openx.net"
+                            }
+                        }
+                    }]
+                }
+            },
+            "mockResponse": {
+                "pbsSeatBids": [
+                    {
+                        "pbsBids": [],
+                        "seat": "openx",
+                        "fledgeAuctionConfigs": [
+                            {
+                                "impid": "test-request-id",
+                                "bidder": "openx",
+                                "config": {
+                                    "seller": "openx.com",
+                                    "interestGroupBuyers": ["buyer1.com"],
+                                    "sellerTimeout": 0,
+                                    "perBuyerSignals": {
+                                        "buyer1.com": [1,"two",3,4, {}]
+                                    }
+                                }
+                            }
+                        ]
+                    }
+                ]
+            }
+        }
+    },
+    "response": {
+        "bids": {
+            "seatbid": []
+        },
+        "ext": {
+            "prebid": {
+                "fledge": {
+                    "auctionconfigs": [
+                        {
+                            "impid": "test-request-id",
+                            "adapter":"openx",
+                            "bidder": "openx",
+                            "config": {
+                                "seller": "openx.com",
+                                "interestGroupBuyers": ["buyer1.com"],
+                                "sellerTimeout": 0,
+                                "perBuyerSignals": {
+                                    "buyer1.com": [1,"two",3,4, {}]
+                                }
+                            }
+                        }
+                    ]
+                }
+            },
+            "warnings": {
+                "general": [
+                    {
+                        "code": 10002,
+                        "message": "debug turned off for account"
+                    }
+                ]
+            }
+        }
+    }
+}
diff --git a/exchange/exchangetest/fledge-with-bids.json b/exchange/exchangetest/fledge-with-bids.json
new file mode 100644
index 00000000..0f998e69
--- /dev/null
+++ b/exchange/exchangetest/fledge-with-bids.json
@@ -0,0 +1,155 @@
+{
+    "description": "FLEDGE request/response with additional contextual bids exist",
+    "fledge_enabled": true,
+    "debuglog": {
+        "enabled": false,
+        "debug_override": false,
+        "debug_enabled_or_overridden": false
+    },
+    "incomingRequest": {
+        "ortbRequest": {
+            "id": "some-request-id",
+            "site": {
+                "page": "test.somepage.com"
+            },
+            "imp": [
+                {
+                    "id": "my-imp-id",
+                    "banner": {
+                        "format": [{"w": 728, "h": 90}]
+                    },
+                    "ext": {
+                        "ae": 1,
+                        "prebid": {
+                            "bidder": {
+                                "openx": {
+                                    "unit": "539439964",
+                                    "delDomain": "se-demo-d.openx.net"
+                                }
+                            }
+                        }
+                    }
+                }
+            ]
+        }
+    },
+    "outgoingRequests": {
+        "openx": {
+            "expectRequest": {
+                "ortbRequest": {
+                    "id": "some-request-id",
+                    "site": {
+                        "page": "test.somepage.com"
+                    },
+                    "imp": [{
+                        "id": "my-imp-id",
+                        "banner": {
+                            "format": [{"w": 728, "h": 90}]
+                        },
+                        "ext": {
+                            "ae": 1,
+                            "bidder": {
+                                "unit": "539439964",
+                                "delDomain": "se-demo-d.openx.net"
+                            }
+                        }
+                    }]
+                }
+            },
+            "mockResponse": {
+                "pbsSeatBids": [
+                    {
+                        "pbsBids": [
+                            {
+                                "ortbBid": {
+                                    "id": "apn-bid",
+                                    "impid": "my-imp-id",
+                                    "price": 0.3,
+                                    "w": 200,
+                                    "h": 250,
+                                    "crid": "creative-1",
+                                    "ext": {
+                                        "origbidcpm": 0.3,
+                                        "someField": "someValue"
+                                    }
+                                },
+                                "bidType": "video"
+                            }
+                        ],
+                        "seat": "openx",
+                        "fledgeAuctionConfigs": [
+                            {
+                                "impid": "test-request-id",
+                                "bidder": "openx",
+                                "config": {
+                                    "seller": "openx.com",
+                                    "interestGroupBuyers": ["buyer1.com"],
+                                    "sellerTimeout": 0,
+                                    "perBuyerSignals": {
+                                        "buyer1.com": [1,"two",3,4, {}]
+                                    }
+                                }
+                            }
+                        ]
+                    }
+                ]
+            }
+        }
+    },
+    "response": {
+        "bids": {
+            "id": "some-request-id",
+            "seatbid": [
+                {
+                    "seat": "openx",
+                    "bid": [
+                        {
+                            "id": "apn-bid",
+                            "impid": "my-imp-id",
+                            "price": 0.3,
+                            "w": 200,
+                            "h": 250,
+                            "crid": "creative-1",
+                            "ext": {
+                                "origbidcpm": 0.3,
+                                "someField": "someValue",
+                                "prebid": {
+                                    "type": "video"
+                                }
+                            }
+                        }
+                    ]
+                }
+            ]
+        },
+        "ext": {
+            "prebid": {
+                "fledge": {
+                    "auctionconfigs": [
+                        {
+                            "impid": "test-request-id",
+                            "adapter":"openx",
+                            "bidder": "openx",
+                            "config": {
+                                "seller": "openx.com",
+                                "interestGroupBuyers": ["buyer1.com"],
+                                "sellerTimeout": 0,
+                                "perBuyerSignals": {
+                                    "buyer1.com": [1,"two",3,4, {}]
+                                }
+                            }
+                        }
+                    ]
+                }
+            },
+            "warnings": {
+                "general": [
+                    {
+                        "code": 10002,
+                        "message": "debug turned off for account"
+                    }
+                ]
+            }
+        }
+    }
+}
diff --git a/modules/prebid/ortb2blocking/module_test.go b/modules/prebid/ortb2blocking/module_test.go
new file mode 100644
index 00000000..654790c9
--- /dev/null
+++ b/modules/prebid/ortb2blocking/module_test.go
@@ -0,0 +1,607 @@
+package ortb2blocking
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"testing"
+
+	"github.com/prebid/openrtb/v17/adcom1"
+	"github.com/prebid/openrtb/v17/openrtb2"
+	"github.com/prebid/prebid-server/hooks/hookexecution"
+	"github.com/prebid/prebid-server/hooks/hookstage"
+	"github.com/prebid/prebid-server/modules/moduledeps"
+	"github.com/stretchr/testify/assert"
+)
+
+var testConfig = json.RawMessage(`
+{
+  "attributes": {
+    "badv": {
+      "enforce_blocks": true,
+      "block_unknown_adomain": true,
+      "blocked_adomain": [
+        "a.com",
+        "b.com",
+        "c.com"
+      ],
+      "allowed_adomain_for_deals": [
+        "z.com",
+        "x.com"
+      ],
+      "action_overrides": {
+        "blocked_adomain": [
+          {
+            "conditions": {
+              "bidders": [
+                "appnexus",
+                "rubicon"
+              ],
+              "media_types": [
+                "video"
+              ]
+            },
+            "override": [
+              "a.com",
+              "b.com"
+            ]
+          },
+          {
+            "conditions": {
+              "bidders": [
+                "rubicon"
+              ]
+            },
+            "override": [
+              "a.com",
+              "b.com",
+              "c.com",
+              "d.com",
+              "e.com"
+            ]
+          }
+        ],
+        "block_unknown_adomain": [
+          {
+            "conditions": {
+              "bidders": [
+                "appnexus"
+              ],
+              "media_types": [
+                "video"
+              ]
+            },
+            "override": true
+          }
+        ],
+        "allowed_adomain_for_deals": [
+          {
+            "conditions": {
+              "deal_ids": [
+                "12345678"
+              ]
+            },
+            "override": [
+              "a.com"
+            ]
+          }
+        ]
+      }
+    },
+    "bcat": {
+      "enforce_blocks": false,
+      "block_unknown_adv_cat": false,
+      "category_taxonomy": 6,
+      "blocked_adv_cat": [
+        "IAB-1",
+        "IAB-2"
+      ],
+      "allowed_adv_cat_for_deals": [
+        "IAB-1"
+      ],
+      "action_overrides": {
+        "blocked_adv_cat": [
+          {
+            "conditions": {
+              "media_types": [
+                "video"
+              ]
+            },
+            "override": [
+              "IAB-1",
+              "IAB-2",
+              "IAB-3",
+              "IAB-4"
+            ]
+          }
+        ],
+        "enforce_blocks": [
+          {
+            "conditions": {
+              "bidders": [
+                "appnexus"
+              ]
+            },
+            "override": true
+          }
+        ],
+        "block_unknown_adv_cat": [
+          {
+            "conditions": {
+              "media_types": [
+                "video"
+              ]
+            },
+            "override": true
+          }
+        ],
+        "allowed_adv_cat_for_deals": [
+          {
+            "conditions": {
+              "deal_ids": [
+                "1111111"
+              ]
+            },
+            "override": [
+              "IAB-1"
+            ]
+          }
+        ]
+      }
+    },
+    "bapp": {
+      "enforce_blocks": false,
+      "blocked_app": [
+        "app1",
+        "app2"
+      ],
+      "action_overrides": {
+        "blocked_app": [
+          {
+            "conditions": {
+              "bidders": [
+                "appnexus"
+              ]
+            },
+            "override": [
+              "app3"
+            ]
+          },
+          {
+            "conditions": {
+              "bidders": [
+                "appnexus"
+              ],
+			  "media_types": [
+				"video"
+			  ]
+            },
+            "override": [
+              "app1"
+            ]
+          },
+          {
+            "conditions": {
+			  "media_types": [
+				"video"
+			  ]
+            },
+            "override": [
+              "app1"
+            ]
+          }
+        ]
+      }
+    },
+    "btype": {
+      "blocked_banner_type": [],
+      "action_overrides": {
+        "blocked_banner_type": [
+          {
+            "conditions": {
+              "bidders": [
+                "appnexus"
+              ]
+            },
+            "override": [
+              3,
+              4,
+              5
+            ]
+          },
+          {
+            "conditions": {
+              "bidders": [
+                "appnexus"
+              ],
+			  "media_types": [
+				"video"
+			  ]
+            },
+            "override": [
+              3
+            ]
+          }
+        ]
+      }
+    },
+    "battr": {
+      "enforce_blocks": false,
+      "blocked_banner_attr": [
+        1,
+        8,
+        9,
+        10
+      ],
+      "action_overrides": {
+        "enforce_blocks": [
+          {
+            "conditions": {
+              "bidders": [
+                "appnexus"
+              ]
+            },
+            "override": true
+          }
+        ]
+      }
+    }
+  }
+}`)
+
+const bidder string = "appnexus"
+
+const bAdvA string = "a.com"
+const bAdvB string = "b.com"
+const bAdvC string = "c.com"
+const bAdvD string = "d.com"
+const bAdvE string = "e.com"
+
+const bApp1 string = "app1"
+const bApp2 string = "app2"
+const bApp3 string = "app3"
+
+const bCat1 string = "IAB-1"
+const bCat2 string = "IAB-2"
+const bCat3 string = "IAB-3"
+const bCat4 string = "IAB-4"
+
+const catTax adcom1.CategoryTaxonomy = 6
+
+const bType3 openrtb2.BannerAdType = 3
+const bType4 openrtb2.BannerAdType = 4
+const bType5 openrtb2.BannerAdType = 5
+
+const bAttr1 adcom1.CreativeAttribute = 1
+const bAttr8 adcom1.CreativeAttribute = 8
+const bAttr9 adcom1.CreativeAttribute = 9
+const bAttr10 adcom1.CreativeAttribute = 10
+
+func TestHandleBidderRequestHook(t *testing.T) {
+	testCases := []struct {
+		description        string
+		bidder             string
+		config             json.RawMessage
+		bidRequest         *openrtb2.BidRequest
+		expectedBidRequest *openrtb2.BidRequest
+		expectedHookResult hookstage.HookResult[hookstage.BidderRequestPayload]
+		expectedError      error
+	}{
+		{
+			description: "Payload changed after successful BidderRequest hook execution",
+			bidder:      bidder,
+			config:      testConfig,
+			bidRequest: &openrtb2.BidRequest{
+				Imp: []openrtb2.Imp{
+					{
+						ID:     "ImpID1",
+						Audio:  &openrtb2.Audio{},
+						Banner: &openrtb2.Banner{},
+						Native: &openrtb2.Native{},
+						Video:  &openrtb2.Video{},
+					},
+					{
+						ID:    "ImpID2",
+						Audio: &openrtb2.Audio{},
+					},
+				},
+			},
+			expectedBidRequest: &openrtb2.BidRequest{
+				BAdv: []string{bAdvA, bAdvB},
+				// BApp contains only first specific override (other overrides ignored)
+				BApp:   []string{bApp3},
+				BCat:   []string{bCat1, bCat2, bCat3, bCat4},
+				CatTax: catTax,
+				Imp: []openrtb2.Imp{
+					{
+						ID:    "ImpID1",
+						Audio: &openrtb2.Audio{},
+						Banner: &openrtb2.Banner{
+							BType: []openrtb2.BannerAdType{bType3, bType4, bType5},
+							// default field override is used if no ActionOverrides defined for field
+							BAttr: []adcom1.CreativeAttribute{bAttr1, bAttr8, bAttr9, bAttr10},
+						},
+						Native: &openrtb2.Native{},
+						Video:  &openrtb2.Video{},
+					},
+					{
+						ID:    "ImpID2",
+						Audio: &openrtb2.Audio{},
+						Banner: &openrtb2.Banner{
+							BType: []openrtb2.BannerAdType{bType3, bType4, bType5},
+							// default field override is used if no ActionOverrides defined for field
+							BAttr: []adcom1.CreativeAttribute{bAttr1, bAttr8, bAttr9, bAttr10},
+						},
+					},
+				},
+			},
+			expectedHookResult: hookstage.HookResult[hookstage.BidderRequestPayload]{
+				ModuleContext: map[string]interface{}{
+					bidder: blockingAttributes{
+						bAdv: []string{bAdvA, bAdvB},
+						bApp: []string{bApp3},
+						bCat: []string{bCat1, bCat2, bCat3, bCat4},
+						bType: map[string][]int{
+							"ImpID1": toInt([]openrtb2.BannerAdType{bType3, bType4, bType5}),
+							"ImpID2": toInt([]openrtb2.BannerAdType{bType3, bType4, bType5}),
+						},
+						bAttr: map[string][]int{
+							"ImpID1": toInt([]adcom1.CreativeAttribute{bAttr1, bAttr8, bAttr9, bAttr10}),
+							"ImpID2": toInt([]adcom1.CreativeAttribute{bAttr1, bAttr8, bAttr9, bAttr10}),
+						},
+						catTax: catTax,
+					},
+				},
+				Warnings: []string{
+					// multiple warnings may be added (per condition)
+					"More than one condition matches request. Bidder: appnexus, request media types: audio, banner, native, video",
+					"More than one condition matches request. Bidder: appnexus, request media types: audio, banner, native, video",
+				},
+			},
+			expectedError: nil,
+		},
+		{
+			description: "Payload changed after successful BidderRequest hook execution for default config",
+			bidder:      bidder,
+			config:      json.RawMessage(`{"attributes": {"badv": {"enforce_blocks": true, "block_unknown_adomain": true, "blocked_adomain": ["a.com","b.com","c.com"]}}}`),
+			bidRequest: &openrtb2.BidRequest{
+				Imp: []openrtb2.Imp{
+					{
+						ID:    "ImpID1",
+						Audio: &openrtb2.Audio{},
+					},
+				},
+			},
+			expectedBidRequest: &openrtb2.BidRequest{
+				BAdv: []string{bAdvA, bAdvB, bAdvC},
+				Imp: []openrtb2.Imp{
+					{
+						ID:    "ImpID1",
+						Audio: &openrtb2.Audio{},
+					},
+				},
+			},
+			expectedHookResult: hookstage.HookResult[hookstage.BidderRequestPayload]{
+				ModuleContext: map[string]interface{}{
+					bidder: blockingAttributes{
+						bAdv:  []string{bAdvA, bAdvB, bAdvC},
+						bType: map[string][]int{},
+						bAttr: map[string][]int{},
+					},
+				},
+			},
+			expectedError: nil,
+		},
+		{
+			description: "BidderRequest attributes not updated if they already present in BidderRequest",
+			bidder:      bidder,
+			config:      testConfig,
+			bidRequest: &openrtb2.BidRequest{
+				BAdv:   []string{"existing.com"},
+				BApp:   []string{"existingApp", "existingApp2"},
+				BCat:   []string{"Existing-IAB-1", "Existing-IAB-2"},
+				CatTax: adcom1.CatTaxIABContent10,
+				Imp: []openrtb2.Imp{
+					{
+						ID:    "ImpID1",
+						Audio: &openrtb2.Audio{},
+						Banner: &openrtb2.Banner{
+							BType: []openrtb2.BannerAdType{openrtb2.BannerAdTypeXHTMLTextAd},
+							BAttr: []adcom1.CreativeAttribute{adcom1.AttrSurvey},
+						},
+						Native: &openrtb2.Native{},
+						Video:  &openrtb2.Video{},
+					},
+				},
+			},
+			expectedBidRequest: &openrtb2.BidRequest{
+				BAdv:   []string{"existing.com"},
+				BApp:   []string{"existingApp", "existingApp2"},
+				BCat:   []string{"Existing-IAB-1", "Existing-IAB-2"},
+				CatTax: adcom1.CatTaxIABContent10,
+				Imp: []openrtb2.Imp{
+					{
+						ID:    "ImpID1",
+						Audio: &openrtb2.Audio{},
+						Banner: &openrtb2.Banner{
+							BType: []openrtb2.BannerAdType{openrtb2.BannerAdTypeXHTMLTextAd},
+							BAttr: []adcom1.CreativeAttribute{adcom1.AttrSurvey},
+						},
+						Native: &openrtb2.Native{},
+						Video:  &openrtb2.Video{},
+					},
+				},
+			},
+			expectedHookResult: hookstage.HookResult[hookstage.BidderRequestPayload]{
+				ModuleContext: map[string]interface{}{bidder: blockingAttributes{
+					bType: map[string][]int{},
+					bAttr: map[string][]int{},
+				}},
+			},
+			expectedError: nil,
+		},
+		{
+			description: "BidRequest fields are not updated if config empty",
+			bidder:      bidder,
+			config:      json.RawMessage(`{}`),
+			bidRequest: &openrtb2.BidRequest{
+				BAdv: []string{bAdvA, bAdvC},
+				Imp:  []openrtb2.Imp{{ID: "ImpID1", Video: &openrtb2.Video{}}},
+			},
+			expectedBidRequest: &openrtb2.BidRequest{
+				// field values preserved if config doesn't provide values for this field
+				BAdv: []string{bAdvA, bAdvC},
+				Imp:  []openrtb2.Imp{{ID: "ImpID1", Video: &openrtb2.Video{}}},
+			},
+			expectedHookResult: hookstage.HookResult[hookstage.BidderRequestPayload]{
+				ModuleContext: map[string]interface{}{bidder: blockingAttributes{
+					bType: map[string][]int{},
+					bAttr: map[string][]int{},
+				}},
+			},
+			expectedError: nil,
+		},
+		{
+			description: "Expect empty hook execution result if empty module config provided",
+			bidder:      bidder,
+			config:      json.RawMessage(""),
+			bidRequest: &openrtb2.BidRequest{
+				BAdv: []string{bAdvA, bAdvC},
+				Imp:  []openrtb2.Imp{{ID: "ImpID1", Video: &openrtb2.Video{}}},
+			},
+			expectedBidRequest: &openrtb2.BidRequest{
+				BAdv: []string{bAdvA, bAdvC},
+				Imp:  []openrtb2.Imp{{ID: "ImpID1", Video: &openrtb2.Video{}}},
+			},
+			expectedHookResult: hookstage.HookResult[hookstage.BidderRequestPayload]{},
+			expectedError:      nil,
+		},
+		{
+			description:        "Expect error on config parsing failure",
+			bidder:             bidder,
+			config:             json.RawMessage("..."),
+			bidRequest:         &openrtb2.BidRequest{},
+			expectedBidRequest: &openrtb2.BidRequest{},
+			expectedHookResult: hookstage.HookResult[hookstage.BidderRequestPayload]{},
+			expectedError:      errors.New("failed to parse config: invalid character '.' looking for beginning of value"),
+		},
+		{
+			description:        "Expect error if nil BidRequest provided",
+			bidder:             bidder,
+			config:             testConfig,
+			bidRequest:         nil,
+			expectedBidRequest: nil,
+			expectedHookResult: hookstage.HookResult[hookstage.BidderRequestPayload]{},
+			expectedError:      hookexecution.NewFailure("empty BidRequest provided"),
+		},
+		{
+			description:        "Expect baadv error if bidders and media_types not defined in config conditions",
+			bidder:             bidder,
+			config:             json.RawMessage(`{"attributes": {"badv": {"action_overrides": {"blocked_adomain": [{"conditions": {}}]}}}}`),
+			bidRequest:         &openrtb2.BidRequest{},
+			expectedBidRequest: &openrtb2.BidRequest{},
+			expectedHookResult: hookstage.HookResult[hookstage.BidderRequestPayload]{},
+			expectedError:      hookexecution.NewFailure("failed to update badv field: failed to get override for badv.blocked_adomain: bidders and media_types absent from conditions, at least one of the fields must be present"),
+		},
+		{
+			description:        "Expect bapp error if bidders and media_types not defined in config conditions",
+			bidder:             bidder,
+			config:             json.RawMessage(`{"attributes": {"bapp": {"action_overrides": {"blocked_app": [{"conditions": {}}]}}}}`),
+			bidRequest:         &openrtb2.BidRequest{},
+			expectedBidRequest: &openrtb2.BidRequest{},
+			expectedHookResult: hookstage.HookResult[hookstage.BidderRequestPayload]{},
+			expectedError:      hookexecution.NewFailure("failed to update bapp field: failed to get override for bapp.blocked_app: bidders and media_types absent from conditions, at least one of the fields must be present"),
+		},
+		{
+			description:        "Expect bcat error if bidders and media_types not defined in config conditions",
+			bidder:             bidder,
+			config:             json.RawMessage(`{"attributes": {"bcat": {"action_overrides": {"blocked_adv_cat": [{"conditions": {}}]}}}}`),
+			bidRequest:         &openrtb2.BidRequest{},
+			expectedBidRequest: &openrtb2.BidRequest{},
+			expectedHookResult: hookstage.HookResult[hookstage.BidderRequestPayload]{},
+			expectedError:      hookexecution.NewFailure("failed to update bcat field: failed to get override for bcat.blocked_adv_cat: bidders and media_types absent from conditions, at least one of the fields must be present"),
+		},
+		{
+			description:        "Expect btype error if bidders and media_types not defined in config conditions",
+			bidder:             bidder,
+			config:             json.RawMessage(`{"attributes": {"btype": {"action_overrides": {"blocked_banner_type": [{"conditions": {}}]}}}}`),
+			bidRequest:         &openrtb2.BidRequest{Imp: []openrtb2.Imp{{ID: "ImpID1", Video: &openrtb2.Video{}}}},
+			expectedBidRequest: &openrtb2.BidRequest{Imp: []openrtb2.Imp{{ID: "ImpID1", Video: &openrtb2.Video{}}}},
+			expectedHookResult: hookstage.HookResult[hookstage.BidderRequestPayload]{},
+			expectedError:      hookexecution.NewFailure("failed to update btype field: failed to get override for imp.*.banner.btype: bidders and media_types absent from conditions, at least one of the fields must be present"),
+		},
+		{
+			description:        "Expect battr error if bidders and media_types not defined in config conditions",
+			bidder:             bidder,
+			config:             json.RawMessage(`{"attributes": {"battr": {"action_overrides": {"blocked_banner_attr": [{"conditions": {}}]}}}}`),
+			bidRequest:         &openrtb2.BidRequest{Imp: []openrtb2.Imp{{ID: "ImpID1", Video: &openrtb2.Video{}}}},
+			expectedBidRequest: &openrtb2.BidRequest{Imp: []openrtb2.Imp{{ID: "ImpID1", Video: &openrtb2.Video{}}}},
+			expectedHookResult: hookstage.HookResult[hookstage.BidderRequestPayload]{},
+			expectedError:      hookexecution.NewFailure("failed to update battr field: failed to get override for imp.*.banner.battr: bidders and media_types absent from conditions, at least one of the fields must be present"),
+		},
+		{
+			description:        "Expect error if override.names empty in config conditions",
+			bidder:             bidder,
+			config:             json.RawMessage(`{"attributes": {"badv": {"action_overrides": {"blocked_adomain": [{"conditions": {"bidders": ["appnexus"]}, "override": {}}]}}}}`),
+			bidRequest:         &openrtb2.BidRequest{},
+			expectedBidRequest: &openrtb2.BidRequest{},
+			expectedHookResult: hookstage.HookResult[hookstage.BidderRequestPayload]{},
+			expectedError:      hookexecution.NewFailure("failed to update badv field: failed to get override for badv.blocked_adomain: empty override field"),
+		},
+		{
+			description:        "Expect error if override.ids empty in config conditions",
+			bidder:             bidder,
+			config:             json.RawMessage(`{"attributes": {"battr": {"action_overrides": {"blocked_banner_attr": [{"conditions": {"bidders": ["appnexus"]}, "override": {}}]}}}}`),
+			bidRequest:         &openrtb2.BidRequest{Imp: []openrtb2.Imp{{ID: "ImpID1", Video: &openrtb2.Video{}}}},
+			expectedBidRequest: &openrtb2.BidRequest{Imp: []openrtb2.Imp{{ID: "ImpID1", Video: &openrtb2.Video{}}}},
+			expectedHookResult: hookstage.HookResult[hookstage.BidderRequestPayload]{},
+			expectedError:      hookexecution.NewFailure("failed to update battr field: failed to get override for imp.*.banner.battr: empty override field"),
+		},
+	}
+
+	for _, test := range testCases {
+		t.Run(test.description, func(t *testing.T) {
+			payload := hookstage.BidderRequestPayload{Bidder: test.bidder, BidRequest: test.bidRequest}
+
+			result, err := Builder(nil, moduledeps.ModuleDeps{})
+			assert.NoError(t, err, "Failed to build module.")
+
+			module, ok := result.(Module)
+			assert.True(t, ok, "Failed to cast module type.")
+
+			hookResult, err := module.HandleBidderRequestHook(
+				context.Background(),
+				hookstage.ModuleInvocationContext{
+					AccountConfig: test.config,
+					Endpoint:      hookexecution.EndpointAuction,
+					ModuleContext: map[string]interface{}{},
+				},
+				payload,
+			)
+			assert.Equal(t, test.expectedError, err, "Invalid hook execution error.")
+
+			// test mutations separately
+			for _, mut := range hookResult.ChangeSet.Mutations() {
+				_, err := mut.Apply(payload)
+				assert.NoError(t, err)
+			}
+			assert.Equal(t, test.expectedBidRequest, payload.BidRequest, "Invalid BidRequest after executing BidderRequestHook.")
+
+			// reset ChangeSet not to break hookResult assertion, we validated ChangeSet separately
+			hookResult.ChangeSet = hookstage.ChangeSet[hookstage.BidderRequestPayload]{}
+			assert.Equal(t, test.expectedHookResult, hookResult, "Invalid hook execution result.")
+		})
+	}
+}
+
+type numeric interface {
+	openrtb2.BannerAdType | adcom1.CreativeAttribute
+}
+
+func toInt[T numeric](values []T) []int {
+	ints := make([]int, len(values))
+	for i := range values {
+		ints[i] = int(values[i])
+	}
+	return ints
+}