From d39b61a2fdf084ae1164f13f9c8da38d3d482fde Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 25 Oct 2026 05:02:27 +0700 Subject: [PATCH] fix: accept shared consent broker readiness Signed-off-by: Jeremi Joslin --- .../scripts/shared-tracking-client.test.mjs | 74 ++++++++++++++++++- docs/site/src/lib/shared-tracking-client.mjs | 17 ++++- docs/site/src/lib/tracking-protocol.mjs | 7 ++ 3 files changed, 94 insertions(+), 4 deletions(-) diff --git a/docs/site/scripts/shared-tracking-client.test.mjs b/docs/site/scripts/shared-tracking-client.test.mjs index 56510c450b..860aa210e6 100644 --- a/docs/site/scripts/shared-tracking-client.test.mjs +++ b/docs/site/scripts/shared-tracking-client.test.mjs @@ -49,6 +49,7 @@ function fixture(options = {}) { createElement: tag => { assert.equal(tag, 'iframe'); const frameListeners = new Map(); + const attributes = new Map(); frame = { contentWindow: { postMessage: (data, origin) => { if (remainingSendFailures > 0) { @@ -57,7 +58,8 @@ function fixture(options = {}) { } sent.push({ data, origin }); } }, - setAttribute() {}, + setAttribute: (name, value) => attributes.set(name, value), + getAttribute: name => attributes.get(name) ?? null, addEventListener: (type, listener) => frameListeners.set(type, listener), fireLoad: () => frameListeners.get('load')?.(), fireError: () => frameListeners.get('error')?.(), @@ -108,20 +110,88 @@ async function nextRequest(run) { return { promise }; } -test('reads shared consent through one fixed iframe without a parent tracking script', async () => { +test('reads shared consent through one fixed iframe without a parent tracking script', async (t) => { const f = fixture(); + t.after(() => f.client.close()); const { promise } = await nextRequest(() => f.client.read()); + void promise.catch(() => {}); assert.equal(f.frame.src, bridgeUrl); assert.equal(f.frame.hidden, true); assert.equal(f.frame.referrerPolicy, 'no-referrer'); assert.equal(f.sent[0].origin, 'https://registrystack.org'); assert.equal(f.sent[0].data.type, 'read'); + assert.equal(f.frame.getAttribute('data-tracking-ready'), 'load'); f.reply(unknown); assert.deepEqual(await promise, unknown); assert.equal(f.values.size, 0); f.client.close(); }); +test('broker readiness permits a consent read before the iframe load event', async (t) => { + const f = fixture({ autoLoad: false }); + t.after(() => f.client.close()); + const { promise } = await nextRequest(() => f.client.read()); + void promise.catch(() => {}); + assert.equal(f.sent.length, 0); + + f.message({ protocol: envelope.protocol, version: envelope.version, type: 'ready' }); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(f.sent.length, 1); + assert.equal(f.sent[0].data.type, 'read'); + assert.equal(f.frame.getAttribute('data-tracking-ready'), 'broker'); + assert.deepEqual(f.client.state, unknown); + f.reply(rejected); + assert.deepEqual(await promise, rejected); + + f.frame.fireLoad(); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(f.sent.length, 1); + assert.equal(f.failures.length, 0); +}); + +test('readiness requires the exact envelope from the current bridge origin and frame', async (t) => { + const f = fixture({ autoLoad: false }); + t.after(() => f.client.close()); + const { promise } = await nextRequest(() => f.client.read()); + void promise.catch(() => {}); + const ready = { protocol: envelope.protocol, version: envelope.version, type: 'ready' }; + f.message(ready, { origin: 'https://attacker.example' }); + f.message(ready, { source: {} }); + f.message({ ...ready, state: rejected }); + f.message({ ...ready, version: 1 }); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(f.sent.length, 0); + assert.deepEqual(f.client.state, unknown); + + f.message(ready); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(f.sent.length, 1); + f.reply(rejected); + assert.deepEqual(await promise, rejected); +}); + +test('readiness from a removed failed frame cannot unlock its replacement', async (t) => { + const f = fixture({ autoLoad: false }); + t.after(() => f.client.close()); + const { promise } = await nextRequest(() => f.client.read()); + void promise.catch(() => {}); + const oldFrame = f.frame; + oldFrame.fireError(); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(f.frames.length, 2); + const ready = { protocol: envelope.protocol, version: envelope.version, type: 'ready' }; + f.message(ready, { source: oldFrame.contentWindow }); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(f.sent.length, 0); + + f.message(ready); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(f.sent.length, 1); + f.reply(rejected); + assert.deepEqual(await promise, rejected); + assert.equal(f.failures.length, 0); +}); + test('concurrent startup and resume reads share one request and confirm an unchanged remote state', async (t) => { const f = fixture(); t.after(() => f.client.close()); diff --git a/docs/site/src/lib/shared-tracking-client.mjs b/docs/site/src/lib/shared-tracking-client.mjs index c17145efd3..d4510d6110 100644 --- a/docs/site/src/lib/shared-tracking-client.mjs +++ b/docs/site/src/lib/shared-tracking-client.mjs @@ -2,6 +2,7 @@ import { SHARED_TRACKING_BRIDGE_URL, createTrackingRequest, parseTrackingChanged, + parseTrackingReady, parseTrackingResponse, } from './tracking-protocol.mjs'; @@ -89,6 +90,7 @@ export function createSharedTrackingClient(options = {}) { let frame; let frameReady; let frameReadyCancel; + let frameReadySignal; let closed = false; let suspended = false; let localRefusal = false; @@ -194,6 +196,10 @@ export function createSharedTrackingClient(options = {}) { const receive = event => { if (closed || event.origin !== bridge.origin || event.source !== frame?.contentWindow) return; + if (parseTrackingReady(event.data)) { + frameReadySignal?.('broker'); + return; + } const changed = parseTrackingChanged(event.data); if (changed) { update(changed.state); @@ -255,12 +261,13 @@ export function createSharedTrackingClient(options = {}) { if (frame === candidate) frame = undefined; if (frameReady === ready) frameReady = undefined; if (frameReadyCancel === reset) frameReadyCancel = undefined; + if (frameReadySignal === markReady) frameReadySignal = undefined; candidate.remove(); rejectReady(trackingError(code)); }; const timer = setTimeout(() => reset('frame-load-timeout'), frameLoadTimeoutMs); frameReadyCancel = reset; - candidate.addEventListener('load', () => { + const markReady = via => { if (settled) return; if (closed || frame !== candidate || frameReady !== ready) { reset(); @@ -269,8 +276,14 @@ export function createSharedTrackingClient(options = {}) { settled = true; clearTimeout(timer); if (frameReadyCancel === reset) frameReadyCancel = undefined; + if (frameReadySignal === markReady) frameReadySignal = undefined; + candidate.setAttribute('data-tracking-ready', via); resolveReady(candidate); - }, { once: true }); + }; + frameReadySignal = markReady; + // The broker can be usable before unrelated iframe resources finish loading. + // Retain load for cached bridges that do not emit application readiness. + candidate.addEventListener('load', () => markReady('load'), { once: true }); candidate.addEventListener('error', () => reset('frame-load-failed'), { once: true }); try { currentDocument.body.append(candidate); diff --git a/docs/site/src/lib/tracking-protocol.mjs b/docs/site/src/lib/tracking-protocol.mjs index 7a8e808543..aa3b9a718a 100644 --- a/docs/site/src/lib/tracking-protocol.mjs +++ b/docs/site/src/lib/tracking-protocol.mjs @@ -183,3 +183,10 @@ export function parseTrackingChanged(value) { ) return null; return { ...value, state: { ...value.state } }; } + +export function parseTrackingReady(value) { + if (!exactKeys(value, ['protocol', 'version', 'type']) || + value.protocol !== TRACKING_PROTOCOL || + value.version !== TRACKING_PROTOCOL_VERSION || value.type !== 'ready') return null; + return { ...value }; +}