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 ? {