Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 37 additions & 3 deletions src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -469,8 +469,20 @@ function recordSessionExemptions(
/**
* Re-apply a host's persisted session exemptions: drop matched rules whose
* policy/network was exempted for this host, then recompute the strongest match.
* `disabled-host` matches are never dropped — a hard-disabled host stands down
* regardless of any self-exemption.
*
* Two matches are never dropped, even when their scope is exempted:
* - `disabled-host` — a hard-disabled host stands down regardless of any
* self-exemption.
* - a *fresh competing attribution param* (`landing-param` / `redirect-domain`)
* that is not itself a self-match on this navigation. A persisted exemption
* means "our attribution owns this host's session", which is meant to re-cover
* ambient lingering signals (a first-party cookie, the initiator). It must not
* swallow a later click that carries *someone else's* attribution id for the
* same network, or a competitor could hijack an already-exempted host. This
* only bites when self-patterns are value-specific (the documented, correct
* way to author them); a name-only self-pattern already claims every value of
* that param as ours, so a competing value looks like a self-match and is
* suppressed — the same footgun the self-exemption docs already warn about.
*/
function applySessionExemptions(
advertiserHost: string,
Expand All @@ -485,6 +497,14 @@ function applySessionExemptions(
const policyIds = new Set(record.policyIds);
const networkIds = new Set(record.networkIds);

// Scopes for which THIS navigation carried our own (value-matched) attribution.
const selfPolicyIds = new Set(
detection.selfExemptScopes?.map((scope) => scope.policyId),
);
const selfNetworkIds = new Set(
detection.selfExemptScopes?.map((scope) => scope.networkId),
);

const filtered = detection.matched.filter((match) => {
if (match.kind === 'disabled-host') {
return true;
Expand All @@ -494,7 +514,21 @@ function applySessionExemptions(
return true;
}

return !(policyIds.has(match.policyId) || networkIds.has(match.networkId));
if (!(policyIds.has(match.policyId) || networkIds.has(match.networkId))) {
return true;
}

const isAttributionParam =
match.kind === 'landing-param' || match.kind === 'redirect-domain';
const isSelfThisNavigation =
selfPolicyIds.has(match.policyId) || selfNetworkIds.has(match.networkId);

// Keep (do not suppress) a competing attribution param that isn't ours.
if (isAttributionParam && !isSelfThisNavigation) {
return true;
}

return false;
});

if (filtered.length === detection.matched.length) {
Expand Down
61 changes: 61 additions & 0 deletions tests/self-exemption-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,67 @@ describe('selfExemptionScope', () => {
).resolves.toMatchObject({ standDown: true, policyId: 'beta' });
});

it('session scope: a competing SAME-network click (different value) still stands down', async () => {
const session = new StanddownSession(new MemoryStateStore(), {
selfExemptionScope: 'session',
});
// Value-specific self-pattern (the documented, correct way to author one).
const selfPatterns = [
{ name: 'alfa_click', value: 'me', match: 'equals' as const, networkId: 'alfa' },
];

// Our own click (value-matched) → exempt, records the host exemption.
await expect(
session.ingest(
{ url: `${MERCHANT}/p?alfa_click=me`, now: 0, selfPatterns },
[alfa],
),
).resolves.toMatchObject({ standDown: false });

// A competitor's alfa click on the same host carries a DIFFERENT value, so it
// is not ours. The persisted exemption must not swallow it.
await expect(
session.ingest(
{ url: `${MERCHANT}/p?alfa_click=rival`, now: 1_000, selfPatterns },
[alfa],
),
).resolves.toMatchObject({ standDown: true, policyId: 'alfa' });
});

it('session scope: our own value keeps re-exempting, and the lingering cookie stays ours', async () => {
const session = new StanddownSession(new MemoryStateStore(), {
selfExemptionScope: 'session',
});
const selfPatterns = [
{ name: 'alfa_click', value: 'me', match: 'equals' as const, networkId: 'alfa' },
];

await expect(
session.ingest(
{ url: `${MERCHANT}/p?alfa_click=me`, now: 0, selfPatterns },
[alfa],
),
).resolves.toMatchObject({ standDown: false });

// Our own click id re-appearing is a self-match, so it stays exempt (the fix
// does not over-correct and stand us down on our own attribution).
await expect(
session.ingest(
{ url: `${MERCHANT}/p?alfa_click=me`, now: 1_000, selfPatterns },
[alfa],
),
).resolves.toMatchObject({ standDown: false });

// The lingering first-party cookie is ambient, not a competing param, so it
// is still re-attributed to us — the ignore_param case is preserved.
await expect(
session.ingest(
{ url: `${MERCHANT}/checkout`, now: 2_000, cookieNames: ['alfa_cookie'] },
[alfa],
),
).resolves.toMatchObject({ standDown: false, reason: 'self-exempted-session' });
});

it('session scope never lifts an already-active stand-down (monotone)', async () => {
const store = new MemoryStateStore();
const session = new StanddownSession(store, {
Expand Down