1/* OnePanel browser-signal collector. 2 * Same-origin, external, and byte-constant. 3 * Token read from tag data-osh-ct or fetched from /_osh/ct. 4 * Ordinary injected pages also carry a server-owned page RID in a preceding meta element. 5 * Two emissions, not one: 6 * ready -- once after load, target within ~1 s. Coarse browser-consistency fields and 7 * explicit capability/missing states only. No behavioural data, no PoW: those are 8 * not known yet this early. 9 * summary -- exactly once, at the earliest of SUMMARY_DWELL_MS / pagehide / hidden. Bucketed 10 * behaviour, visibility, pointer/scroll and PoW evidence. 11 * 12 * /_osh/ct answers either shape and both are handled: 13 * v1 (live today): {"ct": "...", "nonce": "...", "difficulty": N} 14 * -- one credential. The server accepts one ready and one summary POST per 15 * token; the phase header selects the server-side one-shot slot. 16 * v2 (minted by /_osh/ct): 17 * {"pageview_ref": "...", "credentials": {"ready": {...}, "summary": {...}}} 18 * -- two credentials, each bound to its own phase and originating page RID. 19 * -- v2 is live; the widget accepts it alongside cached legacy v1 tags. 20 */ 21(function () { 22 "use strict"; 23 // The catch (e) parameter is required: `catch {}` is ES2019, but this file must parse on ES5 engines. 24 // A legacy-engine parse failure drops the whole bundle and beacon on every page, not just challenge pages. 25 // `e` is intentionally unused: every catch fails open silently; this widget must never break a customer page. 26 var self = document.currentScript; 27 if (!self) return; 28 var pageRequestId = null; 29 var pageCapability = null; 30 try { 31 // The RID belongs to this script only when the injected meta is its immediately preceding 32 // element. Never query the document globally: customer markup can carry arbitrary lookalike 33 // metadata from another request or application.
34 var ridContext = self.previousElementSibling; 35 if (!ridContext && self.previousSibling) { 36 ridContext = self.previousSibling; 37 while (ridContext && ridContext.nodeType === 3 && /^\s*$/.test(ridContext.nodeValue || "")) { 38 ridContext = ridContext.previousSibling; 39 } 40 } 41 if (ridContext && typeof ridContext.getAttribute === "function" 42 && typeof ridContext.tagName === "string" 43 && ridContext.tagName.toLowerCase() === "meta") { 44 pageRequestId = ridContext.getAttribute("data-osh-rid"); 45 pageCapability = ridContext.getAttribute("data-osh-rid-cap"); 46 } 47 } catch (e) {} 48 49 var CSS_PIXELS = [0, 320, 480, 640, 768, 1024, 1280, 1440, 1920, 2560, 3840, 65535]; 50 var LOGICAL_CPU = [0, 1, 2, 4, 8, 16, 32, 64, 128, 65535]; 51 var HOUR = [-24, -12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 24]; 52 var COUNT_BUCKET = [0, 1, 2, 4, 8, 16, 32, 64, 128, 256, 65535]; 53 var PERCENT_BUCKET = [0, 10, 25, 50, 75, 90, 100, 65535]; 54 var MILLISECONDS = [0, 8, 16, 32, 64, 128, 256, 512, 1000, 2000, 4000, 8000, 16000, 30000, 60000, 65535]; 55 56 var POW_BUDGET_MS = 400; 57 // The summary emission's own earliest-trigger window. 15 s, well inside the 300 s 58 // TOKEN_TTL_SECS margin in collect_token.rs, so expiry/clock-skew is not a practical concern at 59 // this dwell -- unlike the old single-emission 240 s design, which existed only to leave that 60 // margin. Splitting emission means the summary no longer has to wait out most of the page's 61 // life to be useful. 62 var SUMMARY_DWELL_MS = 15000; 63 var MAX_POW_ITERATIONS = 500000; 64 65 function bucketUp(ladder, value) { 66 if (typeof value !== "number" || !isFinite(value) || value < 0) return null; 67 for (var i = 0; i < ladder.length; i++) { 68 if (value <= ladder[i]) return ladder[i]; 69 } 70 return ladder[ladder.length - 1]; 71 } 72 73 function nearestAllowed(ladder, value) { 74 if (typeof value !== "number" || !isFinite(value)) return null; 75 return ladder.indexOf(value) === -1 ? null : value; 76 } 77 78 function hexToBytes(hex) { 79 if (typeof hex !== "string" || hex.length % 2 !== 0) return null; 80 var bytes = new Uint8Array(hex.length / 2); 81 for (var i = 0; i < hex.length; i += 2) { 82 var v = parseInt(hex.substr(i, 2), 16); 83 if (isNaN(v)) return null; 84 bytes[i / 2] = v; 85 } 86 return bytes; 87 } 88 89 function strToBytes(str) { 90 var b = new Uint8Array(str.length); 91 for (var i = 0; i < str.length; i++) b[i] = str.charCodeAt(i); 92 return b; 93 } 94 95 function concat(a, b) { 96 var c = new Uint8Array(a.length + b.length); 97 c.set(a, 0); 98 c.set(b, a.length); 99 return c; 100 } 101 102 function leadingZeroBits(buf) { 103 var bytes = new Uint8Array(buf); 104 var bits = 0; 105 for (var i = 0; i < bytes.length; i++) { 106 var b = bytes[i]; 107 if (b === 0) { bits += 8; continue; } 108 bits += Math.clz32(b) - 24; 109 break; 110 } 111 return bits; 112 } 113 114 // Two guards, not one: ready and summary are independent lifecycle stages with independent 115 // exactly-once contracts. A late summary trigger (a second `hidden` transition, say) must not 116 // resend, and neither emission's guard affects the other's. 117 var readySent = false; 118 var summarySent = false; 119 120 // Yield to the event loop between batches WITHOUT waiting for the browser to feel idle. 121 // 122 // This used to be `requestIdleCallback(step, { timeout: 100 })`, and that call is the reason the 123 // proof almost never finished. An idle callback runs when the main thread is idle, and during page 124 // load it is not â so each batch waited out its 100 ms timeout instead. A few batches and the whole 125 // 400 ms budget is gone with barely any hashing done. The fallback branch was no better in kind: 126 // browsers clamp a nested `setTimeout(..., 0)` to ~4 ms, which over the hundreds of batches an 127 // 18-bit target needs is seconds of pure scheduling. 128 // 129 // Measured on the fleet 2026-08-19, solve rate among clients that attempted: iOS 79.6% (Safari, 130 // which has no `requestIdleCallback` and therefore took the clamped-timeout path), Windows 7.9% and 131 // Android 4.4% (Chrome, which has it and took the idle path). An 18x spread on a signal that is 132 // supposed to be about automation. Note the two variables are collinear in that data â Safari never 133 // has the API and Chrome always does â so this change is what separates them: if the gap closes, 134 // scheduling was the cause rather than device speed. 135 // 136 // `MessageChannel` is the yield with no clamp and no idle requirement: posting to a port queues a 137 // macrotask that runs on the next turn, so rendering and input still interleave and the tab stays 138 // responsive, but a batch waits microseconds rather than up to 100 ms. Supported everywhere the 139 // widget already requires WebCrypto. `setTimeout` remains only as a last resort. 140 var yieldThenStep = (function () { 141 if (typeof window.MessageChannel === "function") { 142 var pending = null; 143 var channel = new MessageChannel(); 144 channel.port1.onmessage = function () { 145 var fn = pending; 146 pending = null; 147 if (fn) fn(); 148 }; 149 return function (fn) { 150 pending = fn; 151 channel.port2.postMessage(0); 152 }; 153 } 154 return function (fn) { setTimeout(fn, 0); }; 155 })(); 156 157 function computePoW(nonceHex, difficulty, onDone) { 158 try { 159 var subtle = window.crypto && window.crypto.subtle; 160 if (!subtle || typeof subtle.digest !== "function") { 161 onDone(null, null); 162 return; 163 } 164 var nonceBytes = hexToBytes(nonceHex); 165 if (!nonceBytes || typeof difficulty !== "number" || difficulty <= 0 || difficulty > 32) { 166 onDone(null, null); 167 return; 168 } 169 var startTime = (window.performance && window.performance.now) ? performance.now() : Date.now(); 170 var counter = 0; 171 var batchSize = 128; 172 173 function step() { 174 if (summarySent) { 175 onDone(null, null); 176 return; 177 } 178 var now = (window.performance && window.performance.now) ? performance.now() : Date.now(); 179 if (now - startTime >= POW_BUDGET_MS || counter >= MAX_POW_ITERATIONS) { 180 onDone(null, Math.max(0, Math.round(now - startTime)), true); 181 return; 182 } 183 var promises = []; 184 var candidates = []; 185 for (var i = 0; i < batchSize; i++) { 186 var cand = (counter++).toString(36); 187 candidates.push(cand); 188 promises.push(subtle.digest("SHA-256", concat(nonceBytes, strToBytes(cand)))); 189 } 190 Promise.all(promises).then(function (digests) { 191 if (summarySent) { 192 onDone(null, null); 193 return; 194 } 195 for (var j = 0; j < digests.length; j++) { 196 if (leadingZeroBits(digests[j]) >= difficulty) { 197 var endTime = (window.performance && window.performance.now) ? performance.now() : Date.now(); 198 onDone(candidates[j], Math.max(0, Math.round(endTime - startTime))); 199 return; 200 } 201 } 202 var cur = (window.performance && window.performance.now) ? performance.now() : Date.now(); 203 if (cur - startTime >= POW_BUDGET_MS || counter >= MAX_POW_ITERATIONS) { 204 onDone(null, Math.max(0, Math.round(cur - startTime)), true); 205 return; 206 } 207 yieldThenStep(step); 208 }).catch(function () { 209 onDone(null, null); 210 }); 211 } 212 213 // The FIRST batch goes through the same yield, for the same reason: an idle callback here 214 // could spend 100 ms of a 400 ms budget before a single hash was computed. 215 yieldThenStep(step); 216 } catch (e) { 217 onDone(null, null); 218 } 219 } 220 221 // Sent with `ready`: coarse, static, browser-consistency fields plus explicit capability/missing 222 // states. Nothing here depends on how the visitor behaves on the page -- everything is knowable 223 // the instant the script runs, which is what makes an early, one-shot emission honest. 224 var readySignals = {}; 225 var powSolution = null; 226 var powGaveUp = false;
227 // 4 = the MessageChannel yield plus the TTL-safe dwell. The number exists so a cohort can identify ITSELF in the stored 228 // rows: with two builds live at once, `platform_family` told them apart, but a third build that 229 // also sends it needs its own marker or the comparison collapses. `widget_version` is declared as 230 // an open integer (0..65535), not an enum, so raising it costs no contract change and no consumer 231 // redeploy â checked before making the change, because an unknown enum value is rejected per-event 232 // and silently. 233 // 234 // 1 pre-PoW collector 235 // 2 inline PoW, 18 then 15 bits, yielding through requestIdleCallback 236 // 3 same, yielding through MessageChannel 237 // 4 same, waiting for the TTL-safe dwell before sending 238 // 5 split into a `ready` emission (~1 s after load) and a `summary` emission (15 s / 239 // pagehide / hidden, whichever is earliest) -- what used to be one POST per pageview is 240 // now up to two, and a reader joining rows by pageview must expect that shape 241 // 242 // Raise it whenever a change alters what the numbers MEAN, not on every edit: a version nobody can 243 // map to a behaviour is worse than no version. 244 readySignals.widget_version = 5; 245 246 // Declared in the contract since v1 and never sent by any widget, so `platform_family` read unset 247 // on every stored row and the "is a failed proof just an old browser" check had nothing to group 248 // by. Both are coarse by construction: eight platform families and one boolean add no identifying 249 // entropy beyond what the User-Agent already states. 250 try { 251 readySignals.idle_callback_available = typeof window.requestIdleCallback === "function"; 252 } catch (e) {} 253 try { 254 var uaData = navigator.userAgentData; 255 var plat = (uaData && typeof uaData.platform === "string" && uaData.platform) || 256 (typeof navigator.platform === "string" ? navigator.platform : ""); 257 var ua = typeof navigator.userAgent === "string" ? navigator.userAgent : ""; 258 var hay = (plat + " " + ua).toLowerCase(); 259 var family = "unknown"; 260 if (hay.indexOf("cros") !== -1) family = "chromeos"; 261 else if (hay.indexOf("android") !== -1) family = "android"; 262 // iPadOS reports itself as a Mac, so the touch-point count is what separates them. 263 else if (hay.indexOf("iphone") !== -1 || hay.indexOf("ipad") !== -1 || hay.indexOf("ipod") !== -1) family = "ios"; 264 else if (hay.indexOf("mac") !== -1) { 265 family = (navigator.maxTouchPoints && navigator.maxTouchPoints > 1) ? "ios" : "macos"; 266 } else if (hay.indexOf("win") !== -1) family = "windows"; 267 else if (hay.indexOf("linux") !== -1 || hay.indexOf("x11") !== -1) family = "linux"; 268 else if (hay !== "") family = "other"; 269 readySignals.platform_family = family; 270 } catch (e) {} 271 272 try { 273 var width = window.screen && window.screen.width; 274 var w = bucketUp(CSS_PIXELS, width); 275 if (w !== null) readySignals.screen_width_bucket = w; 276 } catch (e) {} 277 278 try { 279 var hc = bucketUp(LOGICAL_CPU, navigator.hardwareConcurrency); 280 if (hc !== null) readySignals.hardware_concurrency_bucket = hc; 281 } catch (e) {} 282 283 try { 284 var offset = new Date().getTimezoneOffset(); 285 if (typeof offset === "number" && isFinite(offset)) { 286 var hours = nearestAllowed(HOUR, Math.round(-offset / 60)); 287 if (hours !== null) readySignals.timezone_offset_bucket = hours; 288 } 289 } catch (e) {} 290 291 // Sent with `summary`: bucketed behaviour accumulated over the page's life, plus PoW evidence. 292 // Populated at send time by `collectBehavioral()` and by the PoW callback -- never before, 293 // because both depend on how long the page has been open. 294 var summarySignals = {}; 295 296 var pointerEvents = 0; 297 var maxScrollPercent = 0; 298 var visibilityChanges = 0; 299 300 try { 301 var onPointer = function () { pointerEvents++; }; 302 window.addEventListener("pointerdown", onPointer, { passive: true }); 303 window.addEventListener("touchstart", onPointer, { passive: true }); 304 window.addEventListener("mousemove", onPointer, { passive: true }); 305 } catch (e) {} 306 307 try { 308 var onScroll = function () { 309 var docEl = document.documentElement || document.body; 310 if (!docEl) return; 311 var total = docEl.scrollHeight - docEl.clientHeight; 312 if (total > 0) { 313 var pct = Math.round(((window.scrollY || docEl.scrollTop || 0) / total) * 100); 314 if (pct > maxScrollPercent) maxScrollPercent = pct; 315 } 316 }; 317 window.addEventListener("scroll", onScroll, { passive: true }); 318 } catch (e) {} 319 320 try { 321 var onVis = function () { 322 if (document.hidden) visibilityChanges++; 323 }; 324 document.addEventListener("visibilitychange", onVis, { passive: true }); 325 } catch (e) {} 326 327 function collectBehavioral() { 328 var p = bucketUp(COUNT_BUCKET, pointerEvents); 329 if (p !== null) summarySignals.pointer_event_count_bucket = p; 330 331 var s = bucketUp(PERCENT_BUCKET, maxScrollPercent); 332 if (s !== null) summarySignals.scroll_depth_bucket = s; 333 334 var v = bucketUp(COUNT_BUCKET, visibilityChanges); 335 if (v !== null) summarySignals.visibility_change_count_bucket = v; 336 } 337 338 function send(token, payload, phase) { 339 if (!token) return; 340 var keys = Object.keys(payload); 341 if (keys.length === 0) return; 342 try { 343 var headers = { 344 "Content-Type": "application/json", 345 "X-Osh-Ct": token, 346 "X-Osh-Collection-Phase": phase 347 }; 348 if (pageRequestId && pageCapability) { 349 headers["X-Osh-Page-Rid"] = pageRequestId; 350 headers["X-Osh-Page-Cap"] = pageCapability; 351 } 352 fetch("/_osh/collect", { 353 method: "POST", 354 headers: headers, 355 body: JSON.stringify(payload), 356 credentials: "same-origin", 357 keepalive: true 358 }).catch(function () {}); 359 } catch (e) {} 360 } 361 362 // `ready`: fires as soon as a credential is available -- synchronously for the data-osh-ct
363 // path, or on the /_osh/ct promise's resolution otherwise. There is no dwell and no PoW gate, 364 // which is what keeps it inside the ~1 s target: readySignals is fully populated before this 365 // function can even be called. 366 function sendReady(cred) { 367 if (readySent) return; 368 readySent = true; 369 send(cred && cred.ct, readySignals, "ready"); 370 } 371 372 // `summary`: exactly once, guarded the same way `ready` is, at the earliest of the dwell timer, 373 // `pagehide`, or hidden visibility. 374 function sendSummary(cred) { 375 if (!cred) return; 376 collectBehavioral(); 377 if (powSolution !== null) { 378 summarySignals.pow = powSolution; 379 } else if (powGaveUp) { 380 summarySignals.pow = false; 381 } 382 send(cred.ct, summarySignals, "summary"); 383 } 384 385 function triggerSummary(cred) { 386 if (summarySent) return; 387 summarySent = true; 388 sendSummary(cred); 389 } 390 391 function run(readyCred, summaryCred) { 392 sendReady(readyCred); 393 394 var timer = null; 395 var onLeave = function () { 396 if (timer) clearTimeout(timer); 397 triggerSummary(summaryCred); 398 }; 399 try { 400 window.addEventListener("pagehide", onLeave, { once: true }); 401 // The summarySent guard makes this listener one-shot; do not use { once: true }, because a 402 // visible transition must not consume it before the first hidden transition can flush. 403 document.addEventListener("visibilitychange", function () { 404 if (document.visibilityState === "hidden") onLeave(); 405 }); 406 } catch (e) {} 407 408 timer = setTimeout(function () { triggerSummary(summaryCred); }, SUMMARY_DWELL_MS); 409 410 var nonce = summaryCred && summaryCred.nonce; 411 var difficulty = summaryCred && summaryCred.difficulty; 412 if (nonce && typeof difficulty === "number") { 413 computePoW(nonce, difficulty, function (solution, elapsedMs, gaveUp) { 414 if (solution !== null) { 415 powSolution = solution; 416 } else if (gaveUp === true) { 417 powGaveUp = true; 418 } 419 // The duration is worth having for a give-up too: it is the difference between hitting the 420 // budget wall and bailing out after 8 ms because the tab was hidden, and those call for 421 // opposite fixes. 422 if ((solution !== null || gaveUp === true) && typeof elapsedMs === "number") { 423 var b = bucketUp(MILLISECONDS, elapsedMs); 424 if (b !== null) summarySignals.pow_duration_ms_bucket = b; 425 } 426 }); 427 } 428 } 429 430 // Resolve one or two credentials out of whatever /_osh/ct answered. 431 // 432 // v2 (two credentials, each phase-bound) is read only if BOTH `credentials.ready` and 433 // `credentials.summary` are present -- a partial v2 body is treated as no body at all rather 434 // than guessed at. v1 (one credential, no phase binding) is the fallback; the server still 435 // enforces one ready and one summary POST per token, selected by the phase header. 436 function resolveCredentials(data) { 437 if (data && data.credentials && data.credentials.ready && data.credentials.summary) { 438 return { ready: data.credentials.ready, summary: data.credentials.summary }; 439 } 440 if (data && typeof data.ct === "string") { 441 var v1 = { ct: data.ct, nonce: data.nonce, difficulty: data.difficulty }; 442 return { ready: v1, summary: v1 }; 443 } 444 return null; 445 } 446 function resolveTaggedCredentials(encoded) { 447 // The challenge tag carries base64url(JSON), not raw JSON: the attribute remains safe in the 448 // double-quoted HTML context while the widget still receives the same pair shape as /_osh/ct. 449 try { 450 if (typeof encoded !== "string" || encoded.length === 0) return null; 451 var alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; 452 var binary = ""; 453 var buffer = 0; 454 var bits = 0; 455 for (var i = 0; i < encoded.length; i++) { 456 var value = alphabet.indexOf(encoded.charAt(i)); 457 if (value < 0) return null; 458 buffer = (buffer << 6) | value; 459 bits += 6; 460 if (bits >= 8) { 461 bits -= 8; 462 binary += String.fromCharCode((buffer >> bits) & 255); 463 buffer &= bits === 0 ? 0 : (1 << bits) - 1; 464 } 465 } 466 if (bits >= 6 || (bits !== 0 && buffer !== 0)) return null;
467 return resolveCredentials(JSON.parse(binary)); 468 } catch (e) { 469 return null; 470 } 471 } 472 473 var token = document.currentScript.getAttribute("data-osh-ct"); 474 if (token) { 475 var creds = resolveTaggedCredentials(token); 476 if (creds) { 477 run(creds.ready, creds.summary); 478 } else { 479 // Browser-cached pages from before the v2 cutover still carry a raw v1 token. Keep their 480 // one-token/two-emission behaviour, and also preserve the no-beacon-loss fallback if a 481 // malformed or unsupported tagged payload reaches a client. 482 var v1 = { ct: token, nonce: null, difficulty: null }; 483 run(v1, v1); 484 } 485 } else { 486 try { 487 var tokenRequest = { credentials: "same-origin" }; 488 if (pageRequestId && pageCapability) { 489 tokenRequest.headers = { 490 "X-Osh-Page-Rid": pageRequestId, 491 "X-Osh-Page-Cap": pageCapability 492 }; 493 } 494 fetch("/_osh/ct", tokenRequest) 495 .then(function (r) { 496 return r.status === 200 ? r.json() : null; 497 }) 498 .then(function (data) { 499 var creds = resolveCredentials(data); 500 if (creds) run(creds.ready, creds.summary); 501 }) 502 .catch(function () {}); 503 } catch (e) {} 504 } 505})();
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.