@gradial/aci 0.1.52-rc.13 → 0.1.52-rc.15
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/edge/personalization-core.d.ts +24 -0
- package/dist/edge/personalization-core.js +236 -34
- package/dist/internal/domain/edge/personalization-core.d.ts +51 -0
- package/dist/internal/domain/edge/personalization-core.js +232 -32
- package/dist/internal/domain/edge/record/codec.js +2 -2
- package/dist/internal/domain/edge/resolve.js +6 -7
- package/dist/next/collect-route.js +20 -15
- package/dist/next/personalization-host.js +6 -7
- package/package.json +7 -7
- package/templates/nextjs/template/package.json.tmpl +2 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Agentic Content Infrastructure SDK
|
|
2
2
|
|
|
3
|
-
`@gradial/aci` `0.1.52-rc.
|
|
3
|
+
`@gradial/aci` `0.1.52-rc.15` is a pre-1.0 public-surface reset for the ACI
|
|
4
4
|
client SDK. Breaking changes from the old `0.1.x` accidental surface are
|
|
5
5
|
intentional: the root import is framework-neutral, framework runtime behavior is
|
|
6
6
|
behind explicit subpaths, and the first required customer path is React/Next.
|
|
@@ -8,7 +8,7 @@ behind explicit subpaths, and the first required customer path is React/Next.
|
|
|
8
8
|
## Install
|
|
9
9
|
|
|
10
10
|
```bash
|
|
11
|
-
npm install @gradial/aci@0.1.52-rc.
|
|
11
|
+
npm install @gradial/aci@0.1.52-rc.15 zod@^4
|
|
12
12
|
```
|
|
13
13
|
|
|
14
14
|
## Root Import
|
|
@@ -78,13 +78,13 @@ host bundles select and serve immutable release artifacts.
|
|
|
78
78
|
The first RC scaffold path is Next:
|
|
79
79
|
|
|
80
80
|
```bash
|
|
81
|
-
npx @gradial/aci@0.1.52-rc.
|
|
81
|
+
npx @gradial/aci@0.1.52-rc.15 init my-site --template next-starter
|
|
82
82
|
```
|
|
83
83
|
|
|
84
84
|
List available templates:
|
|
85
85
|
|
|
86
86
|
```bash
|
|
87
|
-
npx @gradial/aci@0.1.52-rc.
|
|
87
|
+
npx @gradial/aci@0.1.52-rc.15 init --list
|
|
88
88
|
```
|
|
89
89
|
|
|
90
90
|
## Personalization: The One-Tag Install
|
|
@@ -9,3 +9,27 @@ export function resolvePersonalizationDecision(request: any, route: any, claims:
|
|
|
9
9
|
bucket: number;
|
|
10
10
|
} | null;
|
|
11
11
|
};
|
|
12
|
+
export function evaluateRequestExpression(rule: any, resolve: any): {
|
|
13
|
+
qualified: boolean;
|
|
14
|
+
diagnostics: {
|
|
15
|
+
code: string;
|
|
16
|
+
path: string;
|
|
17
|
+
scope: string;
|
|
18
|
+
message: string;
|
|
19
|
+
}[];
|
|
20
|
+
trace: {
|
|
21
|
+
scope: string;
|
|
22
|
+
empty: boolean;
|
|
23
|
+
groups: never[];
|
|
24
|
+
leaves: never[];
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Classifies the request's device class from its own headers. Returns '' when
|
|
29
|
+
* the request carried neither User-Agent nor Sec-CH-UA-Mobile, which the fact
|
|
30
|
+
* resolver reports as ABSENT — the same status the missing header itself
|
|
31
|
+
* resolves to, and deliberately distinct from the 'unknown' class, which means
|
|
32
|
+
* classified and unrecognized.
|
|
33
|
+
*/
|
|
34
|
+
export function deviceClassFromHeaders(headers: any): "unknown" | "" | "mobile" | "desktop" | "tablet";
|
|
35
|
+
export const DEVICE_CLASSES: string[];
|
|
@@ -141,49 +141,162 @@ function requestDimensionsMatch(request, values) {
|
|
|
141
141
|
}
|
|
142
142
|
return true;
|
|
143
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
|
+
}
|
|
144
156
|
function evaluateRule(request, rule) {
|
|
145
157
|
const all = rule.all || [];
|
|
146
158
|
const any = rule.any || [];
|
|
147
|
-
if (all
|
|
159
|
+
if (validateRequestExpression(rule, all, any).length > 0)
|
|
148
160
|
return false;
|
|
149
|
-
return all
|
|
150
|
-
&& (any.length === 0 || any.some((condition) => evaluateCondition(request, condition)));
|
|
161
|
+
return qualifyRequestExpression(all, any, (field) => signalFact(request, field));
|
|
151
162
|
}
|
|
152
|
-
function
|
|
153
|
-
const
|
|
154
|
-
if (
|
|
155
|
-
|
|
156
|
-
if (
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
switch (condition.operator) {
|
|
161
|
-
case 'equals': return value === expected;
|
|
162
|
-
case 'contains': return value.includes(expected);
|
|
163
|
-
case 'startsWith': return value.startsWith(expected);
|
|
164
|
-
default: return false;
|
|
165
|
-
}
|
|
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` });
|
|
166
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
|
+
}
|
|
167
236
|
}
|
|
168
|
-
function
|
|
169
|
-
if (
|
|
170
|
-
return
|
|
171
|
-
if (
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
return
|
|
175
|
-
if (field.startsWith('utm.'))
|
|
176
|
-
return request.query['utm_' + field.slice(4)] || [];
|
|
177
|
-
if (field.startsWith('geo.')) {
|
|
178
|
-
const geo = request.signals && request.signals.geo;
|
|
179
|
-
const value = geo && geo[field.slice(4).toLowerCase()];
|
|
180
|
-
return value ? [value] : [];
|
|
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' };
|
|
181
244
|
}
|
|
182
|
-
if (
|
|
183
|
-
|
|
184
|
-
|
|
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' };
|
|
185
271
|
}
|
|
186
|
-
|
|
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' };
|
|
187
300
|
}
|
|
188
301
|
// SHA-256 mirrors experiment.assignment.go: the first eight digest bytes are
|
|
189
302
|
// interpreted big-endian and reduced modulo 10,000 basis points.
|
|
@@ -279,3 +392,92 @@ function utf8(value) {
|
|
|
279
392
|
return new Uint8Array(out);
|
|
280
393
|
}
|
|
281
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,6 +1,48 @@
|
|
|
1
1
|
import type { PersonalizationRequestContext, PersonalizationClaims, PersonalizationVariant, PersonalizationRoute, PersonalizationExperiment, PersonalizationIdentity, PersonalizationDecision, ClassificationData, Classification, EdgeRouteEntry } from './types.js';
|
|
2
2
|
export declare function selectPersonalizationVariant(request: PersonalizationRequestContext, route: PersonalizationRoute | null, claims: PersonalizationClaims | null, experiments?: Record<string, PersonalizationExperiment>, identity?: PersonalizationIdentity): PersonalizationVariant | null;
|
|
3
3
|
export declare function resolvePersonalizationDecision(request: PersonalizationRequestContext, route: PersonalizationRoute | null, claims: PersonalizationClaims | null, experiments?: Record<string, PersonalizationExperiment>, identity?: PersonalizationIdentity): PersonalizationDecision;
|
|
4
|
+
export type ExpressionFactStatus = 'present' | 'absent' | 'unavailable';
|
|
5
|
+
export interface ExpressionFact {
|
|
6
|
+
status: ExpressionFactStatus;
|
|
7
|
+
values?: string[];
|
|
8
|
+
}
|
|
9
|
+
export interface ExpressionDiagnostic {
|
|
10
|
+
code: string;
|
|
11
|
+
path: string;
|
|
12
|
+
scope: 'request';
|
|
13
|
+
message: string;
|
|
14
|
+
}
|
|
15
|
+
export interface ExpressionLeafTrace {
|
|
16
|
+
path: string;
|
|
17
|
+
group: 'all' | 'any';
|
|
18
|
+
index: number;
|
|
19
|
+
field: string;
|
|
20
|
+
operator: string;
|
|
21
|
+
status: ExpressionFactStatus;
|
|
22
|
+
matched: boolean;
|
|
23
|
+
reason: 'matched' | 'not_matched' | 'absent' | 'unavailable';
|
|
24
|
+
}
|
|
25
|
+
export interface ExpressionGroupTrace {
|
|
26
|
+
group: 'all' | 'any';
|
|
27
|
+
total: number;
|
|
28
|
+
matched: number;
|
|
29
|
+
passed: boolean;
|
|
30
|
+
}
|
|
31
|
+
export interface RequestExpressionResult {
|
|
32
|
+
qualified: boolean;
|
|
33
|
+
diagnostics: ExpressionDiagnostic[];
|
|
34
|
+
trace: {
|
|
35
|
+
scope: 'request';
|
|
36
|
+
empty: boolean;
|
|
37
|
+
groups: ExpressionGroupTrace[];
|
|
38
|
+
leaves: ExpressionLeafTrace[];
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/** Evaluate one request expression and produce the same redacted trace used for qualification. */
|
|
42
|
+
export declare function evaluateRequestExpression(rule: {
|
|
43
|
+
all?: unknown[];
|
|
44
|
+
any?: unknown[];
|
|
45
|
+
}, resolve: (field: string) => ExpressionFact): RequestExpressionResult;
|
|
4
46
|
/**
|
|
5
47
|
* Phase 1: Classify the visitor against all release-level rules.
|
|
6
48
|
*
|
|
@@ -25,3 +67,12 @@ export declare function classify(ctx: PersonalizationRequestContext, data: Class
|
|
|
25
67
|
* @returns The decision — winning variant + experiment assignment, or nulls
|
|
26
68
|
*/
|
|
27
69
|
export declare function decide(record: EdgeRouteEntry, classification: Classification, ctx: PersonalizationRequestContext): PersonalizationDecision;
|
|
70
|
+
export declare const DEVICE_CLASSES: string[];
|
|
71
|
+
/**
|
|
72
|
+
* Classifies the request's device class from its own headers. Returns '' when
|
|
73
|
+
* the request carried neither User-Agent nor Sec-CH-UA-Mobile, which the fact
|
|
74
|
+
* resolver reports as ABSENT — the same status the missing header itself
|
|
75
|
+
* resolves to, and deliberately distinct from the 'unknown' class, which means
|
|
76
|
+
* classified and unrecognized.
|
|
77
|
+
*/
|
|
78
|
+
export declare function deviceClassFromHeaders(headers: Record<string, string>): string;
|
|
@@ -139,52 +139,163 @@ function requestDimensionsMatch(request, values) {
|
|
|
139
139
|
}
|
|
140
140
|
return true;
|
|
141
141
|
}
|
|
142
|
+
const MAX_EXPRESSION_CONDITIONS = 64;
|
|
143
|
+
/** Evaluate one request expression and produce the same redacted trace used for qualification. */
|
|
144
|
+
export function evaluateRequestExpression(rule, resolve) {
|
|
145
|
+
const all = (rule.all || []);
|
|
146
|
+
const any = (rule.any || []);
|
|
147
|
+
const empty = all.length + any.length === 0;
|
|
148
|
+
const diagnostics = validateRequestExpression(rule, all, any);
|
|
149
|
+
const trace = { scope: 'request', empty, groups: [], leaves: [] };
|
|
150
|
+
if (diagnostics.length > 0)
|
|
151
|
+
return { qualified: false, diagnostics, trace };
|
|
152
|
+
const qualified = qualifyRequestExpression(all, any, resolve, trace);
|
|
153
|
+
return { qualified, diagnostics, trace };
|
|
154
|
+
}
|
|
142
155
|
function evaluateRule(request, rule) {
|
|
143
156
|
const all = (rule.all || []);
|
|
144
157
|
const any = (rule.any || []);
|
|
145
|
-
if (all
|
|
158
|
+
if (validateRequestExpression(rule, all, any).length > 0)
|
|
146
159
|
return false;
|
|
147
|
-
return all
|
|
148
|
-
&& (any.length === 0 || any.some((condition) => evaluateCondition(request, condition)));
|
|
160
|
+
return qualifyRequestExpression(all, any, (field) => signalFact(request, field));
|
|
149
161
|
}
|
|
150
|
-
function
|
|
151
|
-
const
|
|
152
|
-
if (
|
|
153
|
-
|
|
154
|
-
if (
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
162
|
+
function validateRequestExpression(rule, all, any) {
|
|
163
|
+
const diagnostics = [];
|
|
164
|
+
if (all.length + any.length === 0)
|
|
165
|
+
diagnostics.push({ code: 'expression.empty', path: 'expression', scope: 'request', message: 'expression must include at least one all or any condition' });
|
|
166
|
+
if (all.length + any.length > MAX_EXPRESSION_CONDITIONS)
|
|
167
|
+
diagnostics.push({ code: 'expression.conditions.too_many', path: 'expression', scope: 'request', message: `expression must contain at most ${MAX_EXPRESSION_CONDITIONS} conditions` });
|
|
168
|
+
Object.keys(rule).filter((group) => group !== 'all' && group !== 'any').sort().forEach((group) => {
|
|
169
|
+
diagnostics.push({ code: 'expression.group.unsupported', path: `expression.${group}`, scope: 'request', message: `expression.${group} is not a supported expression group` });
|
|
170
|
+
});
|
|
171
|
+
for (const [group, conditions] of [['all', all], ['any', any]]) {
|
|
172
|
+
conditions.forEach((condition, index) => validateRequestCondition(condition, `expression.${group}[${index}]`, diagnostics));
|
|
173
|
+
}
|
|
174
|
+
return diagnostics;
|
|
175
|
+
}
|
|
176
|
+
function qualifyRequestExpression(all, any, resolve, trace) {
|
|
177
|
+
let qualified = true;
|
|
178
|
+
for (const [group, conditions] of [['all', all], ['any', any]]) {
|
|
179
|
+
let matched = 0;
|
|
180
|
+
conditions.forEach((condition, index) => {
|
|
181
|
+
const fact = normalizeFact(resolve(String(condition.field || '')));
|
|
182
|
+
const outcome = matchRequestCondition(condition, fact);
|
|
183
|
+
if (outcome.matched)
|
|
184
|
+
matched++;
|
|
185
|
+
if (trace)
|
|
186
|
+
trace.leaves.push({
|
|
187
|
+
path: `expression.${group}[${index}]`, group, index,
|
|
188
|
+
field: String(condition.field || ''), operator: String(condition.operator || ''),
|
|
189
|
+
status: fact.status, matched: outcome.matched, reason: outcome.reason,
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
const passed = group === 'all' ? matched === conditions.length : conditions.length === 0 || matched > 0;
|
|
193
|
+
if (trace)
|
|
194
|
+
trace.groups.push({ group, total: conditions.length, matched, passed });
|
|
195
|
+
qualified = qualified && passed;
|
|
196
|
+
}
|
|
197
|
+
return qualified;
|
|
164
198
|
}
|
|
165
|
-
function
|
|
199
|
+
function validateRequestCondition(condition, path, diagnostics) {
|
|
200
|
+
Object.keys(condition).filter((property) => !['field', 'operator', 'value'].includes(property)).sort().forEach((property) => {
|
|
201
|
+
diagnostics.push({ code: 'expression.property.unsupported', path: `${path}.${property}`, scope: 'request', message: `${path}.${property} is not a supported condition property` });
|
|
202
|
+
});
|
|
203
|
+
const field = String(condition.field || '');
|
|
204
|
+
if (field !== 'referrer' && field !== 'device.class' && !['query.', 'header.', 'cookie.', 'geo.', 'utm.'].some((prefix) => field.startsWith(prefix) && field.length > prefix.length)) {
|
|
205
|
+
diagnostics.push({ code: 'expression.field.unsupported', path: `${path}.field`, scope: 'request', message: `${path}.field ${JSON.stringify(field)} is not supported` });
|
|
206
|
+
}
|
|
207
|
+
const operator = String(condition.operator || '');
|
|
208
|
+
if (!['equals', 'contains', 'startsWith', 'exists'].includes(operator)) {
|
|
209
|
+
diagnostics.push({ code: 'expression.operator.unsupported', path: `${path}.operator`, scope: 'request', message: `${path}.operator ${JSON.stringify(operator)} is not supported` });
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
// device.class compares against a closed four-value set, so substring
|
|
213
|
+
// operators express nothing the set cannot — and admitting them would
|
|
214
|
+
// re-create the user-agent-substring habit inside the safe field (ADR 0037).
|
|
215
|
+
if (field === 'device.class' && operator !== 'equals' && operator !== 'exists') {
|
|
216
|
+
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` });
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const value = condition.value;
|
|
220
|
+
if (operator === 'exists') {
|
|
221
|
+
if (value !== undefined && value !== null && typeof value !== 'boolean') {
|
|
222
|
+
diagnostics.push({ code: 'expression.value.invalid', path: `${path}.value`, scope: 'request', message: `${path}.value must be a boolean for operator exists` });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
else if (value === undefined || value === null) {
|
|
226
|
+
diagnostics.push({ code: 'expression.value.required', path: `${path}.value`, scope: 'request', message: `${path}.value is required for operator ${operator}` });
|
|
227
|
+
}
|
|
228
|
+
else if (!['string', 'number', 'boolean'].includes(typeof value)
|
|
229
|
+
|| (typeof value === 'number' && (!Number.isFinite(value) || (Number.isInteger(value) && !Number.isSafeInteger(value))))) {
|
|
230
|
+
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` });
|
|
231
|
+
}
|
|
232
|
+
else if (field === 'device.class' && DEVICE_CLASSES.indexOf(canonicalScalar(value)) < 0) {
|
|
233
|
+
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)}` });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
function matchRequestCondition(condition, fact) {
|
|
237
|
+
if (fact.status === 'unavailable')
|
|
238
|
+
return { matched: false, reason: 'unavailable' };
|
|
239
|
+
if (condition.operator === 'exists') {
|
|
240
|
+
const expected = typeof condition.value === 'boolean' ? condition.value : true;
|
|
241
|
+
const matched = (fact.status === 'present') === expected;
|
|
242
|
+
return { matched, reason: matched ? 'matched' : fact.status === 'absent' ? 'absent' : 'not_matched' };
|
|
243
|
+
}
|
|
244
|
+
if (fact.status === 'absent')
|
|
245
|
+
return { matched: false, reason: 'absent' };
|
|
246
|
+
const expected = canonicalScalar(condition.value);
|
|
247
|
+
const matched = (fact.values || []).some((value) => condition.operator === 'equals' ? value === expected
|
|
248
|
+
: condition.operator === 'contains' ? value.includes(expected)
|
|
249
|
+
: condition.operator === 'startsWith' ? value.startsWith(expected) : false);
|
|
250
|
+
return { matched, reason: matched ? 'matched' : 'not_matched' };
|
|
251
|
+
}
|
|
252
|
+
function canonicalScalar(value) {
|
|
253
|
+
if (typeof value === 'number')
|
|
254
|
+
return Object.is(value, -0) ? '0' : JSON.stringify(value);
|
|
255
|
+
return String(value);
|
|
256
|
+
}
|
|
257
|
+
function normalizeFact(fact) {
|
|
258
|
+
if (!fact || !['present', 'absent', 'unavailable'].includes(fact.status))
|
|
259
|
+
return { status: 'unavailable' };
|
|
260
|
+
return fact;
|
|
261
|
+
}
|
|
262
|
+
function signalFact(request, field) {
|
|
263
|
+
const own = (record, key) => !!record && Object.prototype.hasOwnProperty.call(record, key);
|
|
166
264
|
const d = field.indexOf('.');
|
|
167
|
-
if (d < 0)
|
|
168
|
-
|
|
265
|
+
if (d < 0) {
|
|
266
|
+
if (field !== 'referrer')
|
|
267
|
+
return { status: 'absent' };
|
|
268
|
+
const key = own(request.headers, 'referer') ? 'referer' : 'referrer';
|
|
269
|
+
return own(request.headers, key) ? { status: 'present', values: [request.headers[key]] } : { status: 'absent' };
|
|
270
|
+
}
|
|
169
271
|
const p = field.slice(0, d), k = field.slice(d + 1);
|
|
170
272
|
if (p === 'query')
|
|
171
|
-
return request.query[k]
|
|
172
|
-
if (p === 'utm')
|
|
173
|
-
|
|
273
|
+
return own(request.query, k) ? { status: 'present', values: request.query[k] } : { status: 'absent' };
|
|
274
|
+
if (p === 'utm') {
|
|
275
|
+
const key = 'utm_' + k;
|
|
276
|
+
return own(request.query, key) ? { status: 'present', values: request.query[key] } : { status: 'absent' };
|
|
277
|
+
}
|
|
174
278
|
if (p === 'header') {
|
|
175
|
-
const
|
|
176
|
-
return
|
|
279
|
+
const key = k.toLowerCase();
|
|
280
|
+
return own(request.headers, key) ? { status: 'present', values: [request.headers[key]] } : { status: 'absent' };
|
|
177
281
|
}
|
|
178
|
-
if (p === 'cookie')
|
|
179
|
-
|
|
180
|
-
|
|
282
|
+
if (p === 'cookie')
|
|
283
|
+
return own(request.cookies, k) ? { status: 'present', values: [request.cookies[k]] } : { status: 'absent' };
|
|
284
|
+
if (p === 'device') {
|
|
285
|
+
// Derived, not injected: no signals plumbing, so preview, edge, and SDK
|
|
286
|
+
// agree by construction. A request with no user-agent header is absent.
|
|
287
|
+
if (k !== 'class')
|
|
288
|
+
return { status: 'absent' };
|
|
289
|
+
const deviceClass = cachedDeviceClass(request);
|
|
290
|
+
return deviceClass ? { status: 'present', values: [deviceClass] } : { status: 'absent' };
|
|
181
291
|
}
|
|
182
292
|
if (p === 'geo') {
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
293
|
+
const record = request.signals?.geo;
|
|
294
|
+
if (!record)
|
|
295
|
+
return { status: 'unavailable' };
|
|
296
|
+
return own(record, k) ? { status: 'present', values: [record[k]] } : { status: 'absent' };
|
|
186
297
|
}
|
|
187
|
-
return
|
|
298
|
+
return { status: 'absent' };
|
|
188
299
|
}
|
|
189
300
|
// SHA-256 mirrors experiment/assignment.go: the first eight digest bytes are
|
|
190
301
|
// interpreted big-endian and reduced modulo 10,000 basis points. Keep the
|
|
@@ -385,3 +496,92 @@ export function decide(record, classification, ctx) {
|
|
|
385
496
|
}
|
|
386
497
|
return { variant: bestVariant, experiment: bestExperiment };
|
|
387
498
|
}
|
|
499
|
+
// ── Derived request device class (ADR 0037) ──────────────────────────
|
|
500
|
+
//
|
|
501
|
+
// device.class is the one request fact that is neither transcribed from the
|
|
502
|
+
// request nor injected as a signal: it is classified here from headers the
|
|
503
|
+
// request already carries, so this module, the released SDK copy, and the Go
|
|
504
|
+
// preview evaluator agree without three separate plumbing points.
|
|
505
|
+
//
|
|
506
|
+
// The ORDER below is the contract, not an implementation detail. It is fixed
|
|
507
|
+
// by internal/domain/audience/testdata/device-class.v1.json, which Go and both
|
|
508
|
+
// TypeScript copies execute. Change the order only by changing that fixture.
|
|
509
|
+
//
|
|
510
|
+
// The result is approximate and derived from client-controlled input: a
|
|
511
|
+
// personalization convenience, never an authorization or entitlement input.
|
|
512
|
+
export const DEVICE_CLASSES = ['desktop', 'mobile', 'tablet', 'unknown'];
|
|
513
|
+
// Mirrors MaxDeviceHeaderBytes in internal/domain/audience/device.go.
|
|
514
|
+
const MAX_DEVICE_HEADER_BYTES = 512;
|
|
515
|
+
// Tablet runs first because Android tablet agents contain "android" (mobile)
|
|
516
|
+
// and every Android agent contains "linux" (desktop); iPad agents contain
|
|
517
|
+
// neither. Sec-CH-UA-Mobile is a tiebreaker rather than the primary source
|
|
518
|
+
// because UA-CH cannot express tablet at all.
|
|
519
|
+
const DEVICE_TABLET_SIGNATURES = ['ipad', 'tablet', 'playbook', 'kindle', 'silk'];
|
|
520
|
+
// 'iemobile' is deliberately absent: it contains 'mobi' and could never
|
|
521
|
+
// match. Every signature must be reachable given the ones before it.
|
|
522
|
+
const DEVICE_MOBILE_SIGNATURES = ['iphone', 'ipod', 'mobi', 'windows phone', 'blackberry', 'opera mini'];
|
|
523
|
+
const DEVICE_DESKTOP_SIGNATURES = ['windows nt', 'macintosh', 'mac os x', 'x11', 'cros', 'linux'];
|
|
524
|
+
/**
|
|
525
|
+
* Classifies the request's device class from its own headers. Returns '' when
|
|
526
|
+
* the request carried neither User-Agent nor Sec-CH-UA-Mobile, which the fact
|
|
527
|
+
* resolver reports as ABSENT — the same status the missing header itself
|
|
528
|
+
* resolves to, and deliberately distinct from the 'unknown' class, which means
|
|
529
|
+
* classified and unrecognized.
|
|
530
|
+
*/
|
|
531
|
+
export function deviceClassFromHeaders(headers) {
|
|
532
|
+
const userAgent = deviceHeader(headers, 'user-agent');
|
|
533
|
+
const uaMobile = deviceHeader(headers, 'sec-ch-ua-mobile');
|
|
534
|
+
if (!userAgent && !uaMobile)
|
|
535
|
+
return '';
|
|
536
|
+
const lower = userAgent.toLowerCase();
|
|
537
|
+
if (DEVICE_TABLET_SIGNATURES.some((token) => lower.indexOf(token) >= 0))
|
|
538
|
+
return 'tablet';
|
|
539
|
+
// An Android agent without a "mobi" token is a tablet: Chrome on Android
|
|
540
|
+
// phones appends "Mobile", Android tablets do not. The explicit !"mobi"
|
|
541
|
+
// guard is required, not redundant — the mobile pass runs AFTER this one.
|
|
542
|
+
if (lower.indexOf('android') >= 0 && lower.indexOf('mobi') < 0)
|
|
543
|
+
return 'tablet';
|
|
544
|
+
if (DEVICE_MOBILE_SIGNATURES.some((token) => lower.indexOf(token) >= 0))
|
|
545
|
+
return 'mobile';
|
|
546
|
+
if (uaMobile === '?1')
|
|
547
|
+
return 'mobile';
|
|
548
|
+
if (DEVICE_DESKTOP_SIGNATURES.some((token) => lower.indexOf(token) >= 0))
|
|
549
|
+
return 'desktop';
|
|
550
|
+
if (uaMobile === '?0')
|
|
551
|
+
return 'desktop';
|
|
552
|
+
return 'unknown';
|
|
553
|
+
}
|
|
554
|
+
// deviceHeader reads one header so that Go's audience.ClassifyDeviceClass
|
|
555
|
+
// cannot disagree with this function about the same request.
|
|
556
|
+
//
|
|
557
|
+
// Keys are lowercase here: every context builder flattens through the Fetch
|
|
558
|
+
// API, which lowercases. There is deliberately NO case-insensitive fallback —
|
|
559
|
+
// the header.* branch above has none either, and adding one for this field
|
|
560
|
+
// only would mean a device rule resolves where an equivalent header rule
|
|
561
|
+
// silently does not.
|
|
562
|
+
//
|
|
563
|
+
// Trimming is ASCII-only on purpose. String.prototype.trim strips U+FEFF but
|
|
564
|
+
// not U+0085; Go's strings.TrimSpace does the reverse. Neither is "right";
|
|
565
|
+
// they only have to match, so both sides trim the same ASCII set. The length
|
|
566
|
+
// bound matches MaxDeviceHeaderBytes in device.go — no signature lives past
|
|
567
|
+
// it, and the header is attacker-sized on an unauthenticated path.
|
|
568
|
+
function deviceHeader(headers, key) {
|
|
569
|
+
const raw = (headers && headers[key]) || '';
|
|
570
|
+
if (typeof raw !== 'string')
|
|
571
|
+
return '';
|
|
572
|
+
return raw.slice(0, MAX_DEVICE_HEADER_BYTES).replace(/^[ \t\r\n\f\v]+|[ \t\r\n\f\v]+$/g, '');
|
|
573
|
+
}
|
|
574
|
+
// The class describes the REQUEST, so it is identical for every leaf. An
|
|
575
|
+
// envelope may carry up to 64 conditions and a route many candidate variants,
|
|
576
|
+
// each building its own resolver — without this, one page view could lowercase
|
|
577
|
+
// and scan the user agent hundreds of times. Keyed on the context object, so
|
|
578
|
+
// classify()/decide() reuse it too.
|
|
579
|
+
const DEVICE_CLASS_CACHE = new WeakMap();
|
|
580
|
+
function cachedDeviceClass(request) {
|
|
581
|
+
const hit = DEVICE_CLASS_CACHE.get(request);
|
|
582
|
+
if (hit !== undefined)
|
|
583
|
+
return hit;
|
|
584
|
+
const deviceClass = deviceClassFromHeaders(request.headers);
|
|
585
|
+
DEVICE_CLASS_CACHE.set(request, deviceClass);
|
|
586
|
+
return deviceClass;
|
|
587
|
+
}
|
|
@@ -85,7 +85,7 @@ function projectCondition(cond) {
|
|
|
85
85
|
if (!compactOp)
|
|
86
86
|
return null;
|
|
87
87
|
const result = { f: cond.field, o: compactOp };
|
|
88
|
-
if (cond.
|
|
88
|
+
if (cond.value !== undefined)
|
|
89
89
|
result.w = cond.value;
|
|
90
90
|
return result;
|
|
91
91
|
}
|
|
@@ -100,7 +100,7 @@ function expandCondition(raw) {
|
|
|
100
100
|
if (!op)
|
|
101
101
|
return INVALID;
|
|
102
102
|
const c = { field: f, operator: op };
|
|
103
|
-
if (
|
|
103
|
+
if (raw.w !== undefined)
|
|
104
104
|
c.value = raw.w;
|
|
105
105
|
return c;
|
|
106
106
|
}
|
|
@@ -70,17 +70,16 @@ function toPersonalizationRequestContext(request) {
|
|
|
70
70
|
(query[key] ||= []).push(value);
|
|
71
71
|
});
|
|
72
72
|
const headers = Object.fromEntries(request.headers.entries());
|
|
73
|
+
const geo = Object.fromEntries(Object.entries({
|
|
74
|
+
country: headers['x-vercel-ip-country'] || headers['cloudfront-viewer-country'] || '',
|
|
75
|
+
region: headers['x-vercel-ip-country-region'] || headers['cloudfront-viewer-country-region'] || '',
|
|
76
|
+
city: headers['x-vercel-ip-city'] || headers['cloudfront-viewer-city'] || '',
|
|
77
|
+
}).filter(([, value]) => value !== ''));
|
|
73
78
|
return {
|
|
74
79
|
path: url.pathname,
|
|
75
80
|
query,
|
|
76
81
|
headers,
|
|
77
82
|
cookies: Object.fromEntries(cookies.entries()),
|
|
78
|
-
signals: {
|
|
79
|
-
geo: {
|
|
80
|
-
country: headers['x-vercel-ip-country'] || headers['cloudfront-viewer-country'] || '',
|
|
81
|
-
region: headers['x-vercel-ip-country-region'] || headers['cloudfront-viewer-country-region'] || '',
|
|
82
|
-
city: headers['x-vercel-ip-city'] || headers['cloudfront-viewer-city'] || '',
|
|
83
|
-
},
|
|
84
|
-
},
|
|
83
|
+
...(Object.keys(geo).length > 0 ? { signals: { geo } } : {}),
|
|
85
84
|
};
|
|
86
85
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { MAX_BATCH_EVENTS } from '../collect/batcher.js';
|
|
2
2
|
import { computeEnvelopeDigest } from './envelope-digest.js';
|
|
3
3
|
import { validateMimicToken } from '../mimic/core.js';
|
|
4
|
+
import { deviceClassFromHeaders } from '../edge/personalization-core.js';
|
|
4
5
|
import { consentSignals, IdentityErasureFencedError, identityHashHex, parseCookieHeader, verifyClaims, verifyIdentityCookie, } from './personalization-host.js';
|
|
5
6
|
const MAX_COLLECT_BODY_BYTES = 64 * 1024;
|
|
6
7
|
const DEFAULT_RETENTION_SECONDS = 13 * 30 * 24 * 60 * 60;
|
|
@@ -19,9 +20,7 @@ const EVENT_FIELDS = new Set([
|
|
|
19
20
|
'utm',
|
|
20
21
|
'viewport',
|
|
21
22
|
'experiment',
|
|
22
|
-
'deviceClass',
|
|
23
23
|
]);
|
|
24
|
-
const DEVICE_CLASSES = new Set(['desktop', 'mobile', 'tablet', 'unknown']);
|
|
25
24
|
function identityHashShard(identityHash) {
|
|
26
25
|
const hash = identityHash.trim().toLowerCase();
|
|
27
26
|
if (hash.length < 2)
|
|
@@ -130,6 +129,10 @@ export function createPersonalizationCollectRoute(config) {
|
|
|
130
129
|
}
|
|
131
130
|
const identityHash = await identityHashHex('aci_id', rawIdentity);
|
|
132
131
|
const geo = collectGeo(request);
|
|
132
|
+
// Server-derived from the request's own headers by the SAME classifier
|
|
133
|
+
// request-scope targeting uses (ADR 0035), so a reported class and a
|
|
134
|
+
// targeted class agree by construction. Never read from the body.
|
|
135
|
+
const deviceClass = collectDeviceClass(request);
|
|
133
136
|
const collectedAt = now.toISOString();
|
|
134
137
|
const expiresAt = new Date(now.getTime() +
|
|
135
138
|
(config.retentionSeconds ?? DEFAULT_RETENTION_SECONDS) * 1000).toISOString();
|
|
@@ -163,7 +166,7 @@ export function createPersonalizationCollectRoute(config) {
|
|
|
163
166
|
// Server-stamped geo (country + region only, D-PDP-14) from the
|
|
164
167
|
// platform-injected viewer headers; never from the event body.
|
|
165
168
|
...(geo ? { geo } : {}),
|
|
166
|
-
deviceClass
|
|
169
|
+
deviceClass,
|
|
167
170
|
occurredAt: normalized.occurredAt,
|
|
168
171
|
collectedAt,
|
|
169
172
|
expiresAt,
|
|
@@ -247,9 +250,6 @@ function validateEvent(value) {
|
|
|
247
250
|
if (value.utm !== undefined && (!value.utm || typeof value.utm !== 'object' || Array.isArray(value.utm))) {
|
|
248
251
|
throw new CollectValidationError('utm must be an object');
|
|
249
252
|
}
|
|
250
|
-
if (value.deviceClass !== undefined && typeof value.deviceClass !== 'string') {
|
|
251
|
-
throw new CollectValidationError('deviceClass must be a string');
|
|
252
|
-
}
|
|
253
253
|
normalizeViewport(value.viewport);
|
|
254
254
|
normalizeExperiment(value.experiment);
|
|
255
255
|
}
|
|
@@ -271,7 +271,6 @@ function normalizeEvent(event, now) {
|
|
|
271
271
|
eventName,
|
|
272
272
|
eventCategory,
|
|
273
273
|
occurredAt,
|
|
274
|
-
deviceClass: normalizeDeviceClass(event.deviceClass),
|
|
275
274
|
properties: event.properties === undefined ? undefined : sanitizeProperties(event.properties, 'properties', 0),
|
|
276
275
|
page: {
|
|
277
276
|
...(pageURL ? { url: pageURL } : {}),
|
|
@@ -291,10 +290,11 @@ function normalizeEvent(event, now) {
|
|
|
291
290
|
// keeps its historical byte shape for no-source requests.
|
|
292
291
|
function collectGeo(request) {
|
|
293
292
|
const country = normalizeGeoCountry(request.headers.get('x-vercel-ip-country'));
|
|
294
|
-
|
|
295
|
-
if (!country
|
|
293
|
+
// A region without its country keys nothing (see runtime/collect.ts).
|
|
294
|
+
if (!country)
|
|
296
295
|
return undefined;
|
|
297
|
-
|
|
296
|
+
const region = normalizeGeoRegion(request.headers.get('x-vercel-ip-country-region'));
|
|
297
|
+
return { country, ...(region ? { region } : {}) };
|
|
298
298
|
}
|
|
299
299
|
function normalizeGeoCountry(value) {
|
|
300
300
|
const clean = (value ?? '').trim();
|
|
@@ -304,11 +304,16 @@ function normalizeGeoRegion(value) {
|
|
|
304
304
|
const clean = (value ?? '').trim();
|
|
305
305
|
return /^[a-zA-Z0-9]{1,3}$/.test(clean) ? clean.toUpperCase() : '';
|
|
306
306
|
}
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
307
|
+
// collectDeviceClass classifies the request through the shared edge/SDK
|
|
308
|
+
// classifier. Capture has no "absent" bucket — the class joins a rollup key —
|
|
309
|
+
// so a request with nothing to classify floors to the enum's 'unknown', while
|
|
310
|
+
// request-scope targeting keeps absence distinct.
|
|
311
|
+
function collectDeviceClass(request) {
|
|
312
|
+
const headers = {};
|
|
313
|
+
request.headers.forEach((value, name) => {
|
|
314
|
+
headers[name.toLowerCase()] = value;
|
|
315
|
+
});
|
|
316
|
+
return deviceClassFromHeaders(headers) || 'unknown';
|
|
312
317
|
}
|
|
313
318
|
function normalizeEventName(value) {
|
|
314
319
|
const name = value.trim().toLowerCase();
|
|
@@ -202,18 +202,17 @@ function toPersonalizationRequestContext(request) {
|
|
|
202
202
|
(query[key] ||= []).push(value);
|
|
203
203
|
});
|
|
204
204
|
const headers = Object.fromEntries(request.headers.entries());
|
|
205
|
+
const geo = Object.fromEntries(Object.entries({
|
|
206
|
+
country: headers['x-vercel-ip-country'] || '',
|
|
207
|
+
region: headers['x-vercel-ip-country-region'] || '',
|
|
208
|
+
city: headers['x-vercel-ip-city'] || '',
|
|
209
|
+
}).filter(([, value]) => value !== ''));
|
|
205
210
|
return {
|
|
206
211
|
path: url.pathname,
|
|
207
212
|
query,
|
|
208
213
|
headers,
|
|
209
214
|
cookies: Object.fromEntries(cookies.entries()),
|
|
210
|
-
signals: {
|
|
211
|
-
geo: {
|
|
212
|
-
country: headers['x-vercel-ip-country'] || '',
|
|
213
|
-
region: headers['x-vercel-ip-country-region'] || '',
|
|
214
|
-
city: headers['x-vercel-ip-city'] || '',
|
|
215
|
-
},
|
|
216
|
-
},
|
|
215
|
+
...(Object.keys(geo).length > 0 ? { signals: { geo } } : {}),
|
|
217
216
|
};
|
|
218
217
|
}
|
|
219
218
|
export async function verifyClaims(value, keys, now) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gradial/aci",
|
|
3
|
-
"version": "0.1.52-rc.
|
|
3
|
+
"version": "0.1.52-rc.15",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/gradial/bare-metal-go.git",
|
|
@@ -146,11 +146,11 @@
|
|
|
146
146
|
"zod": "^4.0.0"
|
|
147
147
|
},
|
|
148
148
|
"optionalDependencies": {
|
|
149
|
-
"@gradial/aci-cli-darwin-arm64": "0.1.52-rc.
|
|
150
|
-
"@gradial/aci-cli-darwin-x64": "0.1.52-rc.
|
|
151
|
-
"@gradial/aci-cli-linux-arm64": "0.1.52-rc.
|
|
152
|
-
"@gradial/aci-cli-linux-x64": "0.1.52-rc.
|
|
153
|
-
"@gradial/aci-cli-win32-arm64": "0.1.52-rc.
|
|
154
|
-
"@gradial/aci-cli-win32-x64": "0.1.52-rc.
|
|
149
|
+
"@gradial/aci-cli-darwin-arm64": "0.1.52-rc.15",
|
|
150
|
+
"@gradial/aci-cli-darwin-x64": "0.1.52-rc.15",
|
|
151
|
+
"@gradial/aci-cli-linux-arm64": "0.1.52-rc.15",
|
|
152
|
+
"@gradial/aci-cli-linux-x64": "0.1.52-rc.15",
|
|
153
|
+
"@gradial/aci-cli-win32-arm64": "0.1.52-rc.15",
|
|
154
|
+
"@gradial/aci-cli-win32-x64": "0.1.52-rc.15"
|
|
155
155
|
}
|
|
156
156
|
}
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"dependencies": {
|
|
22
22
|
"@c15t/nextjs": "^2.2.1",
|
|
23
23
|
"@gradial/aci": "{{aciVersion}}",
|
|
24
|
-
"next": "
|
|
24
|
+
"next": "15.5.24",
|
|
25
25
|
"react": "^19.0.0",
|
|
26
26
|
"react-dom": "^19.0.0"
|
|
27
27
|
},
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"@eslint/compat": "^1.2.0",
|
|
33
33
|
"@eslint/eslintrc": "^3.2.0",
|
|
34
34
|
"eslint": "^9.18.0",
|
|
35
|
-
"eslint-config-next": "
|
|
35
|
+
"eslint-config-next": "15.5.24",
|
|
36
36
|
"eslint-plugin-import": "^2.29.1",
|
|
37
37
|
"tailwindcss": "^4.2.4",
|
|
38
38
|
"@tailwindcss/postcss": "^4.2.4",
|