From b0346792d496dab3eb4b1ce097ec9e6ff39b38b9 Mon Sep 17 00:00:00 2001 From: Eric Posen <563881+goeric@users.noreply.github.com> Date: Sat, 25 Jul 2026 22:41:53 -0700 Subject: [PATCH] feat(session): optional sessionKey resolver for advertiser-scoped state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ingest keys stand-down state by the landing hostname with no way to override it, so a merchant's www, checkout and alternate-ccTLD hosts are unrelated sessions and a stand-down opened on one does not survive navigation to another. Add an opt-in sessionKey resolver that controls only which bucket state is filed under. Detection is untouched: advertiserHosts rules and referrer classification still see the real hostname, and both SessionRecord and ExemptionRecord keep holding it, so only the map key changes. upsertSessionRecord and recordSessionExemptions each take the bucket key and the real host as separate arguments so their records agree on what advertiserHost means. The resolver takes the URL rather than Signals. The read paths only ever have a URL — a tab id resolves to a URL and nothing more — so a resolver keying off any other Signals field (selfPatterns, redirectChain, initiator, signalCoverage) is unimplementable on the read path by construction: it would return one key on write and a different key on read. A URL argument makes that unrepresentable rather than merely documented. The resolver must also be deterministic (I7) — webext resolves once to cache the tab's key and ingest resolves again to file state, so a varying key would desynchronise them. The resolved key is normalized the way the write paths normalize hosts — otherwise a mixed-case or trailing-dot key files an exemption under one string and looks it up under another, standing the integrator down against its own traffic. An empty return falls back to the landing hostname, and a resolver that throws fails the navigation closed (session-key-error) rather than rejecting ingest, keeping every error path a decision. shouldStandDown deliberately does not run the resolver — it stays a lookup by whatever key it is given — so resolveSessionKey(url) exposes the same resolution for the read side. The content / webext / url adapters all accept the option, and the webext and content read paths resolve through it, so a tab or location read cannot land on a different bucket than the navigation wrote. When a navigation resolves to no key at all, webext also drops the tab's cached key, so the next read re-resolves and fails closed with it instead of answering out of the previous page's bucket. Default behaviour is unchanged when the option is omitted. --- SPEC.md | 2 + src/content.ts | 35 ++-- src/session.ts | 107 +++++++++++-- src/url.ts | 13 ++ src/webext.ts | 84 ++++++---- tests/content.test.ts | 34 ++++ tests/session-key.test.ts | 329 ++++++++++++++++++++++++++++++++++++++ tests/url.test.ts | 21 +++ tests/webext.test.ts | 137 +++++++++++++++- 9 files changed, 705 insertions(+), 57 deletions(-) create mode 100644 tests/session-key.test.ts diff --git a/SPEC.md b/SPEC.md index 9fe78e3..5018c0a 100644 --- a/SPEC.md +++ b/SPEC.md @@ -108,8 +108,10 @@ class StanddownSession { constructor(store: StateStore, opts?: { auditLog?: boolean selfExemptionScope?: 'policy' | 'session' + sessionKey?: (url: string) => string | undefined // state bucket; defaults to the landing hostname }) async ingest(signals: Signals, policies: StanddownPolicy[]): Promise + resolveSessionKey(url: string): string | undefined // the key ingest files state under async shouldStandDown(advertiserHost: string, now: number): Promise async recordActivity(now: number): Promise // feeds inactivity windows async exportAuditLog(): Promise diff --git a/src/content.ts b/src/content.ts index 260ff43..1e3ccc2 100644 --- a/src/content.ts +++ b/src/content.ts @@ -58,6 +58,14 @@ export interface CreateContentStanddownOptions { * disables expiry (lifetime of the session state). */ readonly sessionExemptionTtlMs?: number; + /** + * Resolves the key a navigation's stand-down state is filed under. Defaults + * to the landing hostname; return a stable advertiser key to share one + * session across a merchant's hosts. See `StanddownSession`'s `sessionKey` + * for the scoping rules. `shouldStandDown()` resolves the current location + * through it too, so an argument-less read stays on the same bucket. + */ + readonly sessionKey?: (url: string) => string | undefined; readonly onDecision?: (decision: Decision, signals: Signals) => void; } @@ -189,15 +197,20 @@ export function createContentStanddown( advertiserHost?: string, at = now(), ): Promise { - const host = + // An explicit argument is used as given — it is the caller's own key. With + // none, resolve the current location through the session so a configured + // sessionKey reads the bucket evaluate() wrote, not the raw hostname. + const key = advertiserHost ?? - (windowLike === undefined ? undefined : hostFromUrl(windowLike.location.href)); + (windowLike === undefined + ? undefined + : session.resolveSessionKey(windowLike.location.href)); - if (host === undefined) { + if (key === undefined) { return failClosedDecision('missing-advertiser-host'); } - return session.shouldStandDown(host, at); + return session.shouldStandDown(key, at); } function dispose(): void { @@ -277,12 +290,14 @@ function contentSessionOptions( auditLog?: boolean; selfExemptionScope?: 'policy' | 'session'; sessionExemptionTtlMs?: number; + sessionKey?: (url: string) => string | undefined; } | undefined { const sessionOpts: { auditLog?: boolean; selfExemptionScope?: 'policy' | 'session'; sessionExemptionTtlMs?: number; + sessionKey?: (url: string) => string | undefined; } = {}; if (opts.auditLog !== undefined) { @@ -297,6 +312,10 @@ function contentSessionOptions( sessionOpts.sessionExemptionTtlMs = opts.sessionExemptionTtlMs; } + if (opts.sessionKey !== undefined) { + sessionOpts.sessionKey = opts.sessionKey; + } + return Object.keys(sessionOpts).length > 0 ? sessionOpts : undefined; } @@ -374,14 +393,6 @@ function currentWindow(): ContentWindowLike | undefined { return value; } -function hostFromUrl(value: string): string | undefined { - try { - return new URL(value).hostname.toLowerCase().replace(/\.$/, ''); - } catch { - return undefined; - } -} - function failClosedDecision(reason: string): Decision { return { standDown: true, diff --git a/src/session.ts b/src/session.ts index aa79333..1ac1425 100644 --- a/src/session.ts +++ b/src/session.ts @@ -34,6 +34,7 @@ export class StanddownSession { readonly #maxAuditEntries: number; readonly #selfExemptionScope: 'policy' | 'session'; readonly #sessionExemptionTtlMs: number; + readonly #sessionKey: ((url: string) => string | undefined) | undefined; readonly #readOnlyAuditLog: AuditEntry[] = []; #stateLock: Promise = Promise.resolve(); @@ -58,6 +59,32 @@ export class StanddownSession { * expiry and hold the exemption for the lifetime of the session state. */ sessionExemptionTtlMs?: number; + /** + * Resolves the key a navigation's stand-down state is filed under. + * Defaults to the landing hostname, which keeps every host separate. + * Return a stable advertiser key to share one session across a + * merchant's hosts — `www`, checkout subdomains, alternate ccTLDs. + * Returning `undefined` or an empty string falls back to the landing + * hostname. The key is normalized (lower-cased, trailing dot stripped) + * before use, and a resolver that throws fails the navigation closed. + * It must be deterministic: the same URL must always produce the same + * key, or a read resolves a different bucket than the write filed. + * + * Detection is unaffected: policy `advertiserHosts` rules and referrer + * classification always see the real hostname. + * + * `shouldStandDown` does not call this resolver — it looks up state by + * whatever string it is given. Pass it + * {@link StanddownSession.resolveSessionKey}'s result rather than the raw + * landing hostname, or the lookup misses the bucket and silently returns + * `no-active-standdown`. + * + * Scope this narrowly. A key groups both stand-down sessions *and* + * self-exemptions. Widening it widens suppression (safe), but it also + * widens any self-exemption granted under it (not safe) — so return a + * key no broader than the advertiser whose attribution you control. + */ + sessionKey?: (url: string) => string | undefined; }, ) { this.#store = store; @@ -65,6 +92,7 @@ export class StanddownSession { this.#maxAuditEntries = Math.max(0, opts?.maxAuditEntries ?? 1_000); this.#selfExemptionScope = opts?.selfExemptionScope ?? 'policy'; this.#sessionExemptionTtlMs = opts?.sessionExemptionTtlMs ?? 1_800_000; + this.#sessionKey = opts?.sessionKey; } async ingest( @@ -73,6 +101,24 @@ export class StanddownSession { ): Promise { const advertiserHost = hostFromUrl(signals.url); + let resolvedKey: string | undefined; + + try { + resolvedKey = this.#sessionKey?.(signals.url); + } catch (error) { + return this.#failClosedWithAudit( + signals.now, + 'ingest', + advertiserHost, + `session-key-error: ${messageFromError(error)}`, + ); + } + + // Normalize what the resolver returns: the write paths key state by + // normalizeHost, so an un-normalized key would file state under one string + // and read it back under another. An empty string is not a key. + const stateKey = resolvedKey ? normalizeHost(resolvedKey) : advertiserHost; + try { validatePolicies(policies); } catch (error) { @@ -101,18 +147,27 @@ export class StanddownSession { let effective = detection; - if (this.#selfExemptionScope === 'session' && advertiserHost) { + if (this.#selfExemptionScope === 'session' && stateKey && advertiserHost) { recordSessionExemptions( state, + stateKey, advertiserHost, detection, signals.now, this.#sessionExemptionTtlMs, ); + // The record is looked up by the bucket key, but the first argument is + // the real detected host: applySessionExemptions compares it against + // each match's advertiserHost, which detect() always sets to the actual + // landing hostname. Both sides were the same host before this option + // existed, so that comparison never filtered anything; handing it the + // bucket key instead would make it differ for every match and silently + // stop exemptions from suppressing anything whenever a resolver + // collapses hosts. effective = applySessionExemptions( advertiserHost, detection, - state.exemptions?.[advertiserHost], + state.exemptions?.[stateKey], ); } @@ -128,6 +183,7 @@ export class StanddownSession { const record = upsertSessionRecord( state, + stateKey ?? effective.strongest.advertiserHost, effective.strongest.advertiserHost, effective.strongest.policyId, matchedPolicies, @@ -143,8 +199,8 @@ export class StanddownSession { }; } - const activeDecision = advertiserHost - ? activeDecisionForHost(state, advertiserHost, signals.now) + const activeDecision = stateKey + ? activeDecisionForHost(state, stateKey, signals.now) : undefined; // A session exemption filtered out what would otherwise have stood down. @@ -172,6 +228,28 @@ export class StanddownSession { }, 'ingest'); } + /** + * The state key `ingest` files this navigation under: the `sessionKey` + * resolver's value, normalized, or the landing hostname when no resolver is + * configured or it returns `undefined`/an empty string. + * + * Feed it to {@link StanddownSession.shouldStandDown}, which never runs the + * resolver itself. `undefined` means no key could be resolved — an + * unparseable URL, or a resolver that threw. `ingest` fails closed in both + * cases, so treat it as "stand down", not as "nothing to look up". + */ + resolveSessionKey(url: string): string | undefined { + let resolvedKey: string | undefined; + + try { + resolvedKey = this.#sessionKey?.(url); + } catch { + return undefined; + } + + return resolvedKey ? normalizeHost(resolvedKey) : hostFromUrl(url); + } + async shouldStandDown( advertiserHost: string, now: number, @@ -379,12 +457,13 @@ function trimAuditLog( function upsertSessionRecord( state: StanddownState, + stateKey: string, advertiserHost: string, primaryPolicyId: string, policies: readonly StanddownPolicy[], now: number, ): SessionRecord { - const key = normalizeHost(advertiserHost); + const key = normalizeHost(stateKey); const existing = state.sessions[key]; const startedAt = existing?.startedAt ?? now; const lastActivityAt = now; @@ -394,7 +473,7 @@ function upsertSessionRecord( ? 'session-or-min' : 'inactivity-window'; const baseRecord: SessionRecord = { - advertiserHost: key, + advertiserHost: normalizeHost(advertiserHost), policyId: primaryPolicyId, startedAt, lastActivityAt, @@ -436,13 +515,17 @@ function upsertSessionRecord( } /** - * Persist scoped self-exemptions seen on this navigation for the host, so later - * param-less navigations re-apply them. Monotone: never grant an exemption while - * a stand-down is already active for the host (that would reduce existing + * Persist scoped self-exemptions seen on this navigation under its state key, so + * later param-less navigations re-apply them. Monotone: never grant an exemption + * while a stand-down is already active for that key (that would reduce existing * suppression, which a self-exemption may never do). + * + * Keyed by `stateKey` like {@link upsertSessionRecord}; `advertiserHost` is the + * real detected hostname the record carries. */ function recordSessionExemptions( state: StanddownState, + stateKey: string, advertiserHost: string, detection: Detection, now: number, @@ -454,11 +537,11 @@ function recordSessionExemptions( return; } - if (activeDecisionForHost(state, advertiserHost, now) !== undefined) { + if (activeDecisionForHost(state, stateKey, now) !== undefined) { return; } - const key = normalizeHost(advertiserHost); + const key = normalizeHost(stateKey); if (state.exemptions === undefined) { state.exemptions = {}; @@ -476,7 +559,7 @@ function recordSessionExemptions( const grantedAt = existing?.grantedAt ?? now; const record: ExemptionRecord = { - advertiserHost: key, + advertiserHost: normalizeHost(advertiserHost), policyIds: [...policyIds], networkIds: [...networkIds], grantedAt, diff --git a/src/url.ts b/src/url.ts index b0f96ae..8a65888 100644 --- a/src/url.ts +++ b/src/url.ts @@ -43,6 +43,13 @@ export interface CreateUrlStanddownOptions { * disables expiry (lifetime of the session state). */ readonly sessionExemptionTtlMs?: number; + /** + * Resolves the key a decision's stand-down state is filed under. Defaults to + * the landing hostname; return a stable advertiser key to share one session + * across a merchant's hosts. See `StanddownSession`'s `sessionKey` for the + * scoping rules and for reading state back with `resolveSessionKey`. + */ + readonly sessionKey?: (url: string) => string | undefined; readonly onDecision?: (decision: Decision, signals: Signals) => void; } @@ -180,12 +187,14 @@ function urlSessionOptions( auditLog?: boolean; selfExemptionScope?: 'policy' | 'session'; sessionExemptionTtlMs?: number; + sessionKey?: (url: string) => string | undefined; } | undefined { const sessionOpts: { auditLog?: boolean; selfExemptionScope?: 'policy' | 'session'; sessionExemptionTtlMs?: number; + sessionKey?: (url: string) => string | undefined; } = {}; if (opts.auditLog !== undefined) { @@ -200,6 +209,10 @@ function urlSessionOptions( sessionOpts.sessionExemptionTtlMs = opts.sessionExemptionTtlMs; } + if (opts.sessionKey !== undefined) { + sessionOpts.sessionKey = opts.sessionKey; + } + return Object.keys(sessionOpts).length > 0 ? sessionOpts : undefined; } diff --git a/src/webext.ts b/src/webext.ts index 3393c25..0293f69 100644 --- a/src/webext.ts +++ b/src/webext.ts @@ -67,6 +67,15 @@ export interface CreateStanddownOptions { * disables expiry (lifetime of the session state). */ readonly sessionExemptionTtlMs?: number; + /** + * Resolves the key a navigation's stand-down state is filed under. Defaults + * to the landing hostname; return a stable advertiser key to share one + * session across a merchant's hosts. See `StanddownSession`'s `sessionKey` + * for the scoping rules. The controller's `shouldStandDown` / + * `shouldStandDownForUrl` resolve through it too, so a tab or URL read stays + * on the same bucket the navigation wrote. + */ + readonly sessionKey?: (url: string) => string | undefined; } export interface StanddownWebextController { @@ -198,7 +207,9 @@ export function createStanddown( const redirectChains = new Map(); const redirectRequestIds = new Map(); const initiators = new Map(); - const tabHosts = new Map(); + // The state key each tab's last navigation was filed under — the landing + // hostname unless a sessionKey resolver widened it. + const tabStateKeys = new Map(); const hasWebRequest = typeof chromeApi?.webRequest?.onBeforeRequest?.addListener === 'function'; const hasWebNavigation = @@ -252,7 +263,7 @@ export function createStanddown( redirectChains.delete(tabId); redirectRequestIds.delete(tabId); initiators.delete(tabId); - tabHosts.delete(tabId); + tabStateKeys.delete(tabId); }; const onMessage: RuntimeMessageListener = (message, sender, sendResponse) => { @@ -299,12 +310,6 @@ export function createStanddown( tabId: number, url: string, ): Promise { - const host = hostFromUrl(url); - - if (host !== undefined) { - tabHosts.set(tabId, host); - } - const redirectChain = mode === 'webRequest' ? redirectChains.get(tabId) : undefined; const initiator = initiators.get(tabId); @@ -323,6 +328,19 @@ export function createStanddown( // non-stand-down could be a false negative — mark the coverage partial. signalCoverage: mode === 'webRequest' ? 'full' : 'partial', }); + // Remember the key ingest files this navigation under, so a later + // shouldStandDown(tabId) reads the bucket it wrote rather than the raw host. + const stateKey = session.resolveSessionKey(url); + + if (stateKey !== undefined) { + tabStateKeys.set(tabId, stateKey); + } else { + // No key for this navigation (unparseable URL, or a resolver that threw), + // so ingest fails it closed. Drop whatever the tab's previous navigation + // cached: leaving it would let shouldStandDown(tabId) answer for this + // navigation out of the previous page's bucket, fail-open. + tabStateKeys.delete(tabId); + } return session.ingest(signals, activePolicies); } @@ -331,26 +349,38 @@ export function createStanddown( tabId: number, at = now(), ): Promise { - const host = tabHosts.get(tabId) ?? (await hostForTab(chromeApi, tabId)); + const stateKey = + tabStateKeys.get(tabId) ?? + stateKeyForUrl(await urlForTab(chromeApi, tabId)); - if (host === undefined) { + if (stateKey === undefined) { return failClosedDecision('missing-tab-url'); } - return session.shouldStandDown(host, at); + return session.shouldStandDown(stateKey, at); } async function shouldStandDownForUrl( url: string, at = now(), ): Promise { - const host = hostFromUrl(url); + const stateKey = stateKeyForUrl(url); - if (host === undefined) { + if (stateKey === undefined) { return failClosedDecision('invalid-tab-url'); } - return session.shouldStandDown(host, at); + return session.shouldStandDown(stateKey, at); + } + + /** + * The bucket a URL's state lives in, for read paths that never saw the + * navigation. Resolved through the session so a configured `sessionKey` reads + * the key `ingest` wrote; `undefined` (unparseable URL, or a resolver that + * threw) fails the caller closed. + */ + function stateKeyForUrl(url: string | undefined): string | undefined { + return url === undefined ? undefined : session.resolveSessionKey(url); } async function handleMessage( @@ -438,7 +468,7 @@ export function createStanddown( redirectChains.clear(); redirectRequestIds.clear(); initiators.clear(); - tabHosts.clear(); + tabStateKeys.clear(); } return { @@ -462,12 +492,14 @@ function sessionOptions( auditLog?: boolean; selfExemptionScope?: 'policy' | 'session'; sessionExemptionTtlMs?: number; + sessionKey?: (url: string) => string | undefined; } | undefined { const sessionOpts: { auditLog?: boolean; selfExemptionScope?: 'policy' | 'session'; sessionExemptionTtlMs?: number; + sessionKey?: (url: string) => string | undefined; } = {}; if (opts.auditLog !== undefined) { @@ -482,6 +514,10 @@ function sessionOptions( sessionOpts.sessionExemptionTtlMs = opts.sessionExemptionTtlMs; } + if (opts.sessionKey !== undefined) { + sessionOpts.sessionKey = opts.sessionKey; + } + return Object.keys(sessionOpts).length > 0 ? sessionOpts : undefined; } @@ -552,7 +588,7 @@ function navigationSignals(value: { return signals; } -async function hostForTab( +async function urlForTab( chromeApi: ChromeLike | undefined, tabId: number, ): Promise { @@ -563,12 +599,12 @@ async function hostForTab( return new Promise((resolve) => { try { const result = chromeApi.tabs?.get?.(tabId, (tab) => { - resolve(hostFromUrl(tab?.url ?? tab?.pendingUrl)); + resolve(tab?.url ?? tab?.pendingUrl); }); if (isPromiseLike(result)) { result.then( - (tab) => resolve(hostFromUrl(tab?.url ?? tab?.pendingUrl)), + (tab) => resolve(tab?.url ?? tab?.pendingUrl), () => resolve(undefined), ); } @@ -596,18 +632,6 @@ function isShouldStandDownMessage( ); } -function hostFromUrl(value: string | undefined): string | undefined { - if (value === undefined) { - return undefined; - } - - try { - return new URL(value).hostname.toLowerCase().replace(/\.$/, ''); - } catch { - return undefined; - } -} - function failClosedDecision(reason: string): Decision { return { standDown: true, diff --git a/tests/content.test.ts b/tests/content.test.ts index 21c3fbc..36345b3 100644 --- a/tests/content.test.ts +++ b/tests/content.test.ts @@ -247,6 +247,40 @@ describe('content adapter', () => { controller.dispose(); }); + it('resolves an argument-less shouldStandDown through a configured sessionKey', async () => { + const controller = createContentStanddown({ + policies: [cjPolicy], + window: new FakeContentWindow( + 'https://www.merchant.example/?cjevent=abc', + '', + ), + now: () => 5_000, + // Collapse a merchant's hosts onto one advertiser key. + sessionKey: (url) => + new URL(url).hostname.endsWith('merchant.example') + ? 'merchant.example' + : undefined, + }); + + await expect(controller.ready).resolves.toMatchObject({ + standDown: true, + policyId: 'cj', + }); + + // The read resolves the current location the same way the evaluation did, + // so it lands on the bucket state was filed under. + await expect(controller.shouldStandDown()).resolves.toMatchObject({ + standDown: true, + policyId: 'cj', + }); + // And that bucket is the resolver's key, not the www host it landed on. + await expect( + controller.shouldStandDown('merchant.example'), + ).resolves.toMatchObject({ standDown: true, policyId: 'cj' }); + + controller.dispose(); + }); + it('fails closed when content storage fails', async () => { const controller = createContentStanddown({ policies: [cjPolicy], diff --git a/tests/session-key.test.ts b/tests/session-key.test.ts new file mode 100644 index 0000000..a9b139d --- /dev/null +++ b/tests/session-key.test.ts @@ -0,0 +1,329 @@ +import { describe, expect, it } from 'vitest'; +import { + type Behavior, + MemoryStateStore, + type Signals, + type StanddownPolicy, + StanddownSession, +} from '../src'; + +const behaviors = [ + 'suppress-prompts', + 'no-cookie-write', + 'no-redirect', + 'no-background-tracking', +] as const satisfies readonly Behavior[]; + +const netPolicy: StanddownPolicy = { + id: 'alfa', + schemaVersion: 3, + policyVersion: '0.0.0-test', + network: { id: 'alfa', name: 'alfa network' }, + detection: { + landingParams: [{ anyOf: [{ allOf: [{ name: 'alfa_click' }] }] }], + cookiePatterns: [{ name: 'alfa_cookie', match: 'exact' }], + }, + standdown: { + scope: 'advertiser', + sessionRule: 'session-or-min', + minDurationMs: 1_800_000, + behaviors, + }, + activation: { mode: 'user-click' }, + metadata: { + sourceUrl: 'https://example.com/policy', + lastVerified: '2026-07-11', + }, +} as const satisfies StanddownPolicy; + +/** Collapses a merchant's www/checkout hosts onto one advertiser key. */ +function merchantKey(url: string): string | undefined { + const host = new URL(url).hostname.toLowerCase(); + return host.endsWith('merchant.example') ? 'merchant.example' : host; +} + +/** The same key, returned un-normalized: mixed case and a trailing dot. */ +function messyMerchantKey(url: string): string | undefined { + const host = new URL(url).hostname.toLowerCase(); + return host.endsWith('merchant.example') ? 'Merchant.Example.' : host; +} + +describe('sessionKey', () => { + it('defaults to the landing hostname, keeping hosts isolated', async () => { + const session = new StanddownSession(new MemoryStateStore()); + + await expect( + session.ingest( + { url: 'https://www.merchant.example/p?alfa_click=1', now: 0 }, + [netPolicy], + ), + ).resolves.toMatchObject({ standDown: true, policyId: 'alfa' }); + + // Without a resolver the checkout host is a different bucket. + await expect( + session.shouldStandDown('checkout.merchant.example', 1_000), + ).resolves.toMatchObject({ standDown: false }); + }); + + it('shares a stand-down session across hosts that resolve to one key', async () => { + const session = new StanddownSession(new MemoryStateStore(), { + sessionKey: merchantKey, + }); + + await expect( + session.ingest( + { url: 'https://www.merchant.example/p?alfa_click=1', now: 0 }, + [netPolicy], + ), + ).resolves.toMatchObject({ standDown: true, policyId: 'alfa' }); + + // The checkout host now reads the same bucket. + await expect( + session.ingest({ url: 'https://checkout.merchant.example/pay', now: 1_000 }, [ + netPolicy, + ]), + ).resolves.toMatchObject({ standDown: true, policyId: 'alfa' }); + }); + + it('shares a session self-exemption across hosts that resolve to one key', async () => { + const session = new StanddownSession(new MemoryStateStore(), { + sessionKey: merchantKey, + selfExemptionScope: 'session', + }); + const selfPatterns = [ + { name: 'alfa_click', value: 'me', match: 'equals' as const, networkId: 'alfa' }, + ]; + + await expect( + session.ingest( + { url: 'https://www.merchant.example/p?alfa_click=me', now: 0, selfPatterns }, + [netPolicy], + ), + ).resolves.toMatchObject({ standDown: false }); + + // The exemption follows the merchant, so the lingering cookie on the checkout + // host is still attributed to us rather than standing us down. + await expect( + session.ingest( + { + url: 'https://checkout.merchant.example/pay', + now: 1_000, + cookieNames: ['alfa_cookie'], + }, + [netPolicy], + ), + ).resolves.toMatchObject({ standDown: false, reason: 'self-exempted-session' }); + }); + + it('normalizes the resolved key so exemptions are written and read under one bucket', async () => { + const store = new MemoryStateStore(); + const session = new StanddownSession(store, { + sessionKey: messyMerchantKey, + selfExemptionScope: 'session', + }); + const selfPatterns = [ + { name: 'alfa_click', value: 'me', match: 'equals' as const, networkId: 'alfa' }, + ]; + + await expect( + session.ingest( + { url: 'https://www.merchant.example/p?alfa_click=me', now: 0, selfPatterns }, + [netPolicy], + ), + ).resolves.toMatchObject({ standDown: false }); + + // The exemption is filed under the normalized key and read back under the + // same one — an un-normalized key would stand us down against our own + // lingering cookie on the checkout host. + await expect( + session.ingest( + { + url: 'https://checkout.merchant.example/pay', + now: 1_000, + cookieNames: ['alfa_cookie'], + }, + [netPolicy], + ), + ).resolves.toMatchObject({ standDown: false, reason: 'self-exempted-session' }); + + const state = await store.load(); + expect(Object.keys(state?.exemptions ?? {})).toEqual(['merchant.example']); + // Like SessionRecord, the exemption record keeps the real landing host. + expect(state?.exemptions?.['merchant.example']?.advertiserHost).toBe( + 'www.merchant.example', + ); + }); + + it('fails closed when the resolver throws', async () => { + const session = new StanddownSession(new MemoryStateStore(), { + sessionKey: () => { + throw new Error('resolver exploded'); + }, + }); + + const decision = await session.ingest( + { url: 'https://www.merchant.example/p?alfa_click=1', now: 0 }, + [netPolicy], + ); + + expect(decision).toMatchObject({ + standDown: true, + reason: 'session-key-error: resolver exploded', + }); + expect(decision.behaviors).toEqual(behaviors); + + const audit = await session.exportAuditLog(); + expect(audit[0]).toMatchObject({ + action: 'ingest', + advertiserHost: 'www.merchant.example', + }); + }); + + it('fails closed when a URL-parsing resolver meets a malformed URL', async () => { + // merchantKey calls new URL(), which throws here — ingest must still settle + // on a decision rather than reject. + const session = new StanddownSession(new MemoryStateStore(), { + sessionKey: merchantKey, + }); + + await expect( + session.ingest({ url: 'not a url', now: 0 }, [netPolicy]), + ).resolves.toMatchObject({ standDown: true }); + }); + + it('falls back to the landing hostname when the resolver returns an empty string', async () => { + const store = new MemoryStateStore(); + const session = new StanddownSession(store, { sessionKey: () => '' }); + + await expect( + session.ingest( + { url: 'https://www.merchant.example/p?alfa_click=1', now: 0 }, + [netPolicy], + ), + ).resolves.toMatchObject({ standDown: true, policyId: 'alfa' }); + + // An empty key is no key: filing state under '' would make it unreadable, + // since every read path treats '' as "no key at all". + const state = await store.load(); + expect(Object.keys(state?.sessions ?? {})).toEqual(['www.merchant.example']); + await expect( + session.shouldStandDown('www.merchant.example', 1_000), + ).resolves.toMatchObject({ standDown: true, policyId: 'alfa' }); + }); + + it('resolveSessionKey returns the key ingest files state under', async () => { + const session = new StanddownSession(new MemoryStateStore(), { + sessionKey: messyMerchantKey, + }); + const signals: Signals = { + url: 'https://www.merchant.example/p?alfa_click=1', + now: 0, + }; + const key = session.resolveSessionKey(signals.url); + + expect(key).toBe('merchant.example'); + + await expect(session.ingest(signals, [netPolicy])).resolves.toMatchObject({ + standDown: true, + policyId: 'alfa', + }); + + // Feeding it straight back to shouldStandDown is the documented read path. + await expect( + session.shouldStandDown(key ?? '', 1_000), + ).resolves.toMatchObject({ standDown: true, policyId: 'alfa' }); + }); + + it('resolveSessionKey falls back to the landing host and reports unresolvable keys', async () => { + const plain = new StanddownSession(new MemoryStateStore()); + + expect(plain.resolveSessionKey('https://WWW.Merchant.Example./p')).toBe( + 'www.merchant.example', + ); + expect(plain.resolveSessionKey('not a url')).toBeUndefined(); + + const emptyKey = new StanddownSession(new MemoryStateStore(), { + sessionKey: () => '', + }); + + expect(emptyKey.resolveSessionKey('https://www.merchant.example/p')).toBe( + 'www.merchant.example', + ); + + // A resolver that throws leaves no key to read by. ingest fails closed on + // the same navigation, so undefined means "stand down", not "nothing filed". + const throwing = new StanddownSession(new MemoryStateStore(), { + sessionKey: () => { + throw new Error('resolver exploded'); + }, + }); + + expect( + throwing.resolveSessionKey('https://www.merchant.example/p'), + ).toBeUndefined(); + }); + + it('falls back to the landing hostname when the resolver returns undefined', async () => { + const session = new StanddownSession(new MemoryStateStore(), { + sessionKey: () => undefined, + }); + + await expect( + session.ingest( + { url: 'https://www.merchant.example/p?alfa_click=1', now: 0 }, + [netPolicy], + ), + ).resolves.toMatchObject({ standDown: true, policyId: 'alfa' }); + + await expect( + session.shouldStandDown('www.merchant.example', 1_000), + ).resolves.toMatchObject({ standDown: true, policyId: 'alfa' }); + }); + + it('shouldStandDown reads by the resolved key, not the landing host', async () => { + const session = new StanddownSession(new MemoryStateStore(), { + sessionKey: merchantKey, + }); + + await expect( + session.ingest( + { url: 'https://www.merchant.example/p?alfa_click=1', now: 0 }, + [netPolicy], + ), + ).resolves.toMatchObject({ standDown: true, policyId: 'alfa' }); + + // ingest filed state under the resolved key, so a lookup by that key finds it. + await expect( + session.shouldStandDown('merchant.example', 1_000), + ).resolves.toMatchObject({ standDown: true, policyId: 'alfa' }); + + // shouldStandDown does not run the resolver, so the raw landing host it was + // actually detected on misses the bucket entirely — this is the documented + // contract: callers that configure sessionKey pass it + // resolveSessionKey(url), not the landing hostname. + await expect( + session.shouldStandDown('www.merchant.example', 1_000), + ).resolves.toMatchObject({ standDown: false, reason: 'no-active-standdown' }); + }); + + it('files state under the resolved key while SessionRecord.advertiserHost keeps the real landing host', async () => { + const store = new MemoryStateStore(); + const session = new StanddownSession(store, { + sessionKey: merchantKey, + }); + + await session.ingest( + { url: 'https://www.merchant.example/p?alfa_click=1', now: 0 }, + [netPolicy], + ); + + const state = await store.load(); + expect(Object.keys(state?.sessions ?? {})).toEqual(['merchant.example']); + expect(state?.sessions['merchant.example']?.advertiserHost).toBe( + 'www.merchant.example', + ); + + const audit = await session.exportAuditLog(); + expect(audit[0]?.advertiserHost).toBe('www.merchant.example'); + }); +}); diff --git a/tests/url.test.ts b/tests/url.test.ts index fd1b248..92bb257 100644 --- a/tests/url.test.ts +++ b/tests/url.test.ts @@ -170,6 +170,27 @@ describe('url adapter', () => { expect(decisions.at(-1)?.referrerClass).toBe('own-site'); }); + it('shares stand-down state across hosts that resolve to one session key', async () => { + const controller = createUrlStanddown({ + policies: [cjPolicy], + now: () => 1_000, + // Collapse a merchant's hosts onto one advertiser key. + sessionKey: (url) => + new URL(url).hostname.endsWith('merchant.example') + ? 'merchant.example' + : undefined, + }); + + await expect( + controller.decideForUrl('https://www.merchant.example/?cjevent=abc'), + ).resolves.toMatchObject({ standDown: true, policyId: 'cj' }); + + // A sibling host reads the bucket the first decision filed state under. + await expect( + controller.decideForUrl('https://checkout.merchant.example/pay'), + ).resolves.toMatchObject({ standDown: true, policyId: 'cj' }); + }); + it('fails toward standing down on a missing URL', async () => { const controller = createUrlStanddown({ policies: [cjPolicy], diff --git a/tests/webext.test.ts b/tests/webext.test.ts index 531c631..1418dad 100644 --- a/tests/webext.test.ts +++ b/tests/webext.test.ts @@ -12,6 +12,7 @@ import { type FetchLike, type RuntimeMessageSenderLike, type StanddownMessageResponse, + type TabLike, type WebNavigationCommittedDetails, type WebRequestBeforeDetails, } from '../src/webext'; @@ -418,6 +419,119 @@ describe('webext adapter', () => { controller.dispose(); }); + it('reads tabs and URLs through a configured sessionKey resolver', async () => { + const chrome = fakeChrome({ webRequest: false }); + const controller = createStanddown({ + policies: [cjPolicy], + chrome, + now: () => 9_000, + // Collapse a merchant's hosts onto one advertiser key. + sessionKey: (url) => + new URL(url).hostname.endsWith('merchant.example') + ? 'merchant.example' + : undefined, + }); + + chrome.webNavigation?.onCommitted?.emit({ + tabId: 12, + frameId: 0, + url: 'https://www.merchant.example/?cjevent=abc', + }); + await flushPromises(); + + // The tab read finds the bucket the navigation was filed under, not the + // www host it landed on. + await expect(controller.shouldStandDown(12, 9_001)).resolves.toMatchObject({ + standDown: true, + policyId: 'cj', + }); + // So does a URL read for a sibling host that never navigated itself. + await expect( + controller.shouldStandDownForUrl( + 'https://checkout.merchant.example/pay', + 9_002, + ), + ).resolves.toMatchObject({ standDown: true, policyId: 'cj' }); + + controller.dispose(); + }); + + it('reads a tab it never saw navigate through the tab URL', async () => { + const chrome = fakeChrome({ + webRequest: false, + tabUrls: new Map([[21, 'https://merchant.example/cart']]), + }); + const controller = createStanddown({ + policies: [cjPolicy], + chrome, + now: () => 10_000, + }); + + // Tab 30 lands on the merchant and files an active stand-down. + chrome.webNavigation?.onCommitted?.emit({ + tabId: 30, + frameId: 0, + url: 'https://merchant.example/?cjevent=abc', + }); + await flushPromises(); + + // Tab 21 has no cached key, so the read falls back to chrome.tabs.get and + // resolves the URL that tab is currently on. + await expect(controller.shouldStandDown(21, 10_001)).resolves.toMatchObject({ + standDown: true, + policyId: 'cj', + }); + + controller.dispose(); + }); + + it('drops a cached tab key when a navigation resolves to no key', async () => { + const tabUrls = new Map([[12, 'https://www.merchant.example/']]); + const chrome = fakeChrome({ webRequest: false, tabUrls }); + const controller = createStanddown({ + policies: [cjPolicy], + chrome, + now: () => 12_000, + sessionKey: (url) => + new URL(url).hostname.endsWith('merchant.example') + ? 'merchant.example' + : undefined, + }); + + // A first navigation caches the bucket it filed state under. Nothing is + // standing down in it. + chrome.webNavigation?.onCommitted?.emit({ + tabId: 12, + frameId: 0, + url: 'https://www.merchant.example/', + }); + await flushPromises(); + await expect(controller.shouldStandDown(12, 12_001)).resolves.toMatchObject({ + standDown: false, + reason: 'no-active-standdown', + }); + + // The next navigation in the same tab is unparseable, so the resolver + // throws and ingest fails it closed. + tabUrls.set(12, 'not a url'); + chrome.webNavigation?.onCommitted?.emit({ + tabId: 12, + frameId: 0, + url: 'not a url', + }); + await flushPromises(); + + // The stale key must not answer for it — that would report the previous + // page's bucket for a navigation that just failed closed. The read + // re-resolves and fails closed too. + await expect(controller.shouldStandDown(12, 12_002)).resolves.toMatchObject({ + standDown: true, + reason: 'missing-tab-url', + }); + + controller.dispose(); + }); + it('applies verified refresh bundles outside the decision path', async () => { const keyPair = await createSigningKeyPair(); const updatedPolicy = additivePolicy(cjPolicy); @@ -507,6 +621,8 @@ function fakeChrome(opts: { storageSession?: boolean; local?: FakeChromeStorageArea; session?: FakeChromeStorageArea; + /** Present ⇒ chrome.tabs.get exists and answers from this map. */ + tabUrls?: Map; }): ChromeLike & { webRequest?: { onBeforeRequest: FakeEvent<[WebRequestBeforeDetails]> }; webNavigation?: { onCommitted: FakeEvent<[WebNavigationCommittedDetails]> }; @@ -523,6 +639,23 @@ function fakeChrome(opts: { } { const includeWebNavigation = opts.webNavigation ?? true; const includeStorageSession = opts.storageSession ?? true; + const tabUrls = opts.tabUrls; + const tabs: { + onRemoved: FakeEvent<[number]>; + get?: ( + tabId: number, + callback?: (tab: TabLike | undefined) => void, + ) => undefined; + } = { onRemoved: new FakeEvent<[number]>() }; + + if (tabUrls !== undefined) { + tabs.get = (tabId, callback) => { + const url = tabUrls.get(tabId); + callback?.(url === undefined ? undefined : { id: tabId, url }); + return undefined; + }; + } + const chrome = { runtime: { onMessage: new FakeMessageEvent(), @@ -530,9 +663,7 @@ function fakeChrome(opts: { storage: { local: opts.local ?? new FakeChromeStorageArea(), }, - tabs: { - onRemoved: new FakeEvent<[number]>(), - }, + tabs, }; const withWebNavigation = includeWebNavigation ? {