From fe1d05164a5851cc7664280b01a30ee1c9f04ae4 Mon Sep 17 00:00:00 2001 From: "Ramin B." Date: Tue, 14 Jul 2026 13:58:30 -0400 Subject: [PATCH] feat(session): TTL for session self-exemptions (default 30m) Session-scoped self-exemptions (`selfExemptionScope: 'session'`) never expired: pruneExpiredSessions only pruned `state.sessions`, so a host exemption lived for the lifetime of the session state. That is an unbounded blast radius for a grant, and the sharp edge behind #52 item 3 (a later same-network click on an already-exempted host stays suppressed indefinitely). Add `sessionExemptionTtlMs` (default 30 minutes, fixed from grant, not sliding) on the session and thread it through the content/webext/url adapters. ExemptionRecord gains an `expiresAt`; pruneExpiredState (renamed from pruneExpiredSessions) now drops lapsed exemptions alongside expired sessions. Set the TTL to 0 (or any non-positive value) to disable expiry and keep the old lifetime behavior. This bounds the window from item 3; Option C (narrowing what a live exemption suppresses) is the precise fix and lands separately. Addresses item 3 of #52 (bound). Part of #52. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01PXZmuMFkq8dE2e2KLXvitx --- src/content.ts | 17 ++++++++++- src/session.ts | 45 ++++++++++++++++++++++----- src/types.ts | 9 ++++-- src/url.ts | 17 ++++++++++- src/webext.ts | 17 ++++++++++- tests/session.test.ts | 71 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 164 insertions(+), 12 deletions(-) diff --git a/src/content.ts b/src/content.ts index d2254f8..260ff43 100644 --- a/src/content.ts +++ b/src/content.ts @@ -52,6 +52,12 @@ export interface CreateContentStanddownOptions { * navigations (Dupe's `ignore_param` semantics). */ readonly selfExemptionScope?: 'policy' | 'session'; + /** + * TTL for a `selfExemptionScope: 'session'` exemption, measured from when it + * was first granted. Defaults to 30 minutes; `0` or any non-positive value + * disables expiry (lifetime of the session state). + */ + readonly sessionExemptionTtlMs?: number; readonly onDecision?: (decision: Decision, signals: Signals) => void; } @@ -267,11 +273,16 @@ export function cookieNamesFromString(cookie: string): string[] { function contentSessionOptions( opts: CreateContentStanddownOptions, ): - | { auditLog?: boolean; selfExemptionScope?: 'policy' | 'session' } + | { + auditLog?: boolean; + selfExemptionScope?: 'policy' | 'session'; + sessionExemptionTtlMs?: number; + } | undefined { const sessionOpts: { auditLog?: boolean; selfExemptionScope?: 'policy' | 'session'; + sessionExemptionTtlMs?: number; } = {}; if (opts.auditLog !== undefined) { @@ -282,6 +293,10 @@ function contentSessionOptions( sessionOpts.selfExemptionScope = opts.selfExemptionScope; } + if (opts.sessionExemptionTtlMs !== undefined) { + sessionOpts.sessionExemptionTtlMs = opts.sessionExemptionTtlMs; + } + return Object.keys(sessionOpts).length > 0 ? sessionOpts : undefined; } diff --git a/src/session.ts b/src/session.ts index 87075d9..10faa75 100644 --- a/src/session.ts +++ b/src/session.ts @@ -33,6 +33,7 @@ export class StanddownSession { readonly #auditLog: boolean; readonly #maxAuditEntries: number; readonly #selfExemptionScope: 'policy' | 'session'; + readonly #sessionExemptionTtlMs: number; readonly #readOnlyAuditLog: AuditEntry[] = []; #stateLock: Promise = Promise.resolve(); @@ -50,12 +51,20 @@ export class StanddownSession { * lifts an already-active stand-down and never covers a `disabled-host`. */ selfExemptionScope?: 'policy' | 'session'; + /** + * How long a `selfExemptionScope: 'session'` exemption persists for a + * host, measured from when it was first granted (fixed, not sliding). + * Defaults to 30 minutes. Set to `0` or any non-positive value to disable + * expiry and hold the exemption for the lifetime of the session state. + */ + sessionExemptionTtlMs?: number; }, ) { this.#store = store; this.#auditLog = opts?.auditLog ?? true; this.#maxAuditEntries = Math.max(0, opts?.maxAuditEntries ?? 1_000); this.#selfExemptionScope = opts?.selfExemptionScope ?? 'policy'; + this.#sessionExemptionTtlMs = opts?.sessionExemptionTtlMs ?? 1_800_000; } async ingest( @@ -88,12 +97,18 @@ export class StanddownSession { } return this.#withState(signals.now, (state) => { - pruneExpiredSessions(state, signals.now); + pruneExpiredState(state, signals.now); let effective = detection; if (this.#selfExemptionScope === 'session' && advertiserHost) { - recordSessionExemptions(state, advertiserHost, detection, signals.now); + recordSessionExemptions( + state, + advertiserHost, + detection, + signals.now, + this.#sessionExemptionTtlMs, + ); effective = applySessionExemptions( advertiserHost, detection, @@ -164,7 +179,7 @@ export class StanddownSession { return this.#withState( now, (state) => { - pruneExpiredSessions(state, now); + pruneExpiredState(state, now); return { decision: @@ -186,7 +201,7 @@ export class StanddownSession { await this.#withState( now, (state) => { - pruneExpiredSessions(state, now); + pruneExpiredState(state, now); for (const record of Object.values(state.sessions)) { if (record.sessionRule === 'inactivity-window') { @@ -431,6 +446,7 @@ function recordSessionExemptions( advertiserHost: string, detection: Detection, now: number, + ttlMs: number, ): void { const scopes = detection.selfExemptScopes; @@ -458,12 +474,19 @@ function recordSessionExemptions( networkIds.add(scope.networkId); } - exemptions[key] = { + const grantedAt = existing?.grantedAt ?? now; + const record: ExemptionRecord = { advertiserHost: key, policyIds: [...policyIds], networkIds: [...networkIds], - grantedAt: existing?.grantedAt ?? now, + grantedAt, }; + + if (ttlMs > 0) { + record.expiresAt = grantedAt + ttlMs; + } + + exemptions[key] = record; } /** @@ -615,12 +638,20 @@ function isActive(record: SessionRecord, now: number): boolean { return expiresAt === undefined ? false : now < expiresAt; } -function pruneExpiredSessions(state: StanddownState, now: number): void { +function pruneExpiredState(state: StanddownState, now: number): void { for (const [host, record] of Object.entries(state.sessions)) { if (!isActive(record, now)) { delete state.sessions[host]; } } + + if (state.exemptions) { + for (const [host, record] of Object.entries(state.exemptions)) { + if (record.expiresAt !== undefined && record.expiresAt <= now) { + delete state.exemptions[host]; + } + } + } } function failClosedDecision(reason: string): Decision { diff --git a/src/types.ts b/src/types.ts index 36e68be..d83b356 100644 --- a/src/types.ts +++ b/src/types.ts @@ -214,14 +214,19 @@ export interface SessionRecord { * A session-scoped self-exemption grant for one advertiser host: the integrator's * own attribution (via `selfPatterns`) was seen for these policies/networks, so * later navigations to the host re-apply the exemption for those same scopes. - * Held for the lifetime of the session state; never lifts an already-active - * stand-down and never covers a `disabled-host` match. + * Bounded by `sessionExemptionTtlMs`, measured from `grantedAt`: once `expiresAt` + * passes the record is pruned and a later self-navigation starts a fresh window. + * With the TTL disabled the record is held for the lifetime of the session state. + * Never lifts an already-active stand-down and never covers a `disabled-host` + * match. */ export interface ExemptionRecord { advertiserHost: string; policyIds: string[]; networkIds: string[]; grantedAt: number; + /** Absolute time the exemption lapses. Omitted when the TTL is disabled. */ + expiresAt?: number; } export interface StanddownState { diff --git a/src/url.ts b/src/url.ts index 4a263b0..b0f96ae 100644 --- a/src/url.ts +++ b/src/url.ts @@ -37,6 +37,12 @@ export interface CreateUrlStanddownOptions { * decisions (Dupe's `ignore_param` semantics). */ readonly selfExemptionScope?: 'policy' | 'session'; + /** + * TTL for a `selfExemptionScope: 'session'` exemption, measured from when it + * was first granted. Defaults to 30 minutes; `0` or any non-positive value + * disables expiry (lifetime of the session state). + */ + readonly sessionExemptionTtlMs?: number; readonly onDecision?: (decision: Decision, signals: Signals) => void; } @@ -170,11 +176,16 @@ export function collectUrlSignals( function urlSessionOptions( opts: CreateUrlStanddownOptions, ): - | { auditLog?: boolean; selfExemptionScope?: 'policy' | 'session' } + | { + auditLog?: boolean; + selfExemptionScope?: 'policy' | 'session'; + sessionExemptionTtlMs?: number; + } | undefined { const sessionOpts: { auditLog?: boolean; selfExemptionScope?: 'policy' | 'session'; + sessionExemptionTtlMs?: number; } = {}; if (opts.auditLog !== undefined) { @@ -185,6 +196,10 @@ function urlSessionOptions( sessionOpts.selfExemptionScope = opts.selfExemptionScope; } + if (opts.sessionExemptionTtlMs !== undefined) { + sessionOpts.sessionExemptionTtlMs = opts.sessionExemptionTtlMs; + } + return Object.keys(sessionOpts).length > 0 ? sessionOpts : undefined; } diff --git a/src/webext.ts b/src/webext.ts index e9b2a2b..3393c25 100644 --- a/src/webext.ts +++ b/src/webext.ts @@ -61,6 +61,12 @@ export interface CreateStanddownOptions { * navigations (Dupe's `ignore_param` semantics). */ readonly selfExemptionScope?: 'policy' | 'session'; + /** + * TTL for a `selfExemptionScope: 'session'` exemption, measured from when it + * was first granted. Defaults to 30 minutes; `0` or any non-positive value + * disables expiry (lifetime of the session state). + */ + readonly sessionExemptionTtlMs?: number; } export interface StanddownWebextController { @@ -452,11 +458,16 @@ export function createStanddown( function sessionOptions( opts: CreateStanddownOptions, ): - | { auditLog?: boolean; selfExemptionScope?: 'policy' | 'session' } + | { + auditLog?: boolean; + selfExemptionScope?: 'policy' | 'session'; + sessionExemptionTtlMs?: number; + } | undefined { const sessionOpts: { auditLog?: boolean; selfExemptionScope?: 'policy' | 'session'; + sessionExemptionTtlMs?: number; } = {}; if (opts.auditLog !== undefined) { @@ -467,6 +478,10 @@ function sessionOptions( sessionOpts.selfExemptionScope = opts.selfExemptionScope; } + if (opts.sessionExemptionTtlMs !== undefined) { + sessionOpts.sessionExemptionTtlMs = opts.sessionExemptionTtlMs; + } + return Object.keys(sessionOpts).length > 0 ? sessionOpts : undefined; } diff --git a/tests/session.test.ts b/tests/session.test.ts index d6c7141..693b9ba 100644 --- a/tests/session.test.ts +++ b/tests/session.test.ts @@ -333,6 +333,77 @@ describe('StanddownSession', () => { }); }); + it('bounds a session self-exemption by the default TTL', async () => { + const selfPatterns = [ + { name: 'cjevent', value: 'own', match: 'equals' as const, policyId: 'cj' }, + ]; + const session = new StanddownSession(new MemoryStateStore(), { + selfExemptionScope: 'session', + }); + + // Our own click: self-exempted, and it records the host exemption. + await expect( + session.ingest( + { url: 'https://merchant.example/?cjevent=own', now: 0, selfPatterns }, + [cjPolicy], + ), + ).resolves.toMatchObject({ standDown: false }); + + // A competitor's CJ click on the same host, inside the 30-minute window: + // the exemption still suppresses it (the documented within-window behavior + // that item 3's Option C narrows separately). + await expect( + session.ingest( + { + url: 'https://merchant.example/?cjevent=rival', + now: 60_000, + selfPatterns, + }, + [cjPolicy], + ), + ).resolves.toMatchObject({ standDown: false }); + + // Past the TTL the exemption has lapsed, so the same competitor click now + // stands down. + await expect( + session.ingest( + { + url: 'https://merchant.example/?cjevent=rival', + now: 1_800_001, + selfPatterns, + }, + [cjPolicy], + ), + ).resolves.toMatchObject({ standDown: true, policyId: 'cj' }); + }); + + it('holds a session self-exemption for the state lifetime when TTL is disabled', async () => { + const selfPatterns = [ + { name: 'cjevent', value: 'own', match: 'equals' as const, policyId: 'cj' }, + ]; + const session = new StanddownSession(new MemoryStateStore(), { + selfExemptionScope: 'session', + sessionExemptionTtlMs: 0, + }); + + await session.ingest( + { url: 'https://merchant.example/?cjevent=own', now: 0, selfPatterns }, + [cjPolicy], + ); + + // Well past any default window, the exemption still applies. + await expect( + session.ingest( + { + url: 'https://merchant.example/?cjevent=rival', + now: 10 * 60 * 60 * 1_000, + selfPatterns, + }, + [cjPolicy], + ), + ).resolves.toMatchObject({ standDown: false }); + }); + it('validates the test policy fixture', () => { expect(() => validatePolicy(