@gradial/aci 0.1.52-rc.16 → 0.1.52-rc.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +4 -4
  2. package/dist/astro/preview.js +2 -17
  3. package/dist/audience-data-models.d.ts +193 -16
  4. package/dist/audience-data-models.js +47 -2
  5. package/dist/collect/batcher.d.ts +6 -5
  6. package/dist/collect/batcher.js +6 -5
  7. package/dist/edge/expression-capabilities.v1.d.ts +106 -0
  8. package/dist/edge/expression-capabilities.v1.js +165 -0
  9. package/dist/internal/domain/edge/identity.js +1 -1
  10. package/dist/internal/domain/edge/mimic_token.js +1 -2
  11. package/dist/internal/domain/edge/personalization-core.d.ts +14 -4
  12. package/dist/internal/domain/edge/personalization-core.js +472 -54
  13. package/dist/internal/domain/edge/record/codec.d.ts +5 -2
  14. package/dist/internal/domain/edge/record/codec.js +83 -10
  15. package/dist/internal/domain/edge/resolve.js +13 -2
  16. package/dist/internal/domain/edge/testdata/flat_only_validator_v1.d.ts +10 -0
  17. package/dist/internal/domain/edge/testdata/flat_only_validator_v1.js +253 -0
  18. package/dist/internal/domain/edge/types.d.ts +36 -9
  19. package/dist/internal/domain/edge/types.js +3 -3
  20. package/dist/mimic/core.d.ts +0 -32
  21. package/dist/mimic/core.js +12 -108
  22. package/dist/next/vercel-edge-projection-v1.d.ts +0 -9
  23. package/dist/next/vercel-edge-projection-v1.js +9 -545
  24. package/dist/preview/core.d.ts +1 -0
  25. package/dist/preview/core.js +27 -0
  26. package/dist/react/VideoPlayer.d.ts +1 -1
  27. package/dist/react/index.d.ts +1 -1
  28. package/package.json +16 -7
  29. package/templates/common/.agents/skills/aci/SKILL.md +6 -0
  30. package/templates/common/.agents/skills/dam-delivery/SKILL.md +228 -0
  31. package/templates/common/.agents/skills/dam-delivery/references/parameter-contract.md +107 -0
  32. package/templates/common/.agents/skills/dam-delivery/references/permalink-grammar.md +131 -0
  33. package/dist/edge/personalization-core.d.ts +0 -35
  34. package/dist/edge/personalization-core.js +0 -483
  35. package/dist/next/collect-route.d.ts +0 -25
  36. package/dist/next/collect-route.js +0 -479
  37. package/dist/next/envelope-digest.d.ts +0 -21
  38. package/dist/next/envelope-digest.js +0 -58
  39. package/dist/next/personalization-host.d.ts +0 -138
  40. package/dist/next/personalization-host.js +0 -280
  41. package/dist/next/root-layout.d.ts +0 -4
  42. package/dist/next/root-layout.js +0 -5
  43. package/dist/next/vercel-collect-sdk-route.d.ts +0 -27
  44. package/dist/next/vercel-collect-sdk-route.js +0 -181
  45. package/dist/next/vercel-personalization.d.ts +0 -4
  46. package/dist/next/vercel-personalization.js +0 -4
  47. package/dist/next/vercel-s3-personalization.d.ts +0 -80
  48. package/dist/next/vercel-s3-personalization.js +0 -571
