From f2af866458ee3c226610b8f58693096a3649f843 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 25 Oct 2026 01:01:07 +0700 Subject: [PATCH] fix: recover shared tracking choices after outages Signed-off-by: Jeremi Joslin --- .../scripts/shared-tracking-client.test.mjs | 114 +++++++++++++++++- docs/site/src/lib/shared-tracking-client.mjs | 17 ++- 2 files changed, 126 insertions(+), 5 deletions(-) diff --git a/docs/site/scripts/shared-tracking-client.test.mjs b/docs/site/scripts/shared-tracking-client.test.mjs index 463a974824..b856bee624 100644 --- a/docs/site/scripts/shared-tracking-client.test.mjs +++ b/docs/site/scripts/shared-tracking-client.test.mjs @@ -13,14 +13,15 @@ const generation = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'; const accepted = { choice: 'accepted', generation, expiresAt: Date.now() + 60_000, revision: 10 }; const rejected = { choice: 'rejected', generation: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd', expiresAt: Date.now() + 60_000, revision: 11 }; const envelope = { protocol: 'registrystack-tracking', version: 2 }; +const localRefusalKey = 'registrystack.tracking-local-refusal.v2'; function fixture(options = {}) { - const { factory = createSharedTrackingClient, ...clientOptions } = options; + const { factory = createSharedTrackingClient, initialValues = [], ...clientOptions } = options; const listeners = new Map(); const documentListeners = new Map(); const sent = []; const frames = []; - const values = new Map(); + const values = new Map(initialValues); const storage = { getItem: key => values.get(key) ?? null, setItem: (key, value) => values.set(key, value), @@ -457,6 +458,115 @@ test('a same-origin outage refusal gates an already-open tab and is relayed to t f.client.close(); }); +test('a retained local refusal reads the shared revision before replaying rejection', async (t) => { + const f = fixture({ initialValues: [[localRefusalKey, 'rejected']] }); + t.after(() => f.client.close()); + const recovery = f.client.read(); + const outcome = recovery.then(value => ({ value }), error => ({ error })); + await new Promise(resolve => setImmediate(resolve)); + + const readRequest = f.sent[0].data; + assert.equal(readRequest.type, 'read'); + f.message({ ...envelope, id: readRequest.id, ok: true, state: accepted }); + await new Promise(resolve => setImmediate(resolve)); + + assert.equal(f.client.state.choice, 'rejected'); + await assert.rejects(f.client.pageview({ + page_url: 'https://docs.registrystack.org/', page_title: 'Docs', page_referrer: '', + }), /tracking/i); + assert.equal(f.sent.some(({ data }) => data.type === 'pageview'), false); + const replay = f.sent[1].data; + assert.equal(replay.type, 'choose'); + assert.equal(replay.choice, 'rejected'); + assert.equal(replay.expectedRevision, accepted.revision); + f.message({ ...envelope, id: replay.id, ok: true, state: rejected }); + + assert.deepEqual(await outcome, { value: rejected }); + assert.equal(f.values.has(localRefusalKey), false); + assert.equal(f.failures.length, 0); +}); + +test('an explicit acceptance supersedes a delayed startup refusal replay', async (t) => { + const f = fixture({ initialValues: [[localRefusalKey, 'rejected']] }); + t.after(() => f.client.close()); + const startup = f.client.read(); + const startupOutcome = startup.then(value => ({ value }), error => ({ error })); + await new Promise(resolve => setImmediate(resolve)); + const readRequest = f.sent[0].data; + assert.equal(readRequest.type, 'read'); + + const explicit = await nextRequest(() => f.client.choose('accepted')); + const acceptRequest = f.sent[1].data; + assert.equal(acceptRequest.type, 'choose'); + assert.equal(acceptRequest.choice, 'accepted'); + f.message({ ...envelope, id: acceptRequest.id, ok: true, state: accepted }); + assert.deepEqual(await explicit.promise, accepted); + + f.message({ ...envelope, id: readRequest.id, ok: true, state: accepted }); + assert.deepEqual(await startupOutcome, { value: accepted }); + await new Promise(resolve => setImmediate(resolve)); + assert.equal( + f.sent.filter(({ data }) => data.type === 'choose' && data.choice === 'rejected').length, + 0, + ); + assert.equal(f.values.has(localRefusalKey), false); + assert.equal(f.failures.length, 0); +}); + +test('an explicit acceptance reconciles a stale revision while a retained refusal masks startup', async (t) => { + const f = fixture({ initialValues: [[localRefusalKey, 'rejected']] }); + t.after(() => f.client.close()); + const startup = f.client.read(); + const startupOutcome = startup.then(value => ({ value }), error => ({ error })); + await new Promise(resolve => setImmediate(resolve)); + const startupRead = f.sent[0].data; + + const explicit = await nextRequest(() => f.client.choose('accepted')); + const explicitOutcome = explicit.promise.then(value => ({ value }), error => ({ error })); + const acceptRequest = f.sent[1].data; + assert.equal(acceptRequest.expectedRevision, 0); + f.message({ ...envelope, id: acceptRequest.id, ok: false, error: 'unavailable' }); + await new Promise(resolve => setImmediate(resolve)); + const recoveryRead = f.sent[2].data; + assert.equal(recoveryRead.type, 'read'); + assert.equal(f.client.state.choice, 'rejected'); + f.message({ ...envelope, id: recoveryRead.id, ok: true, state: accepted }); + + assert.deepEqual(await explicitOutcome, { value: accepted }); + f.message({ ...envelope, id: startupRead.id, ok: true, state: accepted }); + assert.deepEqual(await startupOutcome, { value: accepted }); + assert.equal(f.sent.filter(({ data }) => data.type === 'choose').length, 1); + assert.equal(f.values.has(localRefusalKey), false); + assert.equal(f.failures.length, 0); +}); + +test('a later refusal survives an older acceptance reconciliation and a failed rejection request', async (t) => { + const f = fixture({ initialValues: [[localRefusalKey, 'rejected']] }); + t.after(() => f.client.close()); + const acceptance = await nextRequest(() => f.client.choose('accepted')); + const acceptanceOutcome = acceptance.promise.then(value => ({ value }), error => ({ error })); + f.message({ ...envelope, id: f.sent[0].data.id, ok: false, error: 'unavailable' }); + await new Promise(resolve => setImmediate(resolve)); + const recoveryRead = f.sent[1].data; + assert.equal(recoveryRead.type, 'read'); + + const refusal = await nextRequest(() => f.client.choose('rejected')); + const refusalOutcome = refusal.promise.then(value => ({ value }), error => ({ error })); + const rejectionRequest = f.sent[2].data; + f.message({ ...envelope, id: recoveryRead.id, ok: true, state: accepted }); + assert.equal((await acceptanceOutcome).value?.choice, 'rejected'); + assert.equal(f.values.get(localRefusalKey), 'rejected'); + + f.message({ ...envelope, id: rejectionRequest.id, ok: false, error: 'unavailable' }); + assert.match((await refusalOutcome).error?.message, /tracking/i); + assert.equal(f.client.state.choice, 'rejected'); + assert.equal(f.values.get(localRefusalKey), 'rejected'); + await assert.rejects(f.client.pageview({ + page_url: 'https://docs.registrystack.org/', page_title: 'Docs', page_referrer: '', + }), /tracking/i); + assert.equal(f.sent.some(({ data }) => data.type === 'pageview'), false); +}); + test('page data preserves external acquisition and removes identity, search, and internal handoff parameters', () => { const page = buildMauticPageview({ canonicalUrl: 'https://docs.registrystack.org/tutorials/first-registry/?search=private#record', diff --git a/docs/site/src/lib/shared-tracking-client.mjs b/docs/site/src/lib/shared-tracking-client.mjs index 504a887d97..ba8af24cde 100644 --- a/docs/site/src/lib/shared-tracking-client.mjs +++ b/docs/site/src/lib/shared-tracking-client.mjs @@ -80,6 +80,7 @@ export function createSharedTrackingClient(options = {}) { let closed = false; let suspended = false; let localRefusal = false; + let choiceIntent = 0; let expiryTimer; try { localRefusal = currentWindow?.localStorage?.getItem(LOCAL_REFUSAL_KEY) === 'rejected'; @@ -353,13 +354,19 @@ export function createSharedTrackingClient(options = {}) { subscribe(listener) { subscribers.add(listener); return () => subscribers.delete(listener); }, subscribeFailure(listener) { failureSubscribers.add(listener); return () => failureSubscribers.delete(listener); }, async read() { - if (localRefusal) return client.choose('rejected'); - return request('read'); + if (!localRefusal) return request('read'); + const intent = choiceIntent; + // Keep the local refusal active while learning the current shared + // revision. Replaying revision zero would strand it after an outage. + await request('read'); + if (!localRefusal || choiceIntent !== intent) return effectiveState(); + return client.choose('rejected'); }, async choose(choice) { if (!['accepted', 'rejected'].includes(choice) || (choice === 'accepted' && denied())) { throw trackingError(); } + const intent = ++choiceIntent; const expectedRevision = state.revision; if (choice === 'rejected') { localRefusal = true; @@ -376,10 +383,14 @@ export function createSharedTrackingClient(options = {}) { if (choice === 'rejected') discardFrame(); if (choice !== 'accepted' || !recoverableFailures.has(error)) throw error; await request('read'); - if (effectiveState().choice !== 'accepted') throw fail(); + // A retained refusal masks the shared state until this explicit + // acceptance finishes reconciling. Privacy signals still take priority. + if (state.choice !== 'accepted' || denied() || + (state.expiresAt !== null && state.expiresAt <= now())) throw fail(); } // A refusal that could not reach the bridge remains local and visible as // an error, rather than allowing an older shared acceptance to return. + if (choiceIntent !== intent) return effectiveState(); localRefusal = false; try { currentWindow?.localStorage?.removeItem(LOCAL_REFUSAL_KEY); } catch { suspended = true; } notify();