From 3e2e90684850874a07dc2318fccb781003a2ebd0 Mon Sep 17 00:00:00 2001 From: Eric Posen <563881+goeric@users.noreply.github.com> Date: Sat, 25 Jul 2026 21:44:22 -0700 Subject: [PATCH] fix(stores): preserve exemption expiresAt across persistence parseExemptionRecord dropped the optional expiresAt field, so a session self-exemption reloaded from a persisting store had no expiry. pruneExpiredState only prunes when expiresAt is set, making the exemption permanent and restoring the unbounded behaviour that sessionExemptionTtlMs was added in 0.3.3 to bound. Parse it back the same way parseSessionRecord already does. Read-side only: cloneState already preserves the field, which is why MemoryStateStore-based tests never caught it. --- src/stores.ts | 11 ++++++++-- tests/session.test.ts | 51 +++++++++++++++++++++++++++++++++++++++++++ tests/stores.test.ts | 26 ++++++++++++++++++++++ 3 files changed, 86 insertions(+), 2 deletions(-) diff --git a/src/stores.ts b/src/stores.ts index 0843ea8..4006a2b 100644 --- a/src/stores.ts +++ b/src/stores.ts @@ -384,11 +384,12 @@ function parseExemptionRecord(value: unknown): ExemptionRecord { throw new Error('invalid exemption record'); } - const { advertiserHost, policyIds, networkIds, grantedAt } = value; + const { advertiserHost, policyIds, networkIds, grantedAt, expiresAt } = value; if ( typeof advertiserHost !== 'string' || typeof grantedAt !== 'number' || + (expiresAt !== undefined && typeof expiresAt !== 'number') || !Array.isArray(policyIds) || !policyIds.every((id) => typeof id === 'string') || !Array.isArray(networkIds) || @@ -397,12 +398,18 @@ function parseExemptionRecord(value: unknown): ExemptionRecord { throw new Error('invalid exemption record'); } - return { + const record: ExemptionRecord = { advertiserHost, policyIds: [...policyIds], networkIds: [...networkIds], grantedAt, }; + + if (expiresAt !== undefined) { + record.expiresAt = expiresAt; + } + + return record; } function parseSessionRecord(value: unknown): SessionRecord { diff --git a/tests/session.test.ts b/tests/session.test.ts index 7099f47..29b8738 100644 --- a/tests/session.test.ts +++ b/tests/session.test.ts @@ -9,6 +9,7 @@ import { validatePolicy, } from '../src'; import { cjPolicy } from '../src/policies'; +import { SessionStorageStateStore, type WebStorageLike } from '../src/stores'; const behaviors = [ 'suppress-prompts', @@ -404,6 +405,40 @@ describe('StanddownSession', () => { ).resolves.toMatchObject({ standDown: false }); }); + it('bounds a session self-exemption by the default TTL across a persisted reload', async () => { + const storage = new FakeWebStorage(); + const selfPatterns = [ + { name: 'cjevent', value: 'own', match: 'equals' as const, policyId: 'cj' }, + ]; + + const first = new StanddownSession(new SessionStorageStateStore(storage), { + selfExemptionScope: 'session', + }); + await first.ingest( + { url: 'https://merchant.example/?cjevent=own', now: 0, selfPatterns }, + [cjPolicy], + ); + + // A fresh session over the same persisted state: the exemption is reloaded + // through the store's parser rather than held in memory. + const revived = new StanddownSession(new SessionStorageStateStore(storage), { + selfExemptionScope: 'session', + }); + + // Past the 30-minute default TTL the exemption has lapsed, so the lingering + // cookie stands down exactly as it does without persistence. + await expect( + revived.ingest( + { + url: 'https://merchant.example/checkout', + now: 1_800_001, + cookieNames: ['cjevent_dc'], + }, + [cjPolicy], + ), + ).resolves.toMatchObject({ standDown: true, policyId: 'cj' }); + }); + it('validates the test policy fixture', () => { expect(() => validatePolicy( @@ -607,3 +642,19 @@ function testPolicy(standdown: { function clonePolicy(policy: StanddownPolicy): StanddownPolicy { return JSON.parse(JSON.stringify(policy)) as StanddownPolicy; } + +class FakeWebStorage implements WebStorageLike { + readonly #items = new Map(); + + getItem(key: string): string | null { + return this.#items.get(key) ?? null; + } + + setItem(key: string, value: string): void { + this.#items.set(key, value); + } + + removeItem(key: string): void { + this.#items.delete(key); + } +} diff --git a/tests/stores.test.ts b/tests/stores.test.ts index e148548..616c85d 100644 --- a/tests/stores.test.ts +++ b/tests/stores.test.ts @@ -189,6 +189,32 @@ describe('StateStore implementations', () => { ]); }); + it('round-trips a session exemption expiresAt through persistence', async () => { + const local = new FakeChromeStorageArea(); + await new ChromeLocalStateStore(local, { + sessionId: 'browser-session-1', + now: () => 0, + }).save({ + ...stateWithSessionAndInactivityRecords(), + exemptions: { + 'session.example': { + advertiserHost: 'session.example', + policyIds: ['alfa'], + networkIds: ['alfa'], + grantedAt: 0, + expiresAt: 1_800_000, + }, + }, + }); + + const loaded = await new ChromeLocalStateStore(local, { + sessionId: 'browser-session-1', + now: () => 1_000, + }).load(); + + expect(loaded?.exemptions?.['session.example']?.expiresAt).toBe(1_800_000); + }); + it('drops session exemptions when the browser session changes', async () => { const local = new FakeChromeStorageArea(); await new ChromeLocalStateStore(local, {