Skip to content
Open
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
1 change: 1 addition & 0 deletions impl/e2e-tests/CONFIG.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
61 changes: 61 additions & 0 deletions impl/e2e-tests/activation.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
{
"events": [
{
"seconds": 1,
"event": "updateActivationTimestamp",
"topLevelTraversable": "a"
},
{
"seconds": 1002,
"site": "publisher.example",
"event": "saveImpression",
"topLevelTraversable": "a",
"options": { "histogramIndex": 1 },
"expectedError": {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

expectedError here, expected below ??!?

"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,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This works because the time is exactly 1000 seconds, yes? And the conversion measurement works at 1001 seconds because of the flag.

"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]
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would add a cross-site navigation on "b" here to demonstrate that the errors start back up.

]
}
47 changes: 47 additions & 0 deletions impl/e2e.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
"$comment": {
"$ref": "#/$defs/comment"
},
"activationSeconds": {
"type": "number",
"minimum": 1
},
"aggregationServices": {
"type": "object",
"additionalProperties": {
Expand Down Expand Up @@ -77,6 +81,7 @@
}
},
"required": [
"activationSeconds",
"aggregationServices",
"globalPrivacyBudgetPerEpoch",
"impressionSiteQuotaPerEpoch",
Expand Down Expand Up @@ -115,6 +120,9 @@
},
"options": {
"$ref": "#/$defs/AttributionImpressionOptions"
},
"topLevelTraversable": {
"type": "string"
}
},
"required": ["options"],
Expand Down Expand Up @@ -144,6 +152,9 @@
},
"options": {
"$ref": "#/$defs/AttributionConversionOptions"
},
"topLevelTraversable": {
"type": "string"
}
},
"required": ["expected", "options"],
Expand Down Expand Up @@ -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
}
]
}
Expand Down
82 changes: 78 additions & 4 deletions impl/src/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, TopLevelTraversable>();

constructor(delegate: Delegate) {
this.#delegate = delegate;
}
Expand All @@ -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,
{
Expand All @@ -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) {
Expand Down Expand Up @@ -248,7 +321,7 @@ export class Backend {
intermediarySite,
conversionSites: parsedConversionSites,
conversionCallers: parsedConversionCallers,
timestamp,
timestamp: this.#delegate.now(),
lifetime: days(lifetimeDays),
histogramIndex,
priority,
Expand Down Expand Up @@ -353,22 +426,23 @@ export class Backend {
}

measureConversion(
topKey: string | undefined,
topLevelSite: string,
intermediarySite: string | undefined,
options: AttributionConversionOptions,
): AttributionConversionResult {
assert(isValidSite(topLevelSite));
assert(intermediarySite === undefined || isValidSite(intermediarySite));

const now = this.#delegate.now();
this.#checkAttributionAPIActivation(topKey);

const validatedOptions = this.#validateConversionOptions(options);

const report = this.enabled
? this.#doAttributionAndFillHistogram(
topLevelSite,
intermediarySite,
now,
this.#delegate.now(),
validatedOptions,
)
: allZeroHistogram(validatedOptions.histogramSize);
Expand Down
62 changes: 41 additions & 21 deletions impl/src/clear.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand All @@ -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);
Expand All @@ -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.
Expand Down Expand Up @@ -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);
Expand All @@ -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.
Expand Down
Loading
Loading