diff --git a/README.md b/README.md index e0f08b3d..90c8ae16 100644 --- a/README.md +++ b/README.md @@ -918,6 +918,32 @@ The Optable Web SDK provides a method `installGPTSecureSignals` to pass user-def Please refer to the list of approved Secure Signal [providers](https://support.google.com/admanager/answer/14750072). Please refer to the provider's integration documentation for the exact provider name and value to pass as a signal. +To build the signals from cached EIDs instead of by hand, `secureSignalsFromEids(eids, filter?)` builds one `{ provider, id }` pair per EID source — GPT keys secure signals by provider, so the first EID that passes the filter and carries a non-empty uid wins its source — optionally filtered by `sources`, `inserters` or `matchers` (an empty or missing list means no constraint on that field). + +The signals come from the last cached targeting response, so a first visit installs none until `targeting()` has resolved once. + +With the npm package: + +```javascript +import { secureSignalsFromEids } from "@optable/web-sdk/lib/dist/core/secure-signals"; + +const eids = sdk.targetingFromCache()?.ortb2?.user?.eids ?? []; +sdk.installGPTSecureSignals(...secureSignalsFromEids(eids, { sources: ["uidapi.com"] })); +``` + +With the SDK loaded from a script tag, the helper is available under `optable.utils`: + +```html + + +``` + You can verify the signal was correctly passed to GAM by searching for its value cached in `localStorage` under the key `_GESPSK-`. ## Integrating Prebid diff --git a/browser/sdk.ts b/browser/sdk.ts index d56266e6..731f8b4f 100644 --- a/browser/sdk.ts +++ b/browser/sdk.ts @@ -1,5 +1,6 @@ import Commands from "./commands"; import { resolveMultiNodeTargeting } from "../lib/core/resolvers/resolveMultiTargeting"; +import { secureSignalsFromEids } from "../lib/core/secure-signals"; import OptableSDK, { InitConfig } from "../lib/sdk"; import OptablePrebidAnalytics from "../lib/addons/prebid/analytics"; @@ -28,7 +29,7 @@ window.optable = window.optable || {}; window.optable.SDK = OptableSDK; window.optable.OptablePrebidAnalytics = OptablePrebidAnalytics; window.optable.cmd = new Commands(window.optable.cmd || []); -window.optable.utils = { resolveMultiNodeTargeting }; +window.optable.utils = { resolveMultiNodeTargeting, secureSignalsFromEids }; if (window.optable.instance_config) { window.optable.instance = new OptableSDK(window.optable.instance_config); diff --git a/lib/addons/gpt.ts b/lib/addons/gpt.ts index f059b197..ba450e20 100644 --- a/lib/addons/gpt.ts +++ b/lib/addons/gpt.ts @@ -1,12 +1,13 @@ import type { ContextualTargetingKeyValuesOptions } from "../edge/contextual_segments"; import type { WitnessProperties } from "../edge/witness"; +import type { SecureSignal } from "../core/secure-signals"; import { debugLog } from "../core/log"; import OptableSDK from "../sdk"; declare module "../sdk" { export interface OptableSDK { installGPTEventListeners: () => void; - installGPTSecureSignals: () => void; + installGPTSecureSignals: (...signals: SecureSignal[]) => void; } } @@ -86,7 +87,7 @@ OptableSDK.prototype.installGPTEventListeners = function (eventSpec?: GptEventSp /* * Pass user-defined signals to GAM Secure Signals */ -OptableSDK.prototype.installGPTSecureSignals = function (...signals: Array<{ provider: string; id: string }>) { +OptableSDK.prototype.installGPTSecureSignals = function (...signals: SecureSignal[]) { window.googletag = window.googletag || { cmd: [] }; const gpt = window.googletag; diff --git a/lib/core/secure-signals.test.ts b/lib/core/secure-signals.test.ts new file mode 100644 index 00000000..02908881 --- /dev/null +++ b/lib/core/secure-signals.test.ts @@ -0,0 +1,66 @@ +import { secureSignalsFromEids } from "./secure-signals"; + +const EIDS = [ + { source: "uidapi.com", matcher: "m1", inserter: "optable", uids: [{ id: "uid2-a" }, { id: "uid2-b" }] }, + { source: "id5-sync.com", matcher: "m2", uids: [{ id: "id5-a" }] }, + { source: "liveramp.com", uids: [] }, +]; + +describe("secureSignalsFromEids", () => { + it("emits one signal per source, taking the first non-empty uid", () => { + expect(secureSignalsFromEids(EIDS)).toEqual([ + { provider: "uidapi.com", id: "uid2-a" }, + { provider: "id5-sync.com", id: "id5-a" }, + ]); + }); + + it("keeps the first EID when a source appears twice", () => { + const dup = [ + { source: "pair.optable.co", uids: [{ id: "pair-1" }] }, + { source: "pair.optable.co", uids: [{ id: "pair-2" }] }, + ]; + expect(secureSignalsFromEids(dup)).toEqual([{ provider: "pair.optable.co", id: "pair-1" }]); + }); + + it("falls through to a later EID of the same source when the first is empty or filtered out", () => { + expect( + secureSignalsFromEids([ + { source: "s.com", uids: [{ id: "" }] }, + { source: "s.com", uids: [{ id: "second" }] }, + ]) + ).toEqual([{ provider: "s.com", id: "second" }]); + expect( + secureSignalsFromEids( + [ + { source: "s.com", inserter: "other", uids: [{ id: "first" }] }, + { source: "s.com", inserter: "optable", uids: [{ id: "second" }] }, + ], + { inserters: ["optable"] } + ) + ).toEqual([{ provider: "s.com", id: "second" }]); + }); + + it("filters by source, inserter and matcher", () => { + expect(secureSignalsFromEids(EIDS, { sources: ["id5-sync.com"] })).toEqual([ + { provider: "id5-sync.com", id: "id5-a" }, + ]); + expect(secureSignalsFromEids(EIDS, { inserters: ["optable"] }).map((s) => s.provider)).toEqual(["uidapi.com"]); + expect(secureSignalsFromEids(EIDS, { matchers: ["m2"] })).toEqual([{ provider: "id5-sync.com", id: "id5-a" }]); + }); + + it("requires every provided filter to match", () => { + expect(secureSignalsFromEids(EIDS, { sources: ["uidapi.com"], matchers: ["m2"] })).toEqual([]); + }); + + it("treats an empty filter list as no constraint", () => { + expect(secureSignalsFromEids(EIDS, { sources: [] })).toHaveLength(2); + }); + + it("skips uids without an id and tolerates empty input", () => { + expect(secureSignalsFromEids([{ source: "a.com", uids: [{ id: "" }, {}] }])).toEqual([]); + expect(secureSignalsFromEids([{ source: "a.com", uids: [{ id: "" }, { id: "real" }] }])).toEqual([ + { provider: "a.com", id: "real" }, + ]); + expect(secureSignalsFromEids([])).toEqual([]); + }); +}); diff --git a/lib/core/secure-signals.ts b/lib/core/secure-signals.ts new file mode 100644 index 00000000..725710d3 --- /dev/null +++ b/lib/core/secure-signals.ts @@ -0,0 +1,41 @@ +import type { EID, UID } from "iab-openrtb/v26"; + +// Converts cached EIDs into GPT secure-signal pairs for +// installGPTSecureSignals, optionally filtered by source, inserter or +// matcher. An empty or missing filter list means no constraint on that field. +// +// One signal per source: GPT keys secure signals by provider, so registering +// a provider twice drops all but one id. The first EID that passes the filter +// and carries a non-empty uid wins its source. + +// Cached EIDs are parsed from storage, so ORTB's required fields may be +// absent at runtime. Derived from EID so new or renamed ORTB fields flow through. +type SecureSignalEid = Partial> & Pick & { uids?: Array, "id">> }; + +type SecureSignalsFilter = { + sources?: string[]; + inserters?: string[]; + matchers?: string[]; +}; + +type SecureSignal = { provider: string; id: string }; + +export function secureSignalsFromEids(eids: SecureSignalEid[], filter: SecureSignalsFilter = {}): SecureSignal[] { + const allows = (list: string[] | undefined, value: string | undefined) => !list?.length || list.includes(value ?? ""); + + const bySource = new Map(); + eids.forEach((eid) => { + if (bySource.has(eid.source)) return; + if (!allows(filter.sources, eid.source)) return; + if (!allows(filter.inserters, eid.inserter)) return; + if (!allows(filter.matchers, eid.matcher)) return; + + const uid = eid.uids?.find((u) => u.id); + if (uid?.id) { + bySource.set(eid.source, { provider: eid.source, id: uid.id }); + } + }); + return [...bySource.values()]; +} + +export type { SecureSignalEid, SecureSignalsFilter, SecureSignal };