From 1d78d3e7418022fcdc44c4052a3a487c8f627848 Mon Sep 17 00:00:00 2001 From: Andrew Paseltiner Date: Tue, 12 May 2026 11:18:29 -0400 Subject: [PATCH] Add activation to simulator --- impl/e2e-tests/CONFIG.json | 1 + impl/e2e-tests/activation.json | 61 +++++++++++++++++++++++++ impl/e2e.schema.json | 47 +++++++++++++++++++ impl/src/backend.ts | 82 ++++++++++++++++++++++++++++++++-- impl/src/clear.test.ts | 62 ++++++++++++++++--------- impl/src/e2e.test.ts | 35 ++++++++++++++- impl/src/fixture.ts | 4 ++ impl/src/simulator.ts | 8 +++- 8 files changed, 273 insertions(+), 27 deletions(-) create mode 100644 impl/e2e-tests/activation.json diff --git a/impl/e2e-tests/CONFIG.json b/impl/e2e-tests/CONFIG.json index 5e3da7d..e4f87dd 100644 --- a/impl/e2e-tests/CONFIG.json +++ b/impl/e2e-tests/CONFIG.json @@ -3,6 +3,7 @@ "Values here do not meet minimums in the spec.", "Values are kept small so tests for limits can be small." ], + "activationSeconds": 1000, "aggregationServices": { "https://agg-service.example": "dap-18-histogram" }, diff --git a/impl/e2e-tests/activation.json b/impl/e2e-tests/activation.json new file mode 100644 index 0000000..7886d15 --- /dev/null +++ b/impl/e2e-tests/activation.json @@ -0,0 +1,61 @@ +{ + "events": [ + { + "seconds": 1, + "event": "updateActivationTimestamp", + "topLevelTraversable": "a" + }, + { + "seconds": 1002, + "site": "publisher.example", + "event": "saveImpression", + "topLevelTraversable": "a", + "options": { "histogramIndex": 1 }, + "expectedError": { + "error": "DOMException", + "name": "NotAllowedError" + } + }, + { + "seconds": 1003, + "site": "advertiser.example", + "event": "measureConversion", + "topLevelTraversable": "a", + "options": { + "aggregationService": "https://agg-service.example", + "histogramSize": 3, + "value": 5, + "maxValue": 10 + }, + "expected": { + "error": "DOMException", + "name": "NotAllowedError" + } + }, + { + "seconds": 1004, + "event": "updateActivationTimestamp", + "topLevelTraversable": "b" + }, + { + "seconds": 2004, + "site": "publisher.example", + "event": "saveImpression", + "topLevelTraversable": "b", + "options": { "histogramIndex": 1 } + }, + { + "seconds": 2005, + "site": "advertiser.example", + "event": "measureConversion", + "topLevelTraversable": "b", + "options": { + "aggregationService": "https://agg-service.example", + "histogramSize": 3, + "value": 5, + "maxValue": 10 + }, + "expected": [0, 5, 0] + } + ] +} diff --git a/impl/e2e.schema.json b/impl/e2e.schema.json index 515fdfc..a71f32b 100644 --- a/impl/e2e.schema.json +++ b/impl/e2e.schema.json @@ -8,6 +8,10 @@ "$comment": { "$ref": "#/$defs/comment" }, + "activationSeconds": { + "type": "number", + "minimum": 1 + }, "aggregationServices": { "type": "object", "additionalProperties": { @@ -77,6 +81,7 @@ } }, "required": [ + "activationSeconds", "aggregationServices", "globalPrivacyBudgetPerEpoch", "impressionSiteQuotaPerEpoch", @@ -115,6 +120,9 @@ }, "options": { "$ref": "#/$defs/AttributionImpressionOptions" + }, + "topLevelTraversable": { + "type": "string" } }, "required": ["options"], @@ -144,6 +152,9 @@ }, "options": { "$ref": "#/$defs/AttributionConversionOptions" + }, + "topLevelTraversable": { + "type": "string" } }, "required": ["expected", "options"], @@ -191,6 +202,42 @@ } }, "unevaluatedProperties": false + }, + { + "$ref": "#/$defs/commonEvent", + "properties": { + "event": { + "const": "updateActivationTimestamp" + }, + "topLevelTraversable": { + "type": "string" + } + }, + "required": ["topLevelTraversable"], + "unevaluatedProperties": false + }, + { + "$ref": "#/$defs/commonEvent", + "properties": { + "event": { + "const": "updateActivationOnNavigate" + }, + "initiatorOrigin": { + "type": "string" + }, + "responseOrigin": { + "type": "string" + }, + "topLevelTraversable": { + "type": "string" + } + }, + "required": [ + "initiatorOrigin", + "responseOrigin", + "topLevelTraversable" + ], + "unevaluatedProperties": false } ] } diff --git a/impl/src/backend.ts b/impl/src/backend.ts index 5db179d..7f03a11 100644 --- a/impl/src/backend.ts +++ b/impl/src/backend.ts @@ -25,6 +25,15 @@ interface Impression { priority: number; } +class TopLevelTraversable { + attributionEnabled: boolean = false; + attributionActivationTimestamp: Temporal.Instant; + + constructor(now: Temporal.Instant) { + this.attributionActivationTimestamp = now; + } +} + interface PrivacyBudgetKey { epoch: number; site: string; @@ -109,6 +118,7 @@ export interface Delegate { readonly privacyBudgetEpoch: Temporal.Duration; readonly impressionSiteQuotaPerEpoch: number; readonly globalPrivacyBudgetPerEpoch: number; + readonly activationDuration: Temporal.Duration; now(): Temporal.Instant; fairlyAllocateCreditFraction(): number; @@ -168,6 +178,10 @@ export class Backend { #impressionSiteQuotaStore: PrivacyBudgetStoreEntry[] = []; #lastBrowsingHistoryClear: Temporal.Instant | null = null; + // Outside the simulator, this information is owned by the browser's actual + // top-level traversables, but is owned by the simulator here for simplicity. + readonly #topLevelTraversables = new Map(); + constructor(delegate: Delegate) { this.#delegate = delegate; } @@ -192,7 +206,66 @@ export class Backend { return this.#lastBrowsingHistoryClear; } + #getOrCreateTopLevelTraversable(topKey: string): TopLevelTraversable { + let top = this.#topLevelTraversables.get(topKey); + if (top === undefined) { + top = new TopLevelTraversable(this.#delegate.now()); + this.#topLevelTraversables.set(topKey, top); + } + return top; + } + + #checkAttributionAPIActivation(topKey: string | undefined): void { + // Since most logic doesn't care about activation, provide a simple way to + // opt out of it. (This doesn't exist in the actual specification.) + if (topKey === undefined) { + return; + } + + const top = this.#getOrCreateTopLevelTraversable(topKey); + if (top.attributionEnabled) { + return; + } + if ( + Temporal.Instant.compare( + top.attributionActivationTimestamp.add( + this.#delegate.activationDuration, + ), + this.#delegate.now(), + ) < 0 + ) { + throw new DOMException("invalid last activation", "NotAllowedError"); + } + top.attributionActivationTimestamp = UNIX_EPOCH; + top.attributionEnabled = true; + } + + // Outside the simulator, this would be called when: + // 1. "a user interaction cuases firing of an activation trigger input event + // in a Document" + // 2. "navigation starts, and the user navigation involvement is browser UI" + updateActivationTimestamp(topKey: string): void { + this.#getOrCreateTopLevelTraversable( + topKey, + ).attributionActivationTimestamp = this.#delegate.now(); + } + + // Outside the simulator, this would be called "when a top-level traversable + // is navigated as part of the navigate algorithm, after the document state is + // finalized and the origin of the response is known." + updateActivationOnNavigate( + topKey: string, + initiatorOrigin: string, + responseOrigin: string, + ): void { + if (parseSite(initiatorOrigin) == parseSite(responseOrigin)) { + return; + } + this.#getOrCreateTopLevelTraversable(topKey).attributionEnabled = false; + } + saveImpression( + topKey: string | undefined, impressionSite: string, intermediarySite: string | undefined, { @@ -213,7 +286,7 @@ export class Backend { assert(isUnsignedLong(lifetimeDays)); assert(isLong(priority)); - const timestamp = this.#delegate.now(); + this.#checkAttributionAPIActivation(topKey); const maxHistogramSize = this.#delegate.maxHistogramSize; if (histogramIndex >= maxHistogramSize) { @@ -248,7 +321,7 @@ export class Backend { intermediarySite, conversionSites: parsedConversionSites, conversionCallers: parsedConversionCallers, - timestamp, + timestamp: this.#delegate.now(), lifetime: days(lifetimeDays), histogramIndex, priority, @@ -353,6 +426,7 @@ export class Backend { } measureConversion( + topKey: string | undefined, topLevelSite: string, intermediarySite: string | undefined, options: AttributionConversionOptions, @@ -360,7 +434,7 @@ export class Backend { assert(isValidSite(topLevelSite)); assert(intermediarySite === undefined || isValidSite(intermediarySite)); - const now = this.#delegate.now(); + this.#checkAttributionAPIActivation(topKey); const validatedOptions = this.#validateConversionOptions(options); @@ -368,7 +442,7 @@ export class Backend { ? this.#doAttributionAndFillHistogram( topLevelSite, intermediarySite, - now, + this.#delegate.now(), validatedOptions, ) : allZeroHistogram(validatedOptions.histogramSize); diff --git a/impl/src/clear.test.ts b/impl/src/clear.test.ts index 991c5a3..7b21c03 100644 --- a/impl/src/clear.test.ts +++ b/impl/src/clear.test.ts @@ -29,7 +29,7 @@ const siteTable: readonly SiteTableEntry[] = [ function setupImpressions(config?: TestConfig): Backend { const backend = makeBackend(config); for (const entry of siteTable) { - backend.saveImpression(entry.impression, undefined, { + backend.saveImpression(/*top=*/ undefined, entry.impression, undefined, { histogramIndex: 1, conversionSites: entry.conversion, }); @@ -45,11 +45,16 @@ void test("clear-site-state", () => { assert.throws(() => backend.clearState([], false)); // Run one query with the affected site. - const before = backend.measureConversion("conv-one.example", undefined, { - aggregationService: Object.keys(defaultConfig.aggregationServices)[0]!, - histogramSize: defaultConfig.maxHistogramSize, - epsilon: defaultConfig.perSitePrivacyBudget / 1e6 / 10, - }); + const before = backend.measureConversion( + /*top=*/ undefined, + "conv-one.example", + undefined, + { + aggregationService: Object.keys(defaultConfig.aggregationServices)[0]!, + histogramSize: defaultConfig.maxHistogramSize, + epsilon: defaultConfig.perSitePrivacyBudget / 1e6 / 10, + }, + ); assert.ok(before.unencryptedHistogram!.some((v) => v > 0)); backend.clearState(["conv-one.example"], false); @@ -66,11 +71,16 @@ void test("clear-site-state", () => { ); // Re-run a query and it should return an all zero result. - const after = backend.measureConversion("conv-one.example", undefined, { - aggregationService: Object.keys(defaultConfig.aggregationServices)[0]!, - histogramSize: defaultConfig.maxHistogramSize, - epsilon: defaultConfig.perSitePrivacyBudget / 1e6 / 10, - }); + const after = backend.measureConversion( + /*top=*/ undefined, + "conv-one.example", + undefined, + { + aggregationService: Object.keys(defaultConfig.aggregationServices)[0]!, + histogramSize: defaultConfig.maxHistogramSize, + epsilon: defaultConfig.perSitePrivacyBudget / 1e6 / 10, + }, + ); assert.ok(after.unencryptedHistogram!.every((v) => v === 0)); // And all entries in the privacy budget table are for the cleared site. @@ -111,11 +121,16 @@ void test("forget-one-site-conversions", () => { const now = Temporal.Instant.from("2025-01-01T00:00Z"); const backend = setupImpressions({ now, ...defaultConfig }); - const before = backend.measureConversion("conv-one.example", undefined, { - aggregationService: Object.keys(defaultConfig.aggregationServices)[0]!, - histogramSize: defaultConfig.maxHistogramSize, - epsilon: defaultConfig.perSitePrivacyBudget / 1e6 / 10, - }); + const before = backend.measureConversion( + /*top=*/ undefined, + "conv-one.example", + undefined, + { + aggregationService: Object.keys(defaultConfig.aggregationServices)[0]!, + histogramSize: defaultConfig.maxHistogramSize, + epsilon: defaultConfig.perSitePrivacyBudget / 1e6 / 10, + }, + ); assert.ok(before.unencryptedHistogram!.some((v) => v > 0)); assert.ok(backend.privacyBudgetEntries.length > 0); @@ -128,11 +143,16 @@ void test("forget-one-site-conversions", () => { assert.deepEqual(backend.lastBrowsingHistoryClear, now); // Re-run a query and it should return an all zero result. - const after = backend.measureConversion("conv-one.example", undefined, { - aggregationService: Object.keys(defaultConfig.aggregationServices)[0]!, - histogramSize: defaultConfig.maxHistogramSize, - epsilon: defaultConfig.perSitePrivacyBudget / 1e6 / 10, - }); + const after = backend.measureConversion( + /*top=*/ undefined, + "conv-one.example", + undefined, + { + aggregationService: Object.keys(defaultConfig.aggregationServices)[0]!, + histogramSize: defaultConfig.maxHistogramSize, + epsilon: defaultConfig.perSitePrivacyBudget / 1e6 / 10, + }, + ); assert.ok(after.unencryptedHistogram!.every((v) => v === 0)); // Privacy budget entries aren't added; this epoch is off-limits. diff --git a/impl/src/e2e.test.ts b/impl/src/e2e.test.ts index 7ab5dd2..9619a87 100644 --- a/impl/src/e2e.test.ts +++ b/impl/src/e2e.test.ts @@ -25,7 +25,9 @@ type Event = | ClearImpressionsForSite | ClearBrowsingHistoryForAttribution | EnableAPI - | DisableAPI; + | DisableAPI + | UpdateActivationTimestamp + | UpdateActivationOnNavigate; type ExpectedError = | "RangeError" @@ -40,6 +42,7 @@ interface SaveImpression { seconds: number; site: string; intermediarySite?: string | undefined; + topLevelTraversable?: string | undefined; options: AttributionImpressionOptions; expectedError?: ExpectedError; } @@ -49,6 +52,7 @@ interface MeasureConversion { seconds: number; site: string; intermediarySite?: string | undefined; + topLevelTraversable?: string | undefined; options: AttributionConversionOptions; expected: number[] | ExpectedError; } @@ -76,6 +80,20 @@ interface DisableAPI { seconds: number; } +interface UpdateActivationTimestamp { + event: "updateActivationTimestamp"; + seconds: number; + topLevelTraversable: string; +} + +interface UpdateActivationOnNavigate { + event: "updateActivationOnNavigate"; + seconds: number; + topLevelTraversable: string; + initiatorOrigin: string; + responseOrigin: string; +} + function assertThrows( call: () => unknown, expectedError: ExpectedError, @@ -110,6 +128,9 @@ function runTest( ), includeUnencryptedHistogram: true, + activationDuration: Temporal.Duration.from({ + seconds: config.activationSeconds, + }), globalPrivacyBudgetPerEpoch: config.globalPrivacyBudgetPerEpoch, impressionSiteQuotaPerEpoch: config.impressionSiteQuotaPerEpoch, maxConversionSitesPerImpression: config.maxConversionSitesPerImpression, @@ -141,6 +162,7 @@ function runTest( case "saveImpression": { const call = () => backend.saveImpression( + event.topLevelTraversable, event.site, event.intermediarySite, event.options, @@ -157,6 +179,7 @@ function runTest( case "measureConversion": { const call = () => backend.measureConversion( + event.topLevelTraversable, event.site, event.intermediarySite, event.options, @@ -185,6 +208,16 @@ function runTest( case "disableAPI": backend.enabled = false; break; + case "updateActivationTimestamp": + backend.updateActivationTimestamp(event.topLevelTraversable); + break; + case "updateActivationOnNavigate": + backend.updateActivationOnNavigate( + event.topLevelTraversable, + event.initiatorOrigin, + event.responseOrigin, + ); + break; } } } diff --git a/impl/src/fixture.ts b/impl/src/fixture.ts index 424ac06..cb31c55 100644 --- a/impl/src/fixture.ts +++ b/impl/src/fixture.ts @@ -22,6 +22,7 @@ export interface TestConfig { epochStart: number; fairlyAllocateCreditFraction: number; impressionSiteQuotaPerEpoch: number; + activationSeconds: number; } export function makeBackend( @@ -38,6 +39,9 @@ export function makeBackend( ), includeUnencryptedHistogram: true, + activationDuration: Temporal.Duration.from({ + seconds: config.activationSeconds, + }), globalPrivacyBudgetPerEpoch: config.globalPrivacyBudgetPerEpoch, impressionSiteQuotaPerEpoch: config.impressionSiteQuotaPerEpoch, maxConversionSitesPerImpression: config.maxConversionSitesPerImpression, diff --git a/impl/src/simulator.ts b/impl/src/simulator.ts index 61bb331..6f63961 100644 --- a/impl/src/simulator.ts +++ b/impl/src/simulator.ts @@ -16,6 +16,7 @@ const backend = new Backend({ includeUnencryptedHistogram: true, // TODO: Allow these values to be configured in the UI. + activationDuration: Temporal.Duration.from({ seconds: 1000 }), globalPrivacyBudgetPerEpoch: 1000000, impressionSiteQuotaPerEpoch: 1000000, maxConversionSitesPerImpression: 10, @@ -242,7 +243,11 @@ function updateBudgetAndEpochTables() { const li = document.createElement("li"); try { - backend.saveImpression(...sites(site, intermediary), opts); + backend.saveImpression( + /*top=*/ undefined, + ...sites(site, intermediary), + opts, + ); li.innerText = "Success"; } catch (e) { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions @@ -334,6 +339,7 @@ function updateBudgetAndEpochTables() { const li = document.createElement("li"); try { const result = backend.measureConversion( + /*top=*/ undefined, ...sites(site, intermediary), opts, );