Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
18 changes: 17 additions & 1 deletion floxis-backport-kit/INTEGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,23 @@ pbjs.setConfig({
Floxis syncs entirely, exclude `floxis` from both `iframe` and `image`; `userSync.syncEnabled: false`
disables syncs for all bidders.

## 5. Verification & troubleshooting
## 5. Enable first-party fallback id (recommended)

The adapter mints a random UUID in the publisher's own page context (`localStorage` + cookie, scoped to the publisher's origin) and sends it at `user.ext.floxisId`. This is an identity-of-last-resort for Safari, Firefox and other browsers where the Floxis third-party cookie (`__fxId`) is blocked — it does not affect Chrome/Edge, where the third-party cookie takes precedence on the backend.

Since Prebid.js 7.x, bidder-level storage access is **denied by default**. Without the opt-in below, no id is generated and no storage is touched — a safe no-op, not an error. To enable it:

```javascript
pbjs.bidderSettings = {
floxis: {
storageAllowed: true
}
};
```

This is scoped to the Floxis bidder only and does not affect storage permissions for your other SSPs. Storage access is further gated by Prebid.js core's `deviceAccess` config and GDPR purpose-1 consent under Floxis's registered gvlid (1609).

## 6. Verification & troubleshooting

- **Enable debugging:** `pbjs.setConfig({ debug: true })` and watch the console.
- **Module check:** confirm `floxisBidAdapter` appears in `pbjs.installedModules`.
Expand Down
48 changes: 47 additions & 1 deletion floxis-backport-kit/modules/floxisBidAdapter.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { registerBidder } from '../src/adapters/bidderFactory.js';
import { BANNER, NATIVE, VIDEO } from '../src/mediaTypes.js';
import { ortbConverter } from '../libraries/ortbConverter/converter.js';
import { triggerPixel, mergeDeep, replaceAuctionPrice } from '../src/utils.js';
import { triggerPixel, mergeDeep, replaceAuctionPrice, generateUUID } from '../src/utils.js';
import { politeTriggerPixel } from '../libraries/floxisUtils/politePixel.js';
import { getStorageManager } from '../src/storageManager.js';

const BIDDER_CODE = 'floxis';
const GVLID = 1609;
Expand All @@ -12,6 +13,11 @@
const DEFAULT_REGION = 'us-e';
const DEFAULT_PARTNER = BIDDER_CODE;
const SYNC_PATH = '/sync';
const FLOXIS_ID_KEY = 'flx_uid';
const FLOXIS_ID_COOKIE_EXP = 2592000000; // 30 days
const UUID_LENGTH = 36;

export const storage = getStorageManager({ bidderCode: BIDDER_CODE });
// Server-echo user-sync: the /pbjs response carries seat + region in this header (on bid and no-bid
// alike), so getUserSyncs derives sync targets from serverResponses statelessly — no module state that
// could leak across concurrent auctions. Absent header (older backend) => no sync, a safe no-op.
Expand All @@ -20,26 +26,26 @@
// partner/region are interpolated into the request host, so they must be valid DNS labels —
// otherwise a value with URL delimiters (e.g. 'evil.com/x?') would change the request origin.
const HOST_LABEL_REGEX = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/i;
function isValidHostLabel(label) {

Check warning on line 29 in floxis-backport-kit/modules/floxisBidAdapter.js

View workflow job for this annotation

GitHub Actions / Run linter

Missing JSDoc comment
return typeof label === 'string' && HOST_LABEL_REGEX.test(label);
}

// Bidding host: the supply partner's regional subdomain (floxis itself has no partner prefix).
function getBidHost(region, partner) {

Check warning on line 34 in floxis-backport-kit/modules/floxisBidAdapter.js

View workflow job for this annotation

GitHub Actions / Run linter

Missing JSDoc comment
if (!isValidHostLabel(region) || !isValidHostLabel(partner)) return null;
return partner === BIDDER_CODE
? `${region}.floxis.tech`
: `${partner}-${region}.floxis.tech`;
}

function getEndpointUrl(seat, region, partner) {

Check warning on line 41 in floxis-backport-kit/modules/floxisBidAdapter.js

View workflow job for this annotation

GitHub Actions / Run linter

Missing JSDoc comment
const host = getBidHost(region, partner);
return host ? `https://${host}/pbjs?seat=${encodeURIComponent(seat)}` : null;
}

// Cookie-sync host is Floxis-operated and region-scoped (px-<region>.floxis.tech), independent of
// the partner subdomain used for bidding. The trackers /sync endpoint resolves seat -> supply partner.
function getSyncHost(region) {

Check warning on line 48 in floxis-backport-kit/modules/floxisBidAdapter.js

View workflow job for this annotation

GitHub Actions / Run linter

Missing JSDoc comment
return isValidHostLabel(region) ? `https://px-${region}.floxis.tech` : null;
}

Expand All @@ -51,7 +57,7 @@

// Assemble an event-beacon URL. consentSuffix is a pre-built '&k=v&...' string (may be empty).
// extras is a plain object of optional dimension key→value pairs; falsy values are omitted.
function buildEventUrl(eventType, { seat, region }, extras, consentSuffix) {

Check warning on line 60 in floxis-backport-kit/modules/floxisBidAdapter.js

View workflow job for this annotation

GitHub Actions / Run linter

Missing JSDoc comment
const base = `${TELEMETRY_HOST}${TELEMETRY_PATH}?event=${encodeURIComponent(eventType)}&seat=${encodeURIComponent(seat)}&region=${encodeURIComponent(region)}`;
const extraParams = Object.entries(extras)
.filter(([, v]) => v != null && v !== '')
Expand All @@ -61,7 +67,7 @@
}

// IAB consent query params for the trackers /sync endpoint.
function buildConsentQuery(gdprConsent, uspConsent, gppConsent) {

Check warning on line 70 in floxis-backport-kit/modules/floxisBidAdapter.js

View workflow job for this annotation

GitHub Actions / Run linter

Missing JSDoc comment
const query = [];
if (gdprConsent) {
if (typeof gdprConsent.gdprApplies === 'boolean') {
Expand All @@ -81,7 +87,7 @@
return query;
}

function normalizeBidParams(params = {}) {

Check warning on line 90 in floxis-backport-kit/modules/floxisBidAdapter.js

View workflow job for this annotation

GitHub Actions / Run linter

Missing JSDoc comment
return {
seat: params.seat,
region: params.region || DEFAULT_REGION,
Expand All @@ -89,6 +95,40 @@
};
}

function isValidFloxisId(id) {

Check warning on line 98 in floxis-backport-kit/modules/floxisBidAdapter.js

View workflow job for this annotation

GitHub Actions / Run linter

Missing JSDoc comment
return typeof id === 'string' && id.length === UUID_LENGTH;
}

// Neither store available => null, not a freshly minted id we can't persist (would defeat stability).
function getOrCreateFloxisId() {
if (typeof window === 'undefined') return null;
try {
const localOk = storage.localStorageIsEnabled();
const cookieOk = storage.cookiesAreEnabled();
if (!localOk && !cookieOk) return null;

let id = localOk ? storage.getDataFromLocalStorage(FLOXIS_ID_KEY) : null;
if (!isValidFloxisId(id) && cookieOk) {
id = storage.getCookie(FLOXIS_ID_KEY);
}
if (!isValidFloxisId(id)) {
id = generateUUID();
}

if (localOk) {
storage.setDataInLocalStorage(FLOXIS_ID_KEY, id);
}
if (cookieOk) {
const expires = new Date(Date.now() + FLOXIS_ID_COOKIE_EXP).toUTCString();
storage.setCookie(FLOXIS_ID_KEY, id, expires);
}

return id;
} catch (e) {
return null;
}
}

// Parse the server-echoed sync header (`seat=<seat>&region=<label>`) into a sync target. Returns null
// for an absent or malformed header so a response without it simply contributes no sync.
function parseSyncHeader(headerValue) {
Expand Down Expand Up @@ -152,6 +192,12 @@
}
}
});
if (!req.user?.ext?.floxisId) {
const floxisId = getOrCreateFloxisId();
if (floxisId) {
mergeDeep(req, { user: { ext: { floxisId } } });
}
}
return req;
},
bidResponse(buildBidResponse, bid, context) {
Expand Down
17 changes: 17 additions & 0 deletions floxis-backport-kit/modules/floxisBidAdapter.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ The Floxis Bid Adapter enables integration with the Floxis programmatic advertis
- OpenRTB 2.x compliant
- Privacy signal forwarding (GDPR/TCF, USP, GPP, COPPA) via Prebid.js core
- User identity (User ID / `eids`) and supply chain (`schain`) passthrough
- First-party fallback id for cookieless browsers (`user.ext.floxisId`)
- Prebid.js Floors Module support (plus a static `bidFloor` param fallback)
- User sync (iframe and pixel cookie matching)

Expand All @@ -29,6 +30,22 @@ The Floxis Bid Adapter supports the Prebid.js [Floors Module](https://docs.prebi
## User Identity & Supply Chain
`user.ext.eids` from the Prebid [User ID module](https://docs.prebid.org/dev-docs/modules/userId.html) and `source.ext.schain` (set via `pbjs.setConfig({ schain })` or `ortb2`) are forwarded automatically through first-party-data passthrough — no adapter-specific configuration is required.

## First-Party Fallback Id
Floxis's primary identity signal, the `__fxId` cookie set on `.floxis.tech`, is a third-party cookie relative to the publisher and is blocked by Safari, Firefox and other ITP/ETP browsers. To keep an identity-of-last-resort in those browsers, the adapter mints a random v4 UUID **in the publisher's own page context** (first-party) and places it at `user.ext.floxisId` in the OpenRTB request.

- **Storage & scope**: the id is persisted via `localStorage` (preferred) and a cookie, both scoped to the *publisher's own origin* — it is per-publisher, not cross-site, and is never shared between different sites running the adapter. Cookie lifetime is ~30 days; the id is regenerated if the stored value is not a well-formed 36-character UUID.
- **Priority on the backend**: the client id is a fallback only. Floxis's backend applies `processedCookieUserId (the __fxId cookie) .orElse(clientFloxisId) .orElse(existing user.id)` — when the `__fxId` cookie is present (e.g. Chrome/Edge), behavior is unchanged and the client id is ignored.
- **Consent**: storage access goes through Prebid.js core's `storageManager`, gated by the standard `deviceAccess` config and GDPR purpose-1 consent under Floxis's registered `gvlid` (1609) — the adapter adds no bespoke consent logic. If storage access is disallowed, no id is generated or sent, and the auction is unaffected.
- **Publisher opt-in required**: since Prebid.js 7.x, bidder-level storage access is denied by default and must be explicitly granted per bidder — without it, no `floxisId` is ever generated (a safe no-op, not an error). Enable it via:
```js
// https://docs.prebid.org/dev-docs/publisher-api-reference/bidderSettings.html
pbjs.bidderSettings = {
floxis: {
storageAllowed: true
}
}
```

## Privacy
GDPR/TCF, US Privacy, GPP and COPPA signals are handled by Prebid.js core and automatically included in the OpenRTB request; consent strings are also appended to user-sync calls. Floxis is registered with IAB Europe TCF as Vendor ID **1609**, declared via the adapter's `gvlid`.

Expand Down
140 changes: 139 additions & 1 deletion floxis-backport-kit/test/spec/modules/floxisBidAdapter_spec.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { expect } from 'chai';
import sinon from 'sinon';
import { spec } from 'modules/floxisBidAdapter.js';
import { spec, storage } from 'modules/floxisBidAdapter.js';
import { BANNER, NATIVE, VIDEO } from 'src/mediaTypes.js';
import * as utils from 'src/utils.js';
import * as floxisPolitePixel from '../../../libraries/floxisUtils/politePixel.js';
Expand Down Expand Up @@ -387,6 +387,144 @@ describe('floxisBidAdapter', function () {
expect(data.cur).to.deep.equal(['USD']);
});
});

describe('First-party fallback id (user.ext.floxisId)', function () {
const STUBBED_UUID = '11111111-1111-4111-8111-111111111111';
const STORED_UUID = '22222222-2222-4222-8222-222222222222';
// ortb2.id avoids the core request processor's own generateUUID() call for req.id, so the stub's call count reflects only the floxisId logic.
const floxisIdBidderRequest = { ...bidderRequest, ortb2: { id: 'fixed-request-id' } };
let localStorageIsEnabledStub, cookiesAreEnabledStub;
let getDataFromLocalStorageStub, getCookieStub;
let setDataInLocalStorageStub, setCookieStub;
let generateUUIDStub;

beforeEach(function () {
localStorageIsEnabledStub = sinon.stub(storage, 'localStorageIsEnabled');
cookiesAreEnabledStub = sinon.stub(storage, 'cookiesAreEnabled');
getDataFromLocalStorageStub = sinon.stub(storage, 'getDataFromLocalStorage');
getCookieStub = sinon.stub(storage, 'getCookie');
setDataInLocalStorageStub = sinon.stub(storage, 'setDataInLocalStorage');
setCookieStub = sinon.stub(storage, 'setCookie');
generateUUIDStub = sinon.stub(utils, 'generateUUID').returns(STUBBED_UUID);
});

afterEach(function () {
localStorageIsEnabledStub.restore();
cookiesAreEnabledStub.restore();
getDataFromLocalStorageStub.restore();
getCookieStub.restore();
setDataInLocalStorageStub.restore();
setCookieStub.restore();
generateUUIDStub.restore();
});

it('mints and persists a new id when none is stored', function () {
localStorageIsEnabledStub.returns(true);
cookiesAreEnabledStub.returns(true);
getDataFromLocalStorageStub.returns(null);
getCookieStub.returns(null);

const data = spec.buildRequests([validBannerBid], floxisIdBidderRequest)[0].data;

expect(data.user.ext.floxisId).to.equal(STUBBED_UUID);
expect(generateUUIDStub.calledOnce).to.be.true;
expect(setDataInLocalStorageStub.calledWith('flx_uid', STUBBED_UUID)).to.be.true;
expect(setCookieStub.calledWith('flx_uid', STUBBED_UUID)).to.be.true;
});

it('reuses a valid stored id without regenerating it', function () {
localStorageIsEnabledStub.returns(true);
cookiesAreEnabledStub.returns(true);
getDataFromLocalStorageStub.returns(STORED_UUID);

const data = spec.buildRequests([validBannerBid], floxisIdBidderRequest)[0].data;

expect(data.user.ext.floxisId).to.equal(STORED_UUID);
expect(generateUUIDStub.called).to.be.false;
});

it('falls back to the cookie when localStorage has no stored value', function () {
localStorageIsEnabledStub.returns(true);
cookiesAreEnabledStub.returns(true);
getDataFromLocalStorageStub.returns(null);
getCookieStub.returns(STORED_UUID);

const data = spec.buildRequests([validBannerBid], floxisIdBidderRequest)[0].data;

expect(data.user.ext.floxisId).to.equal(STORED_UUID);
expect(generateUUIDStub.called).to.be.false;
});

it('regenerates the id when the stored value is a malformed UUID', function () {
localStorageIsEnabledStub.returns(true);
cookiesAreEnabledStub.returns(true);
getDataFromLocalStorageStub.returns('not-a-uuid');
getCookieStub.returns('also-not-a-uuid');

const data = spec.buildRequests([validBannerBid], floxisIdBidderRequest)[0].data;

expect(data.user.ext.floxisId).to.equal(STUBBED_UUID);
expect(generateUUIDStub.calledOnce).to.be.true;
expect(setDataInLocalStorageStub.calledWith('flx_uid', STUBBED_UUID)).to.be.true;
});

it('sends no id and does not throw when storage is disallowed', function () {
localStorageIsEnabledStub.returns(false);
cookiesAreEnabledStub.returns(false);

let data;
expect(function () {
data = spec.buildRequests([validBannerBid], floxisIdBidderRequest)[0].data;
}).to.not.throw();

expect(data.user?.ext?.floxisId).to.be.undefined;
expect(setDataInLocalStorageStub.called).to.be.false;
expect(setCookieStub.called).to.be.false;
});

it('sends no id and does not throw when a storage accessor throws', function () {
localStorageIsEnabledStub.returns(true);
cookiesAreEnabledStub.returns(true);
getDataFromLocalStorageStub.throws(new Error('storage access error'));

let data;
expect(function () {
data = spec.buildRequests([validBannerBid], floxisIdBidderRequest)[0].data;
}).to.not.throw();

expect(data.user?.ext?.floxisId).to.be.undefined;
});

it('does not clobber other user.ext fields set via ortb2 FPD', function () {
localStorageIsEnabledStub.returns(true);
cookiesAreEnabledStub.returns(true);
getDataFromLocalStorageStub.returns(null);
getCookieStub.returns(null);

const ortb2BidderRequest = {
...floxisIdBidderRequest,
ortb2: { ...floxisIdBidderRequest.ortb2, user: { ext: { consent: 'consent-string-123' } } }
};
const data = spec.buildRequests([validBannerBid], ortb2BidderRequest)[0].data;

expect(data.user.ext.consent).to.equal('consent-string-123');
expect(data.user.ext.floxisId).to.equal(STUBBED_UUID);
});

it('does not overwrite an existing user.ext.floxisId', function () {
localStorageIsEnabledStub.returns(true);
cookiesAreEnabledStub.returns(true);

const ortb2BidderRequest = {
...floxisIdBidderRequest,
ortb2: { ...floxisIdBidderRequest.ortb2, user: { ext: { floxisId: STORED_UUID } } }
};
const data = spec.buildRequests([validBannerBid], ortb2BidderRequest)[0].data;

expect(data.user.ext.floxisId).to.equal(STORED_UUID);
expect(generateUUIDStub.called).to.be.false;
});
});
});

describe('interpretResponse', function () {
Expand Down
Loading