1(async function () { 2 3 // ââ 0. Identity Hash Helpers (optional integration surface) ââ 4 function toHex(buffer) { 5 var bytes = new Uint8Array(buffer); 6 var hex = ''; 7 for (var i = 0; i < bytes.length; i++) { 8 hex += bytes[i].toString(16).padStart(2, '0'); 9 } 10 return hex; 11 } 12 13 function normalizeEmail(email) { 14 if (typeof email !== 'string') return null; 15 16 var trimmed = email.trim().toLowerCase(); 17 if (!trimmed) return null; 18 19 var at = trimmed.indexOf('@'); 20 if (at <= 0 || at === trimmed.length - 1) return null; 21 22 var local = trimmed.slice(0, at); 23 var domain = trimmed.slice(at + 1); 24 if (!local || !domain) return null; 25 26 // Gmail-style canonicalisation to reduce duplicate identities. 27 if (domain === 'gmail.com' || domain === 'googlemail.com') { 28 domain = 'gmail.com'; 29 var plus = local.indexOf('+'); 30 if (plus >= 0) local = local.slice(0, plus); 31 local = local.replace(/\./g, ''); 32 } 33 34 return local + '@' + domain; 35 } 36 37 function normalizePhoneE164(phone, defaultCountryCode) { 38 if (typeof phone !== 'string') return null; 39 40 var trimmed = phone.trim(); 41 if (!trimmed) return null; 42 43 // Drop common extension suffixes (x123, ext. 123). 44 trimmed = trimmed.replace(/(?:ext\.?|x)\s*\d+$/i, '').trim(); 45 if (!trimmed) return null; 46 47 // Convert international prefix (00...) to +... 48 if (trimmed.indexOf('00') === 0) trimmed = '+' + trimmed.slice(2); 49 50 var hasPlus = trimmed.charAt(0) === '+'; 51 var digits = trimmed.replace(/\D/g, ''); 52 if (!digits) return null; 53 54 if (hasPlus) return '+' + digits; 55 56 var cc = typeof defaultCountryCode === 'string' 57 ? defaultCountryCode.replace(/\D/g, '') 58 : ''; 59 if (cc) return '+' + cc + digits; 60 61 // Conservative fallback for NANP-only numbers when no country is provided. 62 if (digits.length === 11 && digits.charAt(0) === '1') return '+' + digits; 63 if (digits.length === 10) return '+1' + digits; 64 65 return null; 66 } 67 68 async function sha256Hex(input) { 69 if (typeof input !== 'string' || !input) return null; 70 if (!window.crypto || !window.crypto.subtle) return null; 71 72 var data = new TextEncoder().encode(input); 73 var digest = await window.crypto.subtle.digest('SHA-256', data); 74 return toHex(digest); 75 } 76 77 async function hashEmailSha256(email) { 78 var normalized = normalizeEmail(email); 79 if (!normalized) return null; 80 return sha256Hex(normalized); 81 } 82 83 async function hashPhoneSha256(phone, defaultCountryCode) { 84 var normalized = normalizePhoneE164(phone, defaultCountryCode); 85 if (!normalized) return null; 86 return sha256Hex(normalized); 87 } 88 89 function getOvid() { 90 try { 91 return localStorage.getItem('ovid') || null; 92 } catch (_) { 93 return null; 94 } 95 } 96 97 // Public helper surface for login providers (Audience.io, etc.). 98 // Example: await window.OneView.hashEmailSha256('[email protected]') 99 window.OneView = { 100 getOvid: getOvid, 101 normalizeEmail: normalizeEmail, 102 normalizePhoneE164: normalizePhoneE164, 103 sha256Hex: sha256Hex, 104 hashEmailSha256: hashEmailSha256, 105 hashPhoneSha256: hashPhoneSha256 106 }; 107 108 // ââ 1. Consent Detection ââââââââââââââââââââââââââââââââââââââ 109 function parseAdmiralCookie() { 110 var val = (document.cookie.match(/(?:^|;\s*)__adm_consent=([^;]*)/) || [])[1]; 111 if (!val) return null; 112 if (val === '1' || val === 'granted') return 'granted'; 113 if (val === '0' || val === 'denied' || val === 'opt_out') return 'opt_out'; 114 return null; 115 } 116 117 function getConsentStatus() { 118 // Prefer standards-based CMP signals when available. 119 // GPP API 120 try { 121 if (typeof window.__gppapi === 'function') { 122 return new Promise(function (resolve) { 123 window.__gppapi('ping', 1, function (pingData, success) { 124 if (!success || !pingData) { resolve(null); return; } 125 // Wait until the CMP has finished loading and captured a decision. 126 if (pingData.signalStatus !== 'ready') { resolve(null); return; } 127 // Signal is ready â consent has been expressed. Treat as granted; 128 // callers needing section-level opt-out should parse pingData.gppString. 129 resolve('granted'); 130 }); 131 }); 132 } 133 } catch (_) {} 134 135 // TCF API (EU/UK) 136 try { 137 if (typeof window.__tcfapi === 'function') { 138 return new Promise(function (resolve) { 139 window.__tcfapi('getTCData', 2, function (tcData, success) { 140 if (!success || !tcData) { resolve(null); return; } 141 // Conservative mapping: if CMP UI is shown and no positive signal, treat as opt-out. 142 resolve(tcData.gdprApplies && !tcData.tcString ? 'opt_out' : 'granted'); 143 }); 144 }); 145 } 146 } catch (_) {} 147 148 // USP API (legacy US Privacy) 149 try { 150 if (typeof window.__uspapi === 'function') { 151 return new Promise(function (resolve) { 152 window.__uspapi('getUSPData', 1, function (uspData, success) { 153 if (!success || !uspData || !uspData.uspString) { resolve(null); return; } 154 // CCPA string example: 1YNN => user opted out of sale/sharing. 155 resolve(uspData.uspString.charAt(2) === 'Y' ? 'opt_out' : 'granted'); 156 }); 157 }); 158 } 159 } catch (_) {} 160 161 // Fallback for current Admiral deployments. 162 return Promise.resolve(parseAdmiralCookie()); 163 } 164 165 // ââ 2. Privacy Gate âââââââââââââââââââââââââââââââââââââââââââ 166 // Global Privacy Control (browser-level) 167 if (navigator.globalPrivacyControl) { 168 localStorage.removeItem('ovid'); // clear any stale identity 169 return; 170 } 171 172 // CMP consent from standards APIs (fallback to Admiral cookie) 173 var cmpConsent = await getConsentStatus();
174 if (cmpConsent === 'opt_out' || cmpConsent === 'denied') { 175 localStorage.removeItem('ovid'); // clear any stale identity 176 return; 177 } 178 179 // ââ 3. Link Decoration (Linker Utility â see §6) ââââââââââââââ 180 // Only runs after consent is confirmed. Passing a cross-site identifier 181 // (?ov_id=) to another domain without consent would be cross-site tracking 182 // against the user's expressed preference. 183 // OV_NETWORK is generated at startup from Api/config/network_sites.yaml by 184 // ClientScriptCache. The fallback list inside the sentinel is only used in 185 // unit tests / when the script is loaded without server substitution. 186 var OV_NETWORK = new Set(/*<OV_NETWORK_HOSTS>*/["ibelieve.com","wava.com","560theanswer.com","hotair.com","kdow.biz","twincitiesbusinessradio.com","money1055.com","983fmtheword.com","faithtalk995.com","kfax.com","thewordsacramento.com","thewordfm1007.com","thewordseattle.com","kkht.com","kkla.com","am980themission.com","truetalk800.com","kpdq.com","kprz.com","faithtalk1360.com","990amtheword.com","947fmtheword.com","am630theword.com","thewordfm.com","familyvaluesradio1010.com","wavaam.com","wezeradio.com","wfil.com","thewordcleveland.com","faithtalkdetroit.com","wmca.com","faithtalk970.com","wordfm.com","wpitradio.com","thewordcolumbus.com","wrolradio.com","letstalkfaith.com","thewordorlando.com","1160hope.com","familytalktoday.com","theanswersandiego.com","1011fmtheanswer.com","960thepatriot.com","theanswerseattle.com","930amtheanswer.com","am1070theanswer.com","710knus.com","am870theanswer.com","660amtheanswer.com","am590theanswer.com","am1460theanswer.com","theanswerdetroit.com","am920theanswer.com","theanswertampa.com","whkradio.com","theanswersarasota.com","990theanswer.com","am970theanswer.com","theanswerorlando.com","theanswerpgh.com","am570theanswer.com","989theanswer.com","am1280thepatriot.com","860amtheanswer.com","959columbus.com","faithtalk590.com","am1380theanswer.com","freedom1570.com","931elrey.com","theanswerportland.com","halfofftuitions.com","salempodcastnetwork.com","gospel1190.com","kdia.com","lapoderosa.com","lapatronaseattle.com","thewordmiami.com","oneplace.com","godtube.com","biblestudytools.com","crosswalk.com","christianity.com"]/*</OV_NETWORK_HOSTS>*/); 187 188 function normalizeHost(hostname) { 189 if (!hostname) return ''; 190 var h = hostname.toLowerCase(); 191 return h.indexOf('www.') === 0 ? h.slice(4) : h; 192 } 193 194 function decorateLink(e) { 195 var link = e.target.closest('a'); 196 if (!link || !link.href) return; 197 try { 198 var url = new URL(link.href); 199 var currentOvid = localStorage.getItem('ovid'); 200 var targetHost = normalizeHost(url.hostname); 201 var pageHost = normalizeHost(location.hostname); 202 // Only decorate cross-site links to other network sites. Same-site links 203 // are identified via the HttpOnly cookie the Nexus already set on this 204 // domain, so adding ?ov_id= is redundant and pollutes the URL bar, 205 // browser history, and server access logs. Mirrors GA4 linker behavior. 206 if (targetHost !== pageHost && OV_NETWORK.has(targetHost) && currentOvid) { 207 url.searchParams.set('ov_id', currentOvid); 208 link.href = url.toString(); 209 } 210 } catch (_) { 211 // Ignore invalid URLs 212 } 213 } 214 215 document.addEventListener('mousedown', decorateLink); 216 document.addEventListener('touchstart', decorateLink); 217 218 // ââ 4. Sync âââââââââââââââââââââââââââââââââââââââââââââââââââ 219 // Build headers â forward CMP consent and linker ID (if present) to the Nexus. 220 var headers = {}; 221 if (cmpConsent) headers['X-Consent-Status'] = cmpConsent; 222 var localStorageOvid = localStorage.getItem('ovid'); 223 224 // If arriving via a decorated link from another network site, the ?ov_id= 225 // parameter carries the visitor's OVID from the originating site. Forward it 226 // as X-OV-ID so the Nexus can recognise this as a known visitor rather than 227 // generating a new OVID. The Nexus validates it is a well-formed UUID before 228 // trusting it. The cookie (set by the Nexus response) then takes over for all 229 // future visits on this domain. 230 var urlOvId = new URLSearchParams(location.search).get('ov_id'); 231 var syncUrl = '/lib/ov-sync'; 232 if (urlOvId) { 233 headers['X-OV-ID'] = urlOvId; 234 // Capture the source site hostname from the browser's referrer so the 235 // Nexus can record the full AâB linker flow without requiring a proxy 236 // contract change (query params are forwarded transparently by reverse proxies). 237 var ref = document.referrer; 238 if (ref) {
239 try { 240 var fromHost = new URL(ref).hostname; 241 // Only record cross-site arrivals â skip if the referrer is the same 242 // domain (e.g. a visitor who navigates internally while ov_id is still 243 // in the URL from a prior cross-site click). 244 if (fromHost && fromHost !== location.hostname) 245 syncUrl = '/lib/ov-sync?ov_from_host=' + encodeURIComponent(fromHost); 246 } catch (_) {} 247 } 248 } 249 if (!urlOvId && localStorageOvid) headers['X-OV-LS-ID'] = localStorageOvid; 250 251 // Early-exit if this tab already synced in the current browser session. 252 // Do not rely on reading the ovid cookie from document.cookie because 253 // production sets it as HttpOnly, which is not visible to client JS. 254 var alreadySynced = sessionStorage.getItem('ov_session_sent'); 255 if (alreadySynced) return; 256 257 // Cross-tab dedupe: prevent immediate duplicate session_start events when 258 // users open multiple same-site tabs in a short window. 259 // Uses a fixed GA-style window of 30 minutes. 260 var SESSION_START_DEDUPE_MS = 30 * 60 * 1000; 261 var SESSION_START_STATE_KEY = 'ov_session_state_v1'; 262 263 function readSessionState() { 264 try { 265 return JSON.parse(localStorage.getItem(SESSION_START_STATE_KEY) || '{}'); 266 } catch (_) { 267 return {}; 268 } 269 } 270 271 function writeSessionState(state) { 272 try { 273 localStorage.setItem(SESSION_START_STATE_KEY, JSON.stringify(state)); 274 } catch (_) { 275 // Ignore storage quota or access errors. 276 } 277 } 278 279 function shouldSendSessionStart(host, ovid, nowMs) { 280 var state = readSessionState(); 281 var last = state[host]; 282 if (!last || typeof last.lastStartMs !== 'number') return true; 283 284 // New OVID on the same host should create a fresh session immediately. 285 if (last.lastOvid && ovid && last.lastOvid !== ovid) return true; 286 287 return nowMs - last.lastStartMs >= SESSION_START_DEDUPE_MS; 288 } 289 290 function markSessionStart(host, ovid, nowMs) { 291 var state = readSessionState(); 292 state[host] = { 293 lastStartMs: nowMs, 294 lastOvid: ovid || null 295 }; 296 writeSessionState(state); 297 } 298 299 function getSameSiteLinkFromEvent(e) { 300 var link = e.target && e.target.closest ? e.target.closest('a') : null; 301 if (!link || !link.href) return null; 302 303 try { 304 var url = new URL(link.href, location.href); 305 if (url.hostname !== location.hostname) return null; 306 return { link: link, url: url }; 307 } catch (_) { 308 return null; 309 } 310 } 311 312 function isLikelyNewTabIntent(e, link) { 313 if (!e || !link) return false; 314 315 if ((link.target || '').toLowerCase() === '_blank') return true; 316 if (e.metaKey || e.ctrlKey) return true; 317 if (e.shiftKey) return true; 318 if (e.type === 'auxclick' && e.button === 1) return true; 319 320 return false; 321 } 322 323 function handleLikelySameSiteNewTab(e) { 324 var linkInfo = getSameSiteLinkFromEvent(e); 325 if (!linkInfo) return; 326 if (!isLikelyNewTabIntent(e, linkInfo.link)) return; 327 328 var nowMs = Date.now(); 329 var host = location.hostname; 330 var sessionOvid = localStorage.getItem('ovid') || null; 331 markSessionStart(host, sessionOvid, nowMs); 332 } 333 334 // Supplemental signal: when users intentionally open same-site links in a 335 // new tab/window, refresh the shared timer so the landing tab does not emit 336 // an immediate duplicate session_start. 337 document.addEventListener('click', handleLikelySameSiteNewTab, true); 338 document.addEventListener('auxclick', handleLikelySameSiteNewTab, true); 339 340 // ââ 4b. GAM Audience Targeting ââââââââââââââââââââââââââââââââ 341 // Apply the server-resolved anonymous-audience key-values to Google Ad 342 // Manager. The values originate from BigQuery segment views, are published 343 // into Firestore/Redis, and returned on the sync response. We push onto the 344 // GPT command queue so this is safe whether GPT has loaded yet or not â the 345 // commands run as soon as googletag is ready, before the first ad request 346 // when ov.js is placed in the page head ahead of slot definitions. 347 function applyGamTargeting(gamKeyValues) { 348 if (!gamKeyValues || typeof gamKeyValues !== 'object') return; 349 var keys = Object.keys(gamKeyValues); 350 if (keys.length === 0) return; 351 window.googletag = window.googletag || {}; 352 window.googletag.cmd = window.googletag.cmd || []; 353 window.googletag.cmd.push(function () {
354 try { 355 var pubads = window.googletag.pubads(); 356 for (var i = 0; i < keys.length; i++) { 357 var key = keys[i]; 358 var value = gamKeyValues[key]; 359 // GPT accepts a string or an array of strings. 360 pubads.setTargeting(key, value); 361 } 362 } catch (e) { /* GPT unavailable or slots already rendered â ignore */ } 363 }); 364 } 365 366 // ââ 5. Engagement Gate ââââââââââââââââââââââââââââââââââââââââ 367 // Defer the sync until the visitor shows engagement: scroll, click, or 5s 368 // dwell. Headless bots and instant bounces never trigger â no API call, 369 // no Firestore write, no Cloud Run billing for non-human traffic. 370 // Note: the server-side unconfirmed-OVID deferral still applies on top of 371 // this â genuine first-time visitors won't write to Firestore until visit 2. 372 var syncFired = false; 373 function doSync() { 374 if (syncFired) return; 375 syncFired = true; 376 // ENG-051: a unique URL + no-store defeats partner-side caches that would 377 // hand one visitor's OVID to everyone in the cache window, and IIS URL 378 // Rewrite's per-URL rule cache that replays one visitor's forwarded headers. 379 var bustedUrl = syncUrl + (syncUrl.indexOf('?') < 0 ? '?' : '&') + '_ovr=' + 380 Date.now().toString(36) + Math.random().toString(36).slice(2, 10); 381 fetch(bustedUrl, { credentials: 'include', headers: headers, cache: 'no-store' }) 382 .then(function (r) { return r.json(); }) 383 .then(function (data) { 384 if (data.status === 'opted_out') return; // server-side backstop 385 if (data.ovid) localStorage.setItem('ovid', data.ovid); 386 sessionStorage.setItem('ov_session_sent', '1'); 387 388 // Apply anonymous-audience targeting to GAM (no-op when absent). 389 applyGamTargeting(data.gamKeyValues); 390 391 // ââ 5. Session Start âââââââââââââââââââââââââââââââââââââ 392 // Fire only when the host-level dedupe window has elapsed. 393 // This avoids counting immediate same-site new-tab opens as new sessions. 394 var nowMs = Date.now(); 395 var host = location.hostname; 396 var sessionOvid = data.ovid || localStorage.getItem('ovid') || null; 397 if (shouldSendSessionStart(host, sessionOvid, nowMs)) { 398 var payload = JSON.stringify({ 399 event: 'session_start', 400 ovid: sessionOvid, 401 ts: nowMs, 402 sessionProof: data.sessionProof || null 403 }); 404 405 // ENG-051: unique URL per call; IIS URL Rewrite caches rule results (incl. forwarded 406 // header values) per URL, which leaked one visitor's headers to the next. 407 var sessionStartUrl = '/lib/ov-session-start?_ovr=' + 408 Date.now().toString(36) + Math.random().toString(36).slice(2, 10); 409 410 if (navigator.sendBeacon) { 411 // Wrap in Blob to set Content-Type: application/json. 412 // sendBeacon with a plain string sends text/plain, which ASP.NET Core 413 // minimal APIs won't bind to the SessionStartRequest model (returns 415). 414 var beacon = new Blob([payload], { type: 'application/json' }); 415 var sent = navigator.sendBeacon(sessionStartUrl, beacon); 416 if (!sent) { 417 // UA queue was full; fall back to fetch so the event is not silently dropped. 418 fetch(sessionStartUrl, { 419 method: 'POST', 420 headers: { 'Content-Type': 'application/json' }, 421 body: payload, 422 keepalive: true 423 }); 424 } 425 } else { 426 fetch(sessionStartUrl, { 427 method: 'POST', 428 headers: { 'Content-Type': 'application/json' }, 429 body: payload, 430 keepalive: true 431 }); 432 } 433 434 markSessionStart(host, sessionOvid, nowMs); 435 } 436 }); 437 } 438 439 document.addEventListener('scroll', doSync, { once: true, passive: true });
440 document.addEventListener('click', doSync, { once: true }); 441 setTimeout(doSync, 5000); 442})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.