@@ -1,483 +0,0 @@
1
- // Runtime-neutral personalization selection. Vercel imports this module and
2
- // the CloudFront Terraform wrapper inlines the same source into its function.
3
- // Keep this file free of platform APIs and dependencies.
4
- export function selectPersonalizationVariant(request, route, claims, experiments, identity) {
5
- return resolvePersonalizationDecision(request, route, claims, experiments, identity).variant;
6
- }
7
- export function resolvePersonalizationDecision(request, route, claims, experiments, identity) {
8
- if (!route)
9
- return { variant: null, experiment: null };
10
- let variant = selectRuleVariant(request, route, claims, experiments);
11
- let experiment = null;
12
- if (identity && identity.usable && route.variants && experiments) {
13
- const candidates = Object.values(experiments)
14
- .filter((item) => (item.kind === 'ab' || item.kind === 'abn') && item.status === 'running')
15
- .filter((item) => routeHasExperiment(route, item.id))
16
- .filter((item) => experimentEligible(item, claims))
17
- .sort((left, right) => (right.priority || 0) - (left.priority || 0)
18
- || String(left.id || '').localeCompare(String(right.id || '')));
19
- for (let index = 0; index < candidates.length; index++) {
20
- const item = candidates[index];
21
- const assignment = assignExperiment(item, identity);
22
- const candidate = assignment && route.variants[assignment.variantKey];
23
- if (!candidate)
24
- continue;
25
- if (!variant || betterVariant(candidate.priority || 0, candidate.variantKey, variant.priority || 0, variant.variantKey)) {
26
- variant = candidate;
27
- experiment = assignment;
28
- }
29
- break;
30
- }
31
- }
32
- return { variant: variant || null, experiment };
33
- }
34
- function selectRuleVariant(request, route, claims, experiments) {
35
- if (!route.variants)
36
- return null;
37
- const matches = [];
38
- for (const key in route.variants) {
39
- const variant = route.variants[key];
40
- const values = variant.variantValues || variantValuesFromKey(key);
41
- const experimentID = String(values.experiment || '').trim();
42
- if (experimentID && (!experiments || !experiments[experimentID]
43
- || experiments[experimentID].status !== 'running'
44
- || !experimentEligible(experiments[experimentID], claims)))
45
- continue;
46
- if (!requestDimensionsMatch(request, values))
47
- continue;
48
- const membershipMatch = (values.audience && claims && (claims.aud || []).includes(values.audience))
49
- || (values.segment && claims && (claims.seg || []).includes(values.segment));
50
- const ruleMatch = variant.rule ? evaluateRule(request, variant.rule) : false;
51
- if (membershipMatch || ruleMatch)
52
- matches.push(variant);
53
- }
54
- matches.sort((left, right) => (right.priority || 0) - (left.priority || 0)
55
- || String(left.variantKey || '').localeCompare(String(right.variantKey || '')));
56
- return matches[0] || null;
57
- }
58
- // CloudFront KVS values are capped at 1 KiB. The variant map key is the
59
- // canonical encoding of its dimensions, so compact route records omit the
60
- // duplicated variantValues object and recover it at the edge.
61
- function variantValuesFromKey(key) {
62
- const values = {};
63
- const parts = String(key || '').replace(/&/g, ';').split(';');
64
- for (let index = 0; index < parts.length; index++) {
65
- const separator = parts[index].indexOf('=');
66
- if (separator > 0)
67
- values[parts[index].slice(0, separator)] = parts[index].slice(separator + 1);
68
- }
69
- return values;
70
- }
71
- // The eligibility gate (audience-scoped-experiments ASE-F4/F5), mirrored
72
- // from internal/domain/edge/personalization-core.ts: an experiment declaring
73
- // eligibilityAudienceId is skipped unless the verified claims carry the ID.
74
- // Ungated experiments return true, preserving byte-identical behavior.
75
- function experimentEligible(item, claims) {
76
- const audienceID = String(item.eligibilityAudienceId || '').trim();
77
- if (!audienceID)
78
- return true;
79
- return !!claims && (claims.aud || []).includes(audienceID);
80
- }
81
- function routeHasExperiment(route, experimentID) {
82
- for (const key in route.variants || {}) {
83
- const values = route.variants[key].variantValues || variantValuesFromKey(key);
84
- if (values.experiment === experimentID)
85
- return true;
86
- }
87
- return false;
88
- }
89
- function assignExperiment(experiment, identity) {
90
- const version = String(experiment.allocationVersion || '').trim();
91
- const salt = String(experiment.salt || '').trim();
92
- const siteID = String(identity.siteId || '').trim();
93
- const identityKey = `${String(identity.namespace || '').trim()}:${String(identity.identityHash || '').trim()}`;
94
- if (!version || !salt || !siteID || identityKey === ':')
95
- return null;
96
- const input = [siteID, String(experiment.id || ''), version, identityKey, salt].join('\0');
97
- const bucket = sha256Bucket(input);
98
- let cumulative = 0;
99
- const arms = experiment.arms || [];
100
- for (let index = 0; index < arms.length; index++) {
101
- const arm = arms[index];
102
- if ((arm.allocationBasisPoints || 0) <= 0)
103
- continue;
104
- cumulative += arm.allocationBasisPoints;
105
- if (bucket < cumulative) {
106
- return {
107
- experimentId: experiment.id,
108
- armId: arm.id,
109
- allocationVersion: version,
110
- variantKey: arm.variantKey,
111
- bucket,
112
- };
113
- }
114
- }
115
- return null;
116
- }
117
- function betterVariant(candidatePriority, candidateKey, bestPriority, bestKey) {
118
- if (candidatePriority !== bestPriority)
119
- return candidatePriority > bestPriority;
120
- return String(candidateKey || '').localeCompare(String(bestKey || '')) < 0;
121
- }
122
- function requestDimensionsMatch(request, values) {
123
- for (const rawKey in values) {
124
- const rawWant = values[rawKey];
125
- const key = String(rawKey).trim();
126
- const normalized = key.toLowerCase();
127
- const want = String(rawWant).trim();
128
- if (!key || !want || ['audience', 'segment', 'experiment', 'experimentarm'].includes(normalized))
129
- continue;
130
- const candidates = (request.query[key] || []).concat(request.query['aci_' + key] || [], request.headers['x-aci-' + normalized] || '', request.headers['x-gradial-' + normalized] || '', request.cookies[key] || '', request.cookies['aci_' + key] || '');
131
- if (normalized === 'locale') {
132
- const locales = String(request.headers['accept-language'] || '')
133
- .split(',').map((part) => part.split(';')[0].trim()).filter(Boolean);
134
- for (let localeIndex = 0; localeIndex < locales.length; localeIndex++)
135
- candidates.push(locales[localeIndex]);
136
- }
137
- if (!candidates.some((candidate) => normalized === 'locale'
138
- ? String(candidate).toLowerCase() === want.toLowerCase()
139
- : String(candidate) === want))
140
- return false;
141
- }
142
- return true;
143
- }
144
- const MAX_EXPRESSION_CONDITIONS = 64;
145
- export function evaluateRequestExpression(rule, resolve) {
146
- const all = rule.all || [];
147
- const any = rule.any || [];
148
- const empty = all.length + any.length === 0;
149
- const diagnostics = validateRequestExpression(rule, all, any);
150
- const trace = { scope: 'request', empty, groups: [], leaves: [] };
151
- if (diagnostics.length > 0)
152
- return { qualified: false, diagnostics, trace };
153
- const qualified = qualifyRequestExpression(all, any, resolve, trace);
154
- return { qualified, diagnostics, trace };
155
- }
156
- function evaluateRule(request, rule) {
157
- const all = rule.all || [];
158
- const any = rule.any || [];
159
- if (validateRequestExpression(rule, all, any).length > 0)
160
- return false;
161
- return qualifyRequestExpression(all, any, (field) => signalFact(request, field));
162
- }
163
- function validateRequestExpression(rule, all, any) {
164
- const diagnostics = [];
165
- if (all.length + any.length === 0)
166
- diagnostics.push({ code: 'expression.empty', path: 'expression', scope: 'request', message: 'expression must include at least one all or any condition' });
167
- if (all.length + any.length > MAX_EXPRESSION_CONDITIONS)
168
- diagnostics.push({ code: 'expression.conditions.too_many', path: 'expression', scope: 'request', message: `expression must contain at most ${MAX_EXPRESSION_CONDITIONS} conditions` });
169
- Object.keys(rule).filter((group) => group !== 'all' && group !== 'any').sort().forEach((group) => {
170
- diagnostics.push({ code: 'expression.group.unsupported', path: `expression.${group}`, scope: 'request', message: `expression.${group} is not a supported expression group` });
171
- });
172
- for (const [group, conditions] of [['all', all], ['any', any]]) {
173
- conditions.forEach((condition, index) => validateRequestCondition(condition, `expression.${group}[${index}]`, diagnostics));
174
- }
175
- return diagnostics;
176
- }
177
- function qualifyRequestExpression(all, any, resolve, trace) {
178
- let qualified = true;
179
- for (const [group, conditions] of [['all', all], ['any', any]]) {
180
- let matched = 0;
181
- conditions.forEach((condition, index) => {
182
- const fact = normalizeFact(resolve(String(condition.field || '')));
183
- const outcome = matchRequestCondition(condition, fact);
184
- if (outcome.matched)
185
- matched++;
186
- if (trace)
187
- trace.leaves.push({
188
- path: `expression.${group}[${index}]`, group, index,
189
- field: String(condition.field || ''), operator: String(condition.operator || ''),
190
- status: fact.status, matched: outcome.matched, reason: outcome.reason,
191
- });
192
- });
193
- const passed = group === 'all' ? matched === conditions.length : conditions.length === 0 || matched > 0;
194
- if (trace)
195
- trace.groups.push({ group, total: conditions.length, matched, passed });
196
- qualified = qualified && passed;
197
- }
198
- return qualified;
199
- }
200
- function validateRequestCondition(condition, path, diagnostics) {
201
- Object.keys(condition).filter((property) => !['field', 'operator', 'value'].includes(property)).sort().forEach((property) => {
202
- diagnostics.push({ code: 'expression.property.unsupported', path: `${path}.${property}`, scope: 'request', message: `${path}.${property} is not a supported condition property` });
203
- });
204
- const field = String(condition.field || '');
205
- if (field !== 'referrer' && field !== 'device.class' && !['query.', 'header.', 'cookie.', 'geo.', 'utm.'].some((prefix) => field.startsWith(prefix) && field.length > prefix.length)) {
206
- diagnostics.push({ code: 'expression.field.unsupported', path: `${path}.field`, scope: 'request', message: `${path}.field ${JSON.stringify(field)} is not supported` });
207
- }
208
- const operator = String(condition.operator || '');
209
- if (!['equals', 'contains', 'startsWith', 'exists'].includes(operator)) {
210
- diagnostics.push({ code: 'expression.operator.unsupported', path: `${path}.operator`, scope: 'request', message: `${path}.operator ${JSON.stringify(operator)} is not supported` });
211
- return;
212
- }
213
- // device.class compares against a closed four-value set, so substring
214
- // operators express nothing the set cannot — and admitting them would
215
- // re-create the user-agent-substring habit inside the safe field (ADR 0035).
216
- if (field === 'device.class' && operator !== 'equals' && operator !== 'exists') {
217
- diagnostics.push({ code: 'expression.operator.unsupported', path: `${path}.operator`, scope: 'request', message: `${path}.operator ${JSON.stringify(operator)} is not supported for field ${JSON.stringify(field)}, which compares against a closed value set` });
218
- return;
219
- }
220
- const value = condition.value;
221
- if (operator === 'exists') {
222
- if (value !== undefined && value !== null && typeof value !== 'boolean') {
223
- diagnostics.push({ code: 'expression.value.invalid', path: `${path}.value`, scope: 'request', message: `${path}.value must be a boolean for operator exists` });
224
- }
225
- }
226
- else if (value === undefined || value === null) {
227
- diagnostics.push({ code: 'expression.value.required', path: `${path}.value`, scope: 'request', message: `${path}.value is required for operator ${operator}` });
228
- }
229
- else if (!['string', 'number', 'boolean'].includes(typeof value)
230
- || (typeof value === 'number' && (!Number.isFinite(value) || (Number.isInteger(value) && !Number.isSafeInteger(value))))) {
231
- diagnostics.push({ code: 'expression.value.invalid', path: `${path}.value`, scope: 'request', message: `${path}.value must be a string, boolean, or finite number using only safe integers` });
232
- }
233
- else if (field === 'device.class' && DEVICE_CLASSES.indexOf(canonicalScalar(value)) < 0) {
234
- diagnostics.push({ code: 'expression.value.invalid', path: `${path}.value`, scope: 'request', message: `${path}.value must be one of ${DEVICE_CLASSES.join(', ')} for field ${JSON.stringify(field)}` });
235
- }
236
- }
237
- function matchRequestCondition(condition, fact) {
238
- if (fact.status === 'unavailable')
239
- return { matched: false, reason: 'unavailable' };
240
- if (condition.operator === 'exists') {
241
- const expected = typeof condition.value === 'boolean' ? condition.value : true;
242
- const matched = (fact.status === 'present') === expected;
243
- return { matched, reason: matched ? 'matched' : fact.status === 'absent' ? 'absent' : 'not_matched' };
244
- }
245
- if (fact.status === 'absent')
246
- return { matched: false, reason: 'absent' };
247
- const expected = canonicalScalar(condition.value);
248
- const matched = (fact.values || []).some((value) => condition.operator === 'equals' ? value === expected
249
- : condition.operator === 'contains' ? value.includes(expected)
250
- : condition.operator === 'startsWith' ? value.startsWith(expected) : false);
251
- return { matched, reason: matched ? 'matched' : 'not_matched' };
252
- }
253
- function canonicalScalar(value) {
254
- if (typeof value === 'number')
255
- return Object.is(value, -0) ? '0' : JSON.stringify(value);
256
- return String(value);
257
- }
258
- function normalizeFact(fact) {
259
- if (!fact || !['present', 'absent', 'unavailable'].includes(fact.status))
260
- return { status: 'unavailable' };
261
- return fact;
262
- }
263
- function signalFact(request, field) {
264
- const own = (record, key) => !!record && Object.prototype.hasOwnProperty.call(record, key);
265
- const d = field.indexOf('.');
266
- if (d < 0) {
267
- if (field !== 'referrer')
268
- return { status: 'absent' };
269
- const key = own(request.headers, 'referer') ? 'referer' : 'referrer';
270
- return own(request.headers, key) ? { status: 'present', values: [request.headers[key]] } : { status: 'absent' };
271
- }
272
- const p = field.slice(0, d), k = field.slice(d + 1);
273
- if (p === 'query')
274
- return own(request.query, k) ? { status: 'present', values: request.query[k] } : { status: 'absent' };
275
- if (p === 'utm') {
276
- const key = 'utm_' + k;
277
- return own(request.query, key) ? { status: 'present', values: request.query[key] } : { status: 'absent' };
278
- }
279
- if (p === 'header') {
280
- const key = k.toLowerCase();
281
- return own(request.headers, key) ? { status: 'present', values: [request.headers[key]] } : { status: 'absent' };
282
- }
283
- if (p === 'cookie')
284
- return own(request.cookies, k) ? { status: 'present', values: [request.cookies[k]] } : { status: 'absent' };
285
- if (p === 'device') {
286
- // Derived, not injected: no signals plumbing, so preview, edge, and SDK
287
- // agree by construction. A request with no user-agent header is absent.
288
- if (k !== 'class')
289
- return { status: 'absent' };
290
- const deviceClass = cachedDeviceClass(request);
291
- return deviceClass ? { status: 'present', values: [deviceClass] } : { status: 'absent' };
292
- }
293
- if (p === 'geo') {
294
- const record = request.signals && request.signals.geo;
295
- if (!record)
296
- return { status: 'unavailable' };
297
- return own(record, k) ? { status: 'present', values: [record[k]] } : { status: 'absent' };
298
- }
299
- return { status: 'absent' };
300
- }
301
- // SHA-256 mirrors experiment.assignment.go: the first eight digest bytes are
302
- // interpreted big-endian and reduced modulo 10,000 basis points.
303
- // CLOUDFRONT_STRIP_START — the build script removes this block for CloudFront
304
- // Functions (experiment bucketing moves to the hosting Lambda to stay under
305
- // the 10 KiB function size limit).
306
- function sha256Bucket(value) {
307
- const bytes = utf8(value);
308
- const words = new Uint32Array(64);
309
- const bitLength = bytes.length * 8;
310
- const paddedLength = ((bytes.length + 9 + 63) >> 6) << 6;
311
- const data = new Uint8Array(paddedLength);
312
- data.set(bytes);
313
- data[bytes.length] = 128;
314
- // The message length is a 64-bit big-endian integer across the final
315
- // eight bytes. The previous code wrote a single truncated byte into each
316
- // word's most significant position, corrupting every digest (caught by
317
- // the assignment parity vectors, spec experiment-variant-caching REQ-9).
318
- const bitsHigh = Math.floor(bitLength / 0x100000000);
319
- const bitsLow = bitLength >>> 0;
320
- data[paddedLength - 8] = (bitsHigh >>> 24) & 255;
321
- data[paddedLength - 7] = (bitsHigh >>> 16) & 255;
322
- data[paddedLength - 6] = (bitsHigh >>> 8) & 255;
323
- data[paddedLength - 5] = bitsHigh & 255;
324
- data[paddedLength - 4] = (bitsLow >>> 24) & 255;
325
- data[paddedLength - 3] = (bitsLow >>> 16) & 255;
326
- data[paddedLength - 2] = (bitsLow >>> 8) & 255;
327
- data[paddedLength - 1] = bitsLow & 255;
328
- let a = 0x6a09e667, b = 0xbb67ae85, c = 0x3c6ef372, d = 0xa54ff53a;
329
- let e = 0x510e527f, f = 0x9b05688c, g = 0x1f83d9ab, h = 0x5be0cd19;
330
- const k = [0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2];
331
- for (let offset = 0; offset < paddedLength; offset += 64) {
332
- for (let i = 0; i < 16; i++) {
333
- const p = offset + i * 4;
334
- words[i] = (data[p] << 24) | (data[p + 1] << 16) | (data[p + 2] << 8) | data[p + 3];
335
- }
336
- for (let i = 16; i < 64; i++) {
337
- const x = words[i - 15], y = words[i - 2];
338
- words[i] = (words[i - 16] + (rotr(x, 7) ^ rotr(x, 18) ^ (x >>> 3)) + words[i - 7] + (rotr(y, 17) ^ rotr(y, 19) ^ (y >>> 10))) >>> 0;
339
- }
340
- let aa = a, bb = b, cc = c, dd = d, ee = e, ff = f, gg = g, hh = h;
341
- for (let i = 0; i < 64; i++) {
342
- const s1 = rotr(ee, 6) ^ rotr(ee, 11) ^ rotr(ee, 25);
343
- const ch = (ee & ff) ^ (~ee & gg);
344
- const t1 = (hh + s1 + ch + k[i] + words[i]) >>> 0;
345
- const s0 = rotr(aa, 2) ^ rotr(aa, 13) ^ rotr(aa, 22);
346
- const maj = (aa & bb) ^ (aa & cc) ^ (bb & cc);
347
- const t2 = (s0 + maj) >>> 0;
348
- hh = gg;
349
- gg = ff;
350
- ff = ee;
351
- ee = (dd + t1) >>> 0;
352
- dd = cc;
353
- cc = bb;
354
- bb = aa;
355
- aa = (t1 + t2) >>> 0;
356
- }
357
- a = (a + aa) >>> 0;
358
- b = (b + bb) >>> 0;
359
- c = (c + cc) >>> 0;
360
- d = (d + dd) >>> 0;
361
- e = (e + ee) >>> 0;
362
- f = (f + ff) >>> 0;
363
- g = (g + gg) >>> 0;
364
- h = (h + hh) >>> 0;
365
- }
366
- // Reduce the 64-bit big-endian value modulo 10000 in 32-bit halves:
367
- // (hi*2^32 + lo) % 10000 == ((hi%10000) * (2^32 % 10000) + lo%10000) % 10000,
368
- // and 2^32 % 10000 = 7296. Multiplying hi by 2^32 directly exceeds
369
- // Number.MAX_SAFE_INTEGER and rounds away the low bits — that float error
370
- // made most JS buckets diverge from assignment.go (caught by the
371
- // assignment parity vectors, spec experiment-variant-caching REQ-9).
372
- return (((a >>> 0) % 10000) * 7296 + ((b >>> 0) % 10000)) % 10000;
373
- }
374
- function rotr(value, amount) { return (value >>> amount) | (value << (32 - amount)); }
375
- function utf8(value) {
376
- const out = [];
377
- for (let i = 0; i < value.length; i++) {
378
- let code = value.charCodeAt(i);
379
- if (code >= 0xd800 && code <= 0xdbff && i + 1 < value.length) {
380
- const next = value.charCodeAt(++i);
381
- code = 0x10000 + ((code - 0xd800) << 10) + (next - 0xdc00);
382
- }
383
- if (code < 0x80)
384
- out.push(code);
385
- else if (code < 0x800)
386
- out.push(0xc0 | (code >> 6), 0x80 | (code & 63));
387
- else if (code < 0x10000)
388
- out.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 63), 0x80 | (code & 63));
389
- else
390
- out.push(0xf0 | (code >> 18), 0x80 | ((code >> 12) & 63), 0x80 | ((code >> 6) & 63), 0x80 | (code & 63));
391
- }
392
- return new Uint8Array(out);
393
- }
394
- // CLOUDFRONT_STRIP_END
395
- // ── Derived request device class (ADR 0035) ──────────────────────────
396
- //
397
- // device.class is the one request fact that is neither transcribed from the
398
- // request nor injected as a signal: it is classified here from headers the
399
- // request already carries, so this module, the released SDK copy, and the Go
400
- // preview evaluator agree without three separate plumbing points.
401
- //
402
- // The ORDER below is the contract, not an implementation detail. It is fixed
403
- // by internal/domain/audience/testdata/device-class.v1.json, which Go and both
404
- // TypeScript copies execute. Change the order only by changing that fixture.
405
- //
406
- // The result is approximate and derived from client-controlled input: a
407
- // personalization convenience, never an authorization or entitlement input.
408
- export const DEVICE_CLASSES = ['desktop', 'mobile', 'tablet', 'unknown'];
409
- // Mirrors MaxDeviceHeaderBytes in internal/domain/audience/device.go.
410
- const MAX_DEVICE_HEADER_BYTES = 512;
411
- // Tablet runs first because Android tablet agents contain "android" (mobile)
412
- // and every Android agent contains "linux" (desktop); iPad agents contain
413
- // neither. Sec-CH-UA-Mobile is a tiebreaker rather than the primary source
414
- // because UA-CH cannot express tablet at all.
415
- const DEVICE_TABLET_SIGNATURES = ['ipad', 'tablet', 'playbook', 'kindle', 'silk'];
416
- // 'iemobile' is deliberately absent: it contains 'mobi' and could never
417
- // match. Every signature must be reachable given the ones before it.
418
- const DEVICE_MOBILE_SIGNATURES = ['iphone', 'ipod', 'mobi', 'windows phone', 'blackberry', 'opera mini'];
419
- const DEVICE_DESKTOP_SIGNATURES = ['windows nt', 'macintosh', 'mac os x', 'x11', 'cros', 'linux'];
420
- /**
421
- * Classifies the request's device class from its own headers. Returns '' when
422
- * the request carried neither User-Agent nor Sec-CH-UA-Mobile, which the fact
423
- * resolver reports as ABSENT — the same status the missing header itself
424
- * resolves to, and deliberately distinct from the 'unknown' class, which means
425
- * classified and unrecognized.
426
- */
427
- export function deviceClassFromHeaders(headers) {
428
- const userAgent = deviceHeader(headers, 'user-agent');
429
- const uaMobile = deviceHeader(headers, 'sec-ch-ua-mobile');
430
- if (!userAgent && !uaMobile)
431
- return '';
432
- const lower = userAgent.toLowerCase();
433
- if (DEVICE_TABLET_SIGNATURES.some((token) => lower.indexOf(token) >= 0))
434
- return 'tablet';
435
- // An Android agent without a "mobi" token is a tablet: Chrome on Android
436
- // phones appends "Mobile", Android tablets do not. The explicit !"mobi"
437
- // guard is required, not redundant — the mobile pass runs AFTER this one.
438
- if (lower.indexOf('android') >= 0 && lower.indexOf('mobi') < 0)
439
- return 'tablet';
440
- if (DEVICE_MOBILE_SIGNATURES.some((token) => lower.indexOf(token) >= 0))
441
- return 'mobile';
442
- if (uaMobile === '?1')
443
- return 'mobile';
444
- if (DEVICE_DESKTOP_SIGNATURES.some((token) => lower.indexOf(token) >= 0))
445
- return 'desktop';
446
- if (uaMobile === '?0')
447
- return 'desktop';
448
- return 'unknown';
449
- }
450
- // deviceHeader reads one header so that Go's audience.ClassifyDeviceClass
451
- // cannot disagree with this function about the same request.
452
- //
453
- // Keys are lowercase here: every context builder flattens through the Fetch
454
- // API, which lowercases. There is deliberately NO case-insensitive fallback —
455
- // the header.* branch above has none either, and adding one for this field
456
- // only would mean a device rule resolves where an equivalent header rule
457
- // silently does not.
458
- //
459
- // Trimming is ASCII-only on purpose. String.prototype.trim strips U+FEFF but
460
- // not U+0085; Go's strings.TrimSpace does the reverse. Neither is "right";
461
- // they only have to match, so both sides trim the same ASCII set. The length
462
- // bound matches MaxDeviceHeaderBytes in device.go — no signature lives past
463
- // it, and the header is attacker-sized on an unauthenticated path.
464
- function deviceHeader(headers, key) {
465
- const raw = (headers && headers[key]) || '';
466
- if (typeof raw !== 'string')
467
- return '';
468
- return raw.slice(0, MAX_DEVICE_HEADER_BYTES).replace(/^[ \t\r\n\f\v]+|[ \t\r\n\f\v]+$/g, '');
469
- }
470
- // The class describes the REQUEST, so it is identical for every leaf. An
471
- // envelope may carry up to 64 conditions and a route many candidate variants,
472
- // each building its own resolver — without this, one page view could lowercase
473
- // and scan the user agent hundreds of times. Keyed on the context object, so
474
- // classify()/decide() reuse it too.
475
- const DEVICE_CLASS_CACHE = new WeakMap();
476
- function cachedDeviceClass(request) {
477
- const hit = DEVICE_CLASS_CACHE.get(request);
478
- if (hit !== undefined)
479
- return hit;
480
- const deviceClass = deviceClassFromHeaders(request.headers);
481
- DEVICE_CLASS_CACHE.set(request, deviceClass);
482
- return deviceClass;
483
- }
@@ -1,25 +0,0 @@
1
- import { type IdentitySigningKeys, type PersonalizationCollectionStore } from './personalization-host.js';
2
- export interface PersonalizationCollectRouteConfig {
3
- siteId: string;
4
- store: PersonalizationCollectionStore;
5
- identityKeys: IdentitySigningKeys;
6
- /**
7
- * Claims verify keys (kid -> secret) for withdrawal EVIDENCE
8
- * (anonymous-experiments REQ-6): suppression fires only when the request
9
- * presents a signature-verified aci_claims cookie — presence alone is
10
- * spoofable, and under universal minting an unverified trigger would
11
- * suppress every reject-all visitor. Empty = evidence never verifies =
12
- * suppression disabled (fails safe).
13
- */
14
- claimsKeys?: Record<string, string>;
15
- /**
16
- * Mimic sign key (audience-mimic design/05): a validly signed, unexpired
17
- * aci_mimic cookie gets the uniform 202 with nothing stored and no claims
18
- * rotation. An invalid cookie is ignored. Absent = no suppression.
19
- */
20
- mimicSignKey?: string;
21
- retentionSeconds?: number;
22
- secureCookies?: boolean;
23
- now?: () => Date;
24
- }
25
- export declare function createPersonalizationCollectRoute(config: PersonalizationCollectRouteConfig): (request: Request) => Promise<Response>;