Skip to content
Closed
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
49 changes: 48 additions & 1 deletion modules/floxisBidAdapter.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { registerBidder } from '../src/adapters/bidderFactory.js';
import { BANNER, NATIVE, VIDEO } from '../src/mediaTypes.js';
import { ortbConverter } from '../libraries/ortbConverter/converter.js';
import { triggerPixel, politeTriggerPixel, mergeDeep, replaceAuctionPrice } from '../src/utils.js';
import { triggerPixel, politeTriggerPixel, mergeDeep, replaceAuctionPrice, generateUUID } from '../src/utils.js';
import { getStorageManager } from '../src/storageManager.js';

const BIDDER_CODE = 'floxis';
const GVLID = 1609;
Expand All @@ -11,6 +12,12 @@
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 Down Expand Up @@ -88,6 +95,40 @@
};
}

function isValidFloxisId(id) {
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 @@ -120,8 +161,8 @@
try {
const floorInfo = bidRequest.getFloor({ currency: DEFAULT_CURRENCY, mediaType: '*', size: '*' });
if (floorInfo && typeof floorInfo.floor === 'number' && floorInfo.floor > 0) {
floor = floorInfo.floor;
floorCur = floorInfo.currency || DEFAULT_CURRENCY;

Check warning on line 165 in modules/floxisBidAdapter.js

View workflow job for this annotation

GitHub Actions / Coverage

164-165 lines are not covered with tests
}
} catch (e) { }
}
Expand Down Expand Up @@ -151,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
19 changes: 18 additions & 1 deletion 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 Expand Up @@ -109,4 +126,4 @@ pbjs.setConfig({
The adapter reports client-observed auction timeouts and bidder transport errors to Floxis as cookieless operational telemetry. Each beacon is a `keepalive` fetch sent with credentials omitted (no cookies) and scheduled off the auction's critical path, so it carries only the seat, region, event type, and relevant operational dimensions (HTTP status, timeout flag, duration, auction ID, publisher domain) — no user or device identifier is included. Consent signals are forwarded as opaque pass-through parameters where available. Each beacon fires at most once per distinct seat+region pair per event, and telemetry failures are silently suppressed so they never affect the auction lifecycle.

## Testing
Unit tests are provided in `test/spec/modules/floxisBidAdapter_spec.js` and cover validation, request building (params, host-label safety, floors, FPD/consent passthrough), response interpretation and meta mapping, user syncs, billing notifications, and error/timeout telemetry callbacks.
Unit tests are provided in `test/spec/modules/floxisBidAdapter_spec.js` and cover validation, request building (params, host-label safety, floors, FPD/consent passthrough, first-party fallback id), response interpretation and meta mapping, user syncs, billing notifications, and error/timeout telemetry callbacks.
140 changes: 139 additions & 1 deletion 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';

Expand Down Expand Up @@ -386,6 +386,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