diff --git a/api.bs b/api.bs index cce0efb..2efec04 100644 --- a/api.bs +++ b/api.bs @@ -1426,8 +1426,9 @@ and [=implicit API inputs=] |implicitInputs|: the [=maximum lookback=]. 1. If the [=list/size=] of |options|.{{AttributionImpressionOptions/conversionSites}} is - greater than an [=implementation-defined=] maximum value, return - [=a promise rejected with=] a {{RangeError}} in |realm|. + greater than the [=implementation-defined=] + [=maximum number of conversion sites per impression=], + return [=a promise rejected with=] a {{RangeError}} in |realm|. 1. Let |conversionSites| be the [=set=] that is the result of invoking [=parse a site=] for each value in |options|.{{AttributionImpressionOptions/conversionSites}}. @@ -1435,8 +1436,9 @@ and [=implicit API inputs=] |implicitInputs|: [=a promise rejected with=] a {{"SyntaxError"}} {{DOMException}} in |realm|. 1. If the [=list/size=] of |options|.{{AttributionImpressionOptions/conversionCallers}} is - greater than an [=implementation-defined=] maximum value, return - [=a promise rejected with=] a {{RangeError}} in |realm|. + greater than the [=implementation-defined=] + [=maximum number of conversion callers per impression=], + return [=a promise rejected with=] a {{RangeError}} in |realm|. 1. Let |conversionCallers| be the [=set=] that is the result of invoking [=parse a site=] for each value in |options|.{{AttributionImpressionOptions/conversionCallers}}. @@ -1598,8 +1600,8 @@ To validate {{AttributionConversionOptions}} |options|: is less than or equal to 0 or is greater than [=maximum epsilon=], throw a {{RangeError}}. 1. If |options|.{{AttributionConversionOptions/histogramSize}} - is 0 or greater than the [=implementation-defined=] maximum histogram size, - or is greater than the maximum aggregation-service histogram size, + is 0 or greater than the [=implementation-defined=] [=maximum histogram size=], + or is greater than the [=maximum aggregation-service histogram size=], if any, for |options|.{{AttributionConversionOptions/aggregationService}}, throw a {{RangeError}}. 1. If |options|.{{AttributionConversionOptions/value}} is 0, @@ -1610,7 +1612,8 @@ To validate {{AttributionConversionOptions}} |options|: 1. Let |credit| be |options|.{{AttributionConversionOptions/credit}} if it [=map/exists=], «1» otherwise. 1. If |credit| [=list/is empty=], throw a {{RangeError}}. 1. If any of the [=list/items=] of |credit| are less than or equal to 0, throw a {{RangeError}}. -1. If the [=list/size=] of |credit| exceeds an [=implementation-defined=] maximum, throw a {{RangeError}}. +1. If the [=list/size=] of |credit| exceeds the [=implementation-defined=] + [=maximum number of credit values=], throw a {{RangeError}}. 1. Let |lookback| be |options|.{{AttributionConversionOptions/lookbackDays}} [=days=] if it [=map/exists=], the [=maximum lookback=] [=days=] otherwise. 1. Set |lookback| to the [=maximum lookback=] [=days=] @@ -1618,19 +1621,22 @@ To validate {{AttributionConversionOptions}} |options|: 1. If |lookback| is 0 [=days=], throw a {{RangeError}}. 1. If the [=list/size=] of |options|.{{AttributionConversionOptions/matchValues}} is - greater than an [=implementation-defined=] maximum value, throw a {{RangeError}}. + greater than the [=implementation-defined=] [=maximum number of match values=], + throw a {{RangeError}}. 1. Let |matchValues| be the result of running [=set/create|creating a set=] with |options|.{{AttributionConversionOptions/matchValues}}. 1. If the [=list/size=] of - |options|.{{AttributionConversionOptions/impressionSites}} is - greater than an [=implementation-defined=] maximum value, throw a {{RangeError}}. + |options|.{{AttributionConversionOptions/impressionSites}} is greater than + the [=implementation-defined=] [=maximum number of impression sites for conversion=], + throw a {{RangeError}}. 1. Let |impressionSites| be the [=set=] that is the result of invoking [=parse a site=] for each value in |options|.{{AttributionConversionOptions/impressionSites}}. 1. If any result in |impressionSites| is failure, throw a {{"SyntaxError"}} {{DOMException}}. 1. If the [=list/size=] of - |options|.{{AttributionConversionOptions/impressionCallers}} is - greater than an [=implementation-defined=] maximum value, throw a {{RangeError}}. + |options|.{{AttributionConversionOptions/impressionCallers}} is greater than + the [=implementation-defined=] [=maximum number of impression callers for conversion=], + throw a {{RangeError}}. 1. Let |impressionCallers| be the [=set=] that is the result of invoking [=parse a site=] for each value in |options|.{{AttributionConversionOptions/impressionCallers}}. @@ -1880,11 +1886,50 @@ regarding how to best set those values. An implementation sets an [=implementation-defined=] maximum lookback for {{AttributionImpressionOptions/lifetimeDays}} and -{{AttributionConversionOptions/lookbackDays}}. It is a positive integer number of [=days=]. +{{AttributionConversionOptions/lookbackDays}}. +[=Maximum lookback=] is a positive integer number of [=days=]. There is no point to having different maximum values for these values as the smaller of the two values will determine which saved [=impressions=] are available for conversions. +Implementations [=must=] set a [=maximum lookback=] of at least 90 days. + +An [=implementation-defined=] value is chosen for the lists of sites +passed to saveImpression() +and measureConversion(). +The maximum number of conversion sites per impression +is a limit on the number of values for {{AttributionImpressionOptions/conversionSites}}; +implementations [=must=] set this value to at least 5. +The maximum number of conversion callers per impression +and maximum number of impression callers for conversion +are limits on the number of values for {{AttributionImpressionOptions/conversionCallers}} +and {{AttributionConversionOptions/impressionCallers}} respectively; +implementations [=must=] set each of these values to at least 10. +The maximum number of impression sites for conversion +is a limit on the number of values for {{AttributionConversionOptions/impressionSites}}; +implementations [=must=] set this value to at least 30. + +An [=implementation-defined=] value is chosen for +the maximum number of credit values, +which is a limit on the number of values for {{AttributionConversionOptions/credit}}; +implementations [=must=] set this value to at least 10. +An [=implementation-defined=] value is chosen for +the maximum number of match values, +which is a limit on the number of values for {{AttributionConversionOptions/matchValues}}; +implementations [=must=] set this value to at least 30. + +The size of histograms +produced by measureConversion() +is subject to both an [=implementation-define=] maximum histogram size +and maximum aggregation-service histogram size. +No minimum value for the [=maximum histogram size=] is set +as the expectation is that the choice of aggregation technology +will set a limit. +The [=maximum aggregation-service histogram size=] +will be a fixed value that is determined +by the choice of technology used by the [=aggregation service=]; +it is not [=implementation-defined=]. + Deciding on a value for differential privacy parameters is hard and therefore TBD. diff --git a/impl/e2e-tests/CONFIG.json b/impl/e2e-tests/CONFIG.json index 9ef3398..940001a 100644 --- a/impl/e2e-tests/CONFIG.json +++ b/impl/e2e-tests/CONFIG.json @@ -4,7 +4,10 @@ }, "maxConversionSitesPerImpression": 3, "maxConversionCallersPerImpression": 3, + "maxImpressionSitesForConversion": 3, + "maxImpressionCallersForConversion": 3, "maxCreditSize": 10, + "maxMatchValues": 10, "maxLookbackDays": 30, "maxHistogramSize": 5, "privacyBudgetMicroEpsilons": 1000000, diff --git a/impl/e2e-tests/measure-conversion-errors.json b/impl/e2e-tests/measure-conversion-errors.json index f9195d2..e79ffe0 100644 --- a/impl/e2e-tests/measure-conversion-errors.json +++ b/impl/e2e-tests/measure-conversion-errors.json @@ -94,6 +94,7 @@ "options": { "aggregationService": "https://agg-service.example", "histogramSize": 1, + "$comment": "Zero credit", "credit": [0] }, "expected": "RangeError" @@ -102,10 +103,10 @@ "seconds": 10, "site": "advertiser.example", "event": "measureConversion", - "$comment": "Greater than CONFIG.maxCreditSize", "options": { "aggregationService": "https://agg-service.example", "histogramSize": 1, + "$comment": "Greater than CONFIG.maxCreditSize", "credit": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] }, "expected": "RangeError" @@ -117,7 +118,9 @@ "options": { "aggregationService": "https://agg-service.example", "histogramSize": 1, - "lookbackDays": 0 + "credit": [1], + "$comment": "Greater than CONFIG.maxMatchValues", + "matchValues": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] }, "expected": "RangeError" }, @@ -125,6 +128,18 @@ "seconds": 12, "site": "advertiser.example", "event": "measureConversion", + "options": { + "aggregationService": "https://agg-service.example", + "histogramSize": 1, + "$comment": "lookback cannot be zero", + "lookbackDays": 0 + }, + "expected": "RangeError" + }, + { + "seconds": 13, + "site": "advertiser.example", + "event": "measureConversion", "options": { "aggregationService": "https://agg-service.example", "histogramSize": 1, @@ -136,7 +151,7 @@ } }, { - "seconds": 13, + "seconds": 14, "site": "advertiser.example", "event": "measureConversion", "options": { @@ -148,6 +163,30 @@ "error": "DOMException", "name": "SyntaxError" } + }, + { + "seconds": 15, + "site": "advertiser.example", + "event": "measureConversion", + "options": { + "aggregationService": "https://agg-service.example", + "histogramSize": 1, + "$comment": "Greater than CONFIG.maxImpressionSitesForConversion", + "impressionSites": ["a", "b", "c", "d"] + }, + "expected": "RangeError" + }, + { + "seconds": 16, + "site": "advertiser.example", + "event": "measureConversion", + "options": { + "aggregationService": "https://agg-service.example", + "histogramSize": 1, + "$comment": "Greater than CONFIG.maxImpressionCallersForConversion", + "impressionCallers": ["a", "b", "c", "d"] + }, + "expected": "RangeError" } ] } diff --git a/impl/e2e-tests/save-impression-errors.json b/impl/e2e-tests/save-impression-errors.json index 88673c3..122dacb 100644 --- a/impl/e2e-tests/save-impression-errors.json +++ b/impl/e2e-tests/save-impression-errors.json @@ -4,7 +4,9 @@ "seconds": 1, "site": "publisher.example", "event": "saveImpression", - "options": { "histogramIndex": -1 }, + "options": { + "histogramIndex": -1 + }, "expectedError": "RangeError" }, { @@ -12,7 +14,9 @@ "site": "publisher.example", "event": "saveImpression", "$comment": "Equal to CONFIG.maxHistogramSize", - "options": { "histogramIndex": 5 }, + "options": { + "histogramIndex": 5 + }, "expectedError": "RangeError" }, { diff --git a/impl/src/backend.ts b/impl/src/backend.ts index 42c5e18..d96dc9e 100644 --- a/impl/src/backend.ts +++ b/impl/src/backend.ts @@ -68,8 +68,17 @@ function validateSite(input: string): void { } } -function parseSites(input: readonly string[]): Set { +function parseSites( + input: readonly string[], + label: string, + limit: number, +): Set { const parsed = new Set(); + if (limit !== null && input.length > limit) { + throw new RangeError( + `number of values in ${label} exceeds limit of ${limit}`, + ); + } for (const site of input) { parsed.add(parseSite(site)); } @@ -80,10 +89,21 @@ export interface Delegate { readonly aggregationServices: AttributionAggregationServices; readonly includeUnencryptedHistogram?: boolean; + /// The maximum number of conversion callers per impression. readonly maxConversionSitesPerImpression: number; + /// The maximum number of conversion sites per impression. readonly maxConversionCallersPerImpression: number; + /// The maximum number of impression sites for conversion. + readonly maxImpressionSitesForConversion: number; + /// The maximum number of impression callers for conversion. + readonly maxImpressionCallersForConversion: number; + /// The maximum number of credit values. readonly maxCreditSize: number; + /// The maximum number of match values. + readonly maxMatchValues: number; + /// The maximum lookback in days. readonly maxLookbackDays: number; + /// The maximum size of histograms. readonly maxHistogramSize: number; readonly privacyBudgetMicroEpsilons: number; readonly privacyBudgetEpoch: Temporal.Duration; @@ -162,23 +182,16 @@ export class Backend { } lifetimeDays = Math.min(lifetimeDays, this.#delegate.maxLookbackDays); - const maxConversionSitesPerImpression = - this.#delegate.maxConversionSitesPerImpression; - if (conversionSites.length > maxConversionSitesPerImpression) { - throw new RangeError( - `conversionSites.length must be <= ${maxConversionSitesPerImpression}`, - ); - } - const parsedConversionSites = parseSites(conversionSites); - - const maxConversionCallersPerImpression = - this.#delegate.maxConversionCallersPerImpression; - if (conversionCallers.length > maxConversionCallersPerImpression) { - throw new RangeError( - `conversionCallers.length must be <= ${maxConversionCallersPerImpression}`, - ); - } - const parsedConversionCallers = parseSites(conversionCallers); + const parsedConversionSites = parseSites( + conversionSites, + "conversionSites", + this.#delegate.maxConversionSitesPerImpression, + ); + const parsedConversionCallers = parseSites( + conversionCallers, + "conversionCallers", + this.#delegate.maxConversionCallersPerImpression, + ); if (matchValue < 0 || !Number.isInteger(matchValue)) { throw new RangeError("matchValue must be a non-negative integer"); @@ -270,6 +283,12 @@ export class Backend { lookbackDays = Math.min(lookbackDays, this.#delegate.maxLookbackDays); const matchValueSet = new Set(); + const maxMatchValues = this.#delegate.maxMatchValues; + if (matchValues.length > maxMatchValues) { + throw new RangeError( + `matchValues size must be in the range [0,${maxMatchValues}]`, + ); + } for (const value of matchValues) { if (value < 0 || !Number.isInteger(value)) { throw new RangeError("match value must be a non-negative integer"); @@ -277,14 +296,25 @@ export class Backend { matchValueSet.add(value); } + const parsedImpressionSites = parseSites( + impressionSites, + "impressionSites", + this.#delegate.maxImpressionSitesForConversion, + ); + const parsedImpressionCallers = parseSites( + impressionCallers, + "impressionCallers", + this.#delegate.maxImpressionCallersForConversion, + ); + return { aggregationService: aggregationServiceEntry, epsilon, histogramSize, lookback: days(lookbackDays), matchValues: matchValueSet, - impressionSites: parseSites(impressionSites), - impressionCallers: parseSites(impressionCallers), + impressionSites: parsedImpressionSites, + impressionCallers: parsedImpressionCallers, credit, value, maxValue, @@ -661,7 +691,7 @@ export class Backend { } clearState(sites: readonly string[], forgetVisits: boolean): void { - const parsedSites = parseSites(sites); + const parsedSites = parseSites(sites, "sites", Infinity); if (!forgetVisits) { this.#zeroBudgetForSites(parsedSites); return; diff --git a/impl/src/e2e.test.ts b/impl/src/e2e.test.ts index c727c9a..abe3629 100644 --- a/impl/src/e2e.test.ts +++ b/impl/src/e2e.test.ts @@ -89,7 +89,10 @@ function runTest( maxConversionSitesPerImpression: config.maxConversionSitesPerImpression, maxConversionCallersPerImpression: config.maxConversionCallersPerImpression, + maxImpressionSitesForConversion: config.maxImpressionSitesForConversion, + maxImpressionCallersForConversion: config.maxImpressionCallersForConversion, maxCreditSize: config.maxCreditSize, + maxMatchValues: config.maxMatchValues, maxLookbackDays: config.maxLookbackDays, maxHistogramSize: config.maxHistogramSize, privacyBudgetMicroEpsilons: config.privacyBudgetMicroEpsilons, @@ -132,7 +135,6 @@ function runTest( event.intermediarySite, event.options, ); - if (Array.isArray(event.expected)) { assert.deepEqual( call().unencryptedHistogram, diff --git a/impl/src/fixture.ts b/impl/src/fixture.ts index ab24cde..6ec4024 100644 --- a/impl/src/fixture.ts +++ b/impl/src/fixture.ts @@ -10,7 +10,10 @@ export interface TestConfig { aggregationServices: Record; maxConversionSitesPerImpression: number; maxConversionCallersPerImpression: number; + maxImpressionSitesForConversion: number; + maxImpressionCallersForConversion: number; maxCreditSize: number; + maxMatchValues: number; maxLookbackDays: number; maxHistogramSize: number; privacyBudgetMicroEpsilons: number; @@ -35,7 +38,10 @@ export function makeBackend( maxConversionSitesPerImpression: config.maxConversionSitesPerImpression, maxConversionCallersPerImpression: config.maxConversionCallersPerImpression, + maxImpressionSitesForConversion: config.maxImpressionSitesForConversion, + maxImpressionCallersForConversion: config.maxImpressionCallersForConversion, maxCreditSize: config.maxCreditSize, + maxMatchValues: config.maxMatchValues, maxLookbackDays: config.maxLookbackDays, maxHistogramSize: config.maxHistogramSize, privacyBudgetMicroEpsilons: config.privacyBudgetMicroEpsilons, diff --git a/impl/src/simulator.ts b/impl/src/simulator.ts index eea63b4..98c51f4 100644 --- a/impl/src/simulator.ts +++ b/impl/src/simulator.ts @@ -18,7 +18,10 @@ const backend = new Backend({ // TODO: Allow these values to be configured in the UI. maxConversionSitesPerImpression: 10, maxConversionCallersPerImpression: 10, + maxImpressionSitesForConversion: 10, + maxImpressionCallersForConversion: 10, maxCreditSize: Infinity, + maxMatchValues: Infinity, maxLookbackDays: 30, maxHistogramSize: 100, privacyBudgetMicroEpsilons: 1000000,