@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.
- package/README.md +4 -4
- package/dist/astro/preview.js +2 -17
- package/dist/audience-data-models.d.ts +193 -16
- package/dist/audience-data-models.js +47 -2
- package/dist/collect/batcher.d.ts +6 -5
- package/dist/collect/batcher.js +6 -5
- package/dist/edge/expression-capabilities.v1.d.ts +106 -0
- package/dist/edge/expression-capabilities.v1.js +165 -0
- package/dist/internal/domain/edge/identity.js +1 -1
- package/dist/internal/domain/edge/mimic_token.js +1 -2
- package/dist/internal/domain/edge/personalization-core.d.ts +14 -4
- package/dist/internal/domain/edge/personalization-core.js +472 -54
- package/dist/internal/domain/edge/record/codec.d.ts +5 -2
- package/dist/internal/domain/edge/record/codec.js +83 -10
- package/dist/internal/domain/edge/resolve.js +13 -2
- package/dist/internal/domain/edge/testdata/flat_only_validator_v1.d.ts +10 -0
- package/dist/internal/domain/edge/testdata/flat_only_validator_v1.js +253 -0
- package/dist/internal/domain/edge/types.d.ts +36 -9
- package/dist/internal/domain/edge/types.js +3 -3
- package/dist/mimic/core.d.ts +0 -32
- package/dist/mimic/core.js +12 -108
- package/dist/next/vercel-edge-projection-v1.d.ts +0 -9
- package/dist/next/vercel-edge-projection-v1.js +9 -545
- package/dist/preview/core.d.ts +1 -0
- package/dist/preview/core.js +27 -0
- package/dist/react/VideoPlayer.d.ts +1 -1
- package/dist/react/index.d.ts +1 -1
- package/package.json +16 -7
- package/templates/common/.agents/skills/aci/SKILL.md +6 -0
- package/templates/common/.agents/skills/dam-delivery/SKILL.md +228 -0
- package/templates/common/.agents/skills/dam-delivery/references/parameter-contract.md +107 -0
- package/templates/common/.agents/skills/dam-delivery/references/permalink-grammar.md +131 -0
- package/dist/edge/personalization-core.d.ts +0 -35
- package/dist/edge/personalization-core.js +0 -483
- package/dist/next/collect-route.d.ts +0 -25
- package/dist/next/collect-route.js +0 -479
- package/dist/next/envelope-digest.d.ts +0 -21
- package/dist/next/envelope-digest.js +0 -58
- package/dist/next/personalization-host.d.ts +0 -138
- package/dist/next/personalization-host.js +0 -280
- package/dist/next/root-layout.d.ts +0 -4
- package/dist/next/root-layout.js +0 -5
- package/dist/next/vercel-collect-sdk-route.d.ts +0 -27
- package/dist/next/vercel-collect-sdk-route.js +0 -181
- package/dist/next/vercel-personalization.d.ts +0 -4
- package/dist/next/vercel-personalization.js +0 -4
- package/dist/next/vercel-s3-personalization.d.ts +0 -80
- 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>;
|