Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions modules/epom_asBidAdapter.md
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`).
242 changes: 242 additions & 0 deletions modules/epom_asBidAdapter.ts
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]+)?$/;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject malformed hostname and port values

When params.host is syntactically invalid but matches this expression—for example ads.example.com:99999, ., or a..bisBidRequestValid accepts it and buildRequests constructs an unusable HTTPS URL. Core's Request construction 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 👍 / 👎.

Copy link
Copy Markdown
Author

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:99999 makes new URL() throw rather than return something unusable, and core does not call buildRequests inside 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, :99999 and :65535.

I left the hostname labels alone. . and a..b both 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.


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);
Loading