-
Notifications
You must be signed in to change notification settings - Fork 2.4k
Epom Ad Server Bid Adapter: new adapter #15505
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
ruin
wants to merge
10
commits into
prebid:master
Choose a base branch
from
epom:epom_as-bid-adapter
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+1,065
−0
Open
Changes from 9 commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
c638f3c
New adapter: Epom Ad Server (epom_as)
epomrnd 69a3414
Group bids by host without casting through unknown
epomrnd 5a0f568
Add channel and customParams to the Epom Ad Server adapter
epomrnd f99cf21
Accept a host with a port, matching the Prebid Server schema
epomrnd f91fe5a
Send credentials, matching how the ad server answers CORS
epomrnd 29039fc
Set the bid TTL to the window the impression beacon actually has
epomrnd 4c5938c
Epom Ad Server: stop the TTL comment naming one deployment's window
epomrnd b99c10b
Epom Ad Server: point the test parameters at a placement that fills
epomrnd 9d7cbd9
Epom Ad Server: make the two transports agree, and stop dropping a pu…
epomrnd 479e28e
Epom Ad Server: refuse a port a URL cannot carry
epomrnd File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,142 @@ | ||
| # Overview | ||
|
|
||
| ``` | ||
| Module Name: Epom Ad Server Bidder Adapter | ||
| Module Type: Bidder Adapter | ||
| Maintainer: support@epom.com | ||
| ``` | ||
|
|
||
| # Description | ||
|
|
||
| Prebid.js adapter for the **Epom Ad Server** — the sell-side product of the Epom platform, where a publisher's own direct-sold campaigns are booked and served. | ||
|
|
||
| This is a different product from `epom_dsp`, which is the buy side. `epom_dsp` buys impressions on the open market; `epom_as` sells a publisher's own inventory. | ||
|
|
||
| Epom Ad Server is white-label: each network runs its own deployment on its own domain, so the serving host is supplied per ad unit via `params.host`. Only the host is configurable — the request path is fixed by the adapter, so a page configuration cannot redirect the auction payload to an arbitrary URL. A page may mix several deployments; the adapter groups impressions by host and sends one request to each. | ||
|
|
||
| All ad units on the page are auctioned in a **single request** with one `imp` per ad unit. The ad server resolves the page as a unit, so its roadblock and one-campaign-per-page rules require every slot to be decided together. | ||
|
|
||
| Supported media types: `banner`. | ||
|
|
||
| The same parameters are accepted by the Prebid Server adapter, which posts to `https://{{.Host}}/hb/bid` on the host the PBS host company configures. | ||
|
|
||
| ## Bid TTL | ||
|
|
||
| Bids default to a **25-second** TTL — short, because the ad server stops accepting a bid's impression beacon after that window, and a creative rendered from cache past it would serve without being counted. It is a default, not a ceiling: a deployment configured with a wider window says so per bid in `bid.exp`, which takes precedence. | ||
|
|
||
| ## Deals | ||
|
|
||
| The ad server stamps `dealid` on the bids it returns for a deal-backed line item; the adapter surfaces it as `bid.dealId`, which Prebid exposes as the `hb_deal_epom_as` targeting key. A Google Ad Manager Sponsorship line item keyed on that value is the supported way to have an Epom direct bid outrank the rest of the stack rather than compete with it on price alone. | ||
|
|
||
| ## Advertiser domains | ||
|
|
||
| Epom Ad Server does not currently populate `seatbid[].bid[].adomain`, so `bid.meta.advertiserDomains` is left unset rather than filled with a placeholder. Brand-safety line items and analytics that key on advertiser domain will not match Epom bids until the ad server starts sending it; the adapter forwards the field unchanged as soon as it does. | ||
|
|
||
| ## Device storage | ||
|
|
||
| The adapter uses no storage manager and writes nothing to cookies or `localStorage`. It does send the request with credentials, so an Epom identity cookie previously set by the ad server on its own domain reaches the auction — the ad server answers with the request's own `Origin` rather than a wildcard. That cookie is disclosed in the device-storage disclosure published for IAB TCF Global Vendor List ID **849**, declared on the adapter as `disclosureURL`. There is no cross-domain user sync: `getUserSyncs` deliberately registers nothing. | ||
|
|
||
| # Bid Parameters | ||
|
|
||
| | Name | Scope | Description | Example | Type | | ||
| |----------------|----------|-------------------------------------------------------------------------------------------------------------------------------|------------------------|----------| | ||
| | `host` | required | Serving host of the publisher's Epom Ad Server deployment, as a bare hostname with an optional port — no scheme, path or query. The adapter POSTs to `https://{host}/hb/bid`. | `'ads.example.com'` | `string` | | ||
| | `placementKey` | required | Placement identifier, copied from the placement's invocation-code tab in the Epom UI. Sent as `imp.tagid`. | `'a4f21c9e7b'` | `string` | | ||
| | `channel` | optional | Epom channel — a publisher traffic-slice label used for channel targeting and reporting. Sent as `imp.ext.epom_as.channel`. An empty value is ignored. | `'sports-uk'` | `string` | | ||
| | `customParams` | optional | Epom custom parameters, for custom targeting and creative macros. Values must be strings, numbers or booleans; they are stringified and merged into `imp.ext.data`, where keys already on the impression win. The ad server applies its own ingest limits on top (at most 32 keys, keys to 128 and values to 512 characters) and ignores anything beyond them. | `{section: 'sport'}` | `object` | | ||
| | `bidFloor` | optional | CPM floor for this impression, applied only when no floor has already been resolved — a value from the Price Floors module always wins. `0` means no floor. | `0.50` | `number` | | ||
| | `bidFloorCur` | optional | Currency of `bidFloor`, as an ISO-4217 code. Defaults to `USD`. | `'EUR'` | `string` | | ||
|
|
||
| A bid whose parameters violate the table above is rejected by `isBidRequestValid` and never leaves the page — the same input the Prebid Server params schema rejects. | ||
|
|
||
| # Test Parameters | ||
|
|
||
| A live placement on an Epom Ad Server demo deployment that always fills, for verifying the adapter end to end: | ||
|
|
||
| ```js | ||
| const adUnits = [ | ||
| { | ||
| code: "test-div", | ||
| mediaTypes: { | ||
| banner: { | ||
| sizes: [[300, 250]] | ||
| } | ||
| }, | ||
| bids: [ | ||
| { | ||
| bidder: "epom_as", | ||
| params: { | ||
| host: "aj2494.online", | ||
| placementKey: "63bad7a99f270394e7b4b370952cbff2" | ||
| } | ||
| } | ||
| ] | ||
| } | ||
| ]; | ||
| ``` | ||
|
|
||
| The optional parameters, on a deployment of your own. `host` is a bare hostname — | ||
| the adapter posts to `https://{host}/hb/bid` — and every ad unit naming the same | ||
| host travels in one request: | ||
|
|
||
| ```js | ||
| const adUnits = [ | ||
| { | ||
| code: "leaderboard", | ||
| mediaTypes: { banner: { sizes: [[728, 90], [970, 250]] } }, | ||
| bids: [{ | ||
| bidder: "epom_as", | ||
| params: { | ||
| host: "ads.example.com", | ||
| placementKey: "a4f21c9e7b", | ||
| channel: "sports-uk", | ||
| customParams: { section: "sport" } | ||
| } | ||
| }] | ||
| }, | ||
| { | ||
| code: "sidebar", | ||
| mediaTypes: { banner: { sizes: [[300, 250]] } }, | ||
| bids: [{ | ||
| bidder: "epom_as", | ||
| params: { | ||
| host: "ads.example.com", | ||
| placementKey: "6d0e83b415", | ||
| bidFloor: 0.50, | ||
| bidFloorCur: "EUR" | ||
| } | ||
| }] | ||
| } | ||
| ]; | ||
| ``` | ||
|
|
||
| # Multiple Deployments | ||
|
|
||
| A publisher whose inventory is sold by two Epom networks can run both in the same auction. Each host receives its own request containing only the impressions addressed to it. | ||
|
|
||
| ```js | ||
| pbjs.addAdUnits([ | ||
| { | ||
| code: "slot-a", | ||
| mediaTypes: { banner: { sizes: [[300, 250]] } }, | ||
| bids: [{ | ||
| bidder: "epom_as", | ||
| params: { host: "ads.network-one.com", placementKey: "a4f21c9e7b" } | ||
| }] | ||
| }, | ||
| { | ||
| code: "slot-b", | ||
| mediaTypes: { banner: { sizes: [[728, 90]] } }, | ||
| bids: [{ | ||
| bidder: "epom_as", | ||
| params: { host: "ads.network-two.com", placementKey: "6d0e83b415" } | ||
| }] | ||
| } | ||
| ]); | ||
| ``` | ||
|
|
||
| # Consent and Privacy | ||
|
|
||
| The adapter registers IAB TCF Global Vendor List ID **849** and relies on Prebid.js's standard consent plumbing via `ortbConverter`: GDPR (`regs.ext.gdpr`, `user.ext.consent`), US Privacy (`regs.ext.us_privacy`), GPP (`regs.gpp`, `regs.gpp_sid`) and COPPA (`regs.coppa`) are forwarded to the ad server unchanged, without bidder-specific configuration. | ||
|
|
||
| First-party data set through `pbjs.setConfig({ortb2})`, `pbjs.setBidderConfig` and an ad unit's `ortb2Imp` is forwarded as-is, as are the EIDs written by the User ID modules (`user.ext.eids`). |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,242 @@ | ||
| import { ortbConverter } from '../libraries/ortbConverter/converter.js'; | ||
| import { type BidderSpec, registerBidder } from '../src/adapters/bidderFactory.js'; | ||
| import { type Bid } from '../src/bidfactory.js'; | ||
| import { BANNER } from '../src/mediaTypes.js'; | ||
| import { deepSetValue, isPlainObject } from '../src/utils.js'; | ||
|
|
||
| /** | ||
| * Prebid.js adapter for the Epom Ad Server — the supply side of the Epom | ||
| * platform, where a publisher's own direct campaigns live. | ||
| * | ||
| * Epom is white-label: every network runs its own deployment under its own | ||
| * domain, so the serving host is a per-bid parameter (`params.host`) rather | ||
| * than a module constant. Only the host varies — the path is fixed, so a | ||
| * page config can never redirect the auction payload to an arbitrary URL. | ||
| * | ||
| * Not to be confused with `epom_dsp`, which is the demand side: it buys | ||
| * impressions. This adapter sells a publisher's inventory. | ||
| */ | ||
|
|
||
| const BIDDER_CODE = 'epom_as'; | ||
| const GVLID = 849; | ||
| const BID_PATH = '/hb/bid'; | ||
| const DEFAULT_CURRENCY = 'USD'; | ||
| /** | ||
| * A bid cached longer than the ad server accepts its impression beacon renders without being | ||
| * counted, so this is deliberately short. It is only the floor: a deployment configured with a | ||
| * wider window says so per bid in `bid.exp`, which overrides this. | ||
| */ | ||
| const DEFAULT_TTL = 25; | ||
|
|
||
| /** | ||
| * Hostname with an optional port — no scheme, path, query, fragment or userinfo, so a page | ||
| * configuration cannot redirect the payload. Byte-identical to the expression Prebid Server | ||
| * validates the same parameter with (`util/urlutil/security.go`, and the `host` property of | ||
| * the epom_as params schema), so a bid this adapter accepts is one that server also accepts. | ||
| * Single-label hosts are deliberately allowed: an internal deployment reachable as `api-us` | ||
| * is a legitimate configuration on both transports. | ||
| */ | ||
| const HOST_PATTERN = /^[a-zA-Z0-9.-]+(:[0-9]+)?$/; | ||
|
|
||
| export type EpomAsBidParams = { | ||
| /** Serving host of the publisher's Epom deployment, e.g. `ads.example.com`. */ | ||
| host: string; | ||
| /** Opaque placement identifier from the Epom invocation-code tab. */ | ||
| placementKey: string; | ||
| /** Epom channel — a traffic-slice label used for targeting and reporting. */ | ||
| channel?: string; | ||
| /** Epom custom parameters, for custom targeting and creative macros. */ | ||
| customParams?: Record<string, string | number | boolean>; | ||
| /** Optional CPM floor, used only when the Price Floors module supplies none. */ | ||
| bidFloor?: number; | ||
| /** Currency of `bidFloor`. Defaults to USD. */ | ||
| bidFloorCur?: string; | ||
| }; | ||
|
|
||
| declare module '../src/adUnits' { | ||
| interface BidderParams { | ||
| [BIDDER_CODE]: EpomAsBidParams; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * The values the ad server can key targeting on. A nested object or an array stringifies to | ||
| * something no campaign can ever match, so it is rejected as a misconfiguration in | ||
| * `isBidRequestValid` rather than quietly sent — which is also what the Prebid Server params | ||
| * schema does with the same input. | ||
| */ | ||
| function isTargetableScalar(value: unknown): boolean { | ||
| const type = typeof value; | ||
| return type === 'string' || type === 'number' || type === 'boolean'; | ||
| } | ||
|
|
||
| /** | ||
| * Stringify every entry — the ad server compares custom parameters as strings, so sending | ||
| * `2` and `"2"` differently would make an otherwise identical campaign match one and not | ||
| * the other. Nothing is dropped: the caps the ad server applies on ingest are its own, and | ||
| * silently discarding keys here would leave a publisher with no signal that half their | ||
| * targeting never arrived. | ||
| */ | ||
| function sanitiseCustomParams( | ||
| raw: EpomAsBidParams['customParams'] | ||
| ): Record<string, string> | null { | ||
| if (!isPlainObject(raw)) { | ||
| return null; | ||
| } | ||
| const keys = Object.keys(raw); | ||
| if (keys.length === 0) { | ||
| return null; | ||
| } | ||
| const out: Record<string, string> = {}; | ||
| keys.forEach((key) => { | ||
| out[key] = String(raw[key]); | ||
| }); | ||
| return out; | ||
| } | ||
|
|
||
| const converter = ortbConverter<typeof BIDDER_CODE>({ | ||
| context: { | ||
| netRevenue: true, | ||
| ttl: DEFAULT_TTL, | ||
| currency: DEFAULT_CURRENCY, | ||
| mediaType: BANNER, | ||
| }, | ||
|
|
||
| imp(buildImp, bidRequest, context) { | ||
| const imp = buildImp(bidRequest, context); | ||
| const params = bidRequest.params; | ||
|
|
||
| // The placement travels as `imp.tagid` rather than in an ext object so a | ||
| // single request can carry a different placement per impression. It is | ||
| // per-imp data and therefore cannot live in the URL — see buildRequests. | ||
| imp.tagid = params.placementKey; | ||
|
|
||
| // Honour a manual floor only when the Price Floors module has not already | ||
| // resolved one; the module's value is always the more informed of the two. | ||
| // A floor of 0 is "no floor" and is left off the wire entirely. | ||
| if (imp.bidfloor == null && typeof params.bidFloor === 'number' && params.bidFloor > 0) { | ||
| imp.bidfloor = params.bidFloor; | ||
| imp.bidfloorcur = params.bidFloorCur || DEFAULT_CURRENCY; | ||
| } | ||
|
|
||
| if (params.channel) { | ||
| deepSetValue(imp, `ext.${BIDDER_CODE}.channel`, String(params.channel)); | ||
| } | ||
|
|
||
| // Custom parameters go to imp.ext.data, the standard first-party-data home, so | ||
| // that RTD modules and gptPreAuction contribute to the same object rather than | ||
| // to a private one the ad server would have to read twice. Anything already on | ||
| // the impression wins — first-party data is the more authoritative source. | ||
| const custom = sanitiseCustomParams(params.customParams); | ||
| if (custom) { | ||
| imp.ext = imp.ext || {}; | ||
| imp.ext.data = { ...custom, ...(imp.ext.data as object) }; | ||
| } | ||
|
|
||
| return imp; | ||
| }, | ||
|
|
||
| request(buildRequest, imps, bidderRequest, context) { | ||
| const request = buildRequest(imps, bidderRequest, context); | ||
| if (!request.cur || request.cur.length === 0) { | ||
| request.cur = [DEFAULT_CURRENCY]; | ||
| } | ||
| return request; | ||
| }, | ||
| }); | ||
|
|
||
| export const spec: BidderSpec<typeof BIDDER_CODE> = { | ||
| code: BIDDER_CODE, | ||
| gvlid: GVLID, | ||
| // Device-storage disclosure for the Epom identity cookie. The adapter itself | ||
| // uses no storage manager and writes nothing — the cookie is set by the ad | ||
| // server on its own domain and only reaches the auction because the POST is | ||
| // credentialed (see buildRequests). | ||
| disclosureURL: 'https://epom.com/deviceStorage.json', | ||
| supportedMediaTypes: [BANNER], | ||
|
|
||
| /** | ||
| * Only the bidder's own parameters are checked, and each check is the client-side | ||
| * twin of the Prebid Server params schema — a bid rejected here is an imp that | ||
| * server would reject too. Core already logs the rejection, so nothing is logged. | ||
| */ | ||
| isBidRequestValid(bid) { | ||
| const params = bid?.params; | ||
| if (typeof params?.host !== 'string' || !HOST_PATTERN.test(params.host)) { | ||
| return false; | ||
| } | ||
| if (typeof params.placementKey !== 'string' || params.placementKey.length === 0) { | ||
| return false; | ||
| } | ||
| if (params.customParams !== undefined && | ||
| (!isPlainObject(params.customParams) || !Object.values(params.customParams).every(isTargetableScalar))) { | ||
| return false; | ||
| } | ||
| if (params.bidFloor !== undefined && | ||
| (typeof params.bidFloor !== 'number' || !isFinite(params.bidFloor) || params.bidFloor < 0)) { | ||
| return false; | ||
| } | ||
| return true; | ||
| }, | ||
|
|
||
| /** | ||
| * One request per host, carrying every impression that belongs to it. | ||
| * | ||
| * Prebid runs a single auction for the whole page, so `validBidRequests` | ||
| * arrives with one entry per ad unit. Batching them into one OpenRTB | ||
| * request with N `imp` objects is what lets the ad server resolve the page | ||
| * as a unit — its roadblock and one-campaign-per-page rules only hold when | ||
| * every slot is decided together. Emitting one request per ad unit would | ||
| * make those rules race each other. | ||
| * | ||
| * Grouping is by host because a page may legitimately mix two Epom | ||
| * deployments. Taking the host from the first bid instead would silently | ||
| * route the remaining impressions to the wrong network. | ||
| */ | ||
| buildRequests(validBidRequests, bidderRequest) { | ||
| if (!validBidRequests?.length) { | ||
| return []; | ||
| } | ||
|
|
||
| const byHost = new Map<string, (typeof validBidRequests)[number][]>(); | ||
| validBidRequests.forEach((bid) => { | ||
| const group = byHost.get(bid.params.host); | ||
| if (group) { | ||
| group.push(bid); | ||
| } else { | ||
| byHost.set(bid.params.host, [bid]); | ||
| } | ||
| }); | ||
|
|
||
| return Array.from(byHost, ([host, group]) => ({ | ||
| method: 'POST' as const, | ||
| url: `https://${host}${BID_PATH}`, | ||
| data: converter.toORTB({ bidRequests: group, bidderRequest }), | ||
| // Both options are already core's defaults for an adapter POST | ||
| // (src/adapters/bidderFactory), and are pinned here so a change to those | ||
| // defaults cannot break either one silently. `text/plain` keeps this a | ||
| // simple cross-origin request, so the browser skips the CORS preflight — | ||
| // one round-trip inside the auction timeout instead of two; the body is | ||
| // still JSON. Credentials are sent because the ad server answers with the | ||
| // request's own Origin, which is what lets an existing Epom identity | ||
| // reach the auction. | ||
| options: { contentType: 'text/plain', withCredentials: true }, | ||
| })); | ||
| }, | ||
|
|
||
| interpretResponse(serverResponse, request) { | ||
| if (!serverResponse?.body) { | ||
| return []; | ||
| } | ||
| return (converter.fromORTB({ | ||
| response: serverResponse.body, | ||
| request: request.data, | ||
| }) as { bids: Bid[] }).bids; | ||
| }, | ||
|
|
||
| // The ad server matches on its own first-party cookie and offers no | ||
| // cross-domain sync endpoint, so there is deliberately nothing to register. | ||
| getUserSyncs: () => [], | ||
| }; | ||
|
|
||
| registerBidder(spec); | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
params.hostis syntactically invalid but matches this expression—for exampleads.example.com:99999,., ora..b—isBidRequestValidaccepts it andbuildRequestsconstructs an unusable HTTPS URL. Core'sRequestconstruction can then throw before sending, causing the Epom adapter to complete without bids for that request. Validate hostname labels and the port range, or parse and verify the constructed URL before accepting the bid.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Taken in part, and thank you — the port case is real.
ads.example.com:99999makesnew URL()throw rather than return something unusable, and core does not callbuildRequestsinside a try (bidderFactory.ts:436), so one mistyped port in a page's configuration would take the whole request down rather than cost a single bid. Fixed in 479e28e: the port is range-checked to 1-65535 on top of the existing shape, with boundary tests for:0,:65536,:99999and:65535.I left the hostname labels alone.
.anda..bboth construct a valid URL and simply fail to resolve, which is an ordinary no-fill rather than a thrown request. The expression itself is the one Prebid Server validates this same parameter with (util/urlutil/security.go), and its purpose there is to keep URL control characters out of a host rather than to check that a host resolves — tightening it here would put the two transports back out of step, and would reject single-label hosts, which are a legitimate deployment shape for this white-label ad server.