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
17 changes: 16 additions & 1 deletion src/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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) {
Expand All @@ -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;
}

Expand Down
45 changes: 38 additions & 7 deletions src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown> = Promise.resolve();

Expand All @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -164,7 +179,7 @@ export class StanddownSession {
return this.#withState(
now,
(state) => {
pruneExpiredSessions(state, now);
pruneExpiredState(state, now);

return {
decision:
Expand All @@ -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') {
Expand Down Expand Up @@ -431,6 +446,7 @@ function recordSessionExemptions(
advertiserHost: string,
detection: Detection,
now: number,
ttlMs: number,
): void {
const scopes = detection.selfExemptScopes;

Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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 {
Expand Down
9 changes: 7 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
17 changes: 16 additions & 1 deletion src/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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) {
Expand All @@ -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;
}

Expand Down
17 changes: 16 additions & 1 deletion src/webext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
}

Expand Down
71 changes: 71 additions & 0 deletions tests/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down