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 };