@gradial/aci 0.1.52-rc.2 → 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/CHANGELOG.md +7 -0
- package/README.md +4 -4
- package/dist/astro/preview.js +2 -17
- package/dist/audience-data-models.d.ts +289 -16
- package/dist/audience-data-models.js +99 -2
- package/dist/cli/compile-registry.d.mts +5 -1
- package/dist/cli/compile-registry.mjs +25 -10
- package/dist/cli/validate-content.d.mts +31 -1
- package/dist/cli/validate-content.mjs +50 -12
- package/dist/collect/aci.js +1 -1
- package/dist/collect/aci.js.sha256 +1 -1
- package/dist/collect/api.d.ts +3 -7
- package/dist/collect/api.js +2 -8
- package/dist/collect/batcher.d.ts +6 -5
- package/dist/collect/batcher.js +6 -5
- package/dist/collect/index.js +1 -1
- package/dist/content/provider.d.ts +1 -0
- package/dist/content/resolve-slots.d.ts +9 -2
- package/dist/content/resolve-slots.js +5 -3
- package/dist/content/types.d.ts +2 -0
- package/dist/edge/expression-capabilities.v1.d.ts +106 -0
- package/dist/edge/expression-capabilities.v1.js +165 -0
- package/dist/internal/domain/edge/compose.d.ts +24 -0
- package/dist/internal/domain/edge/compose.js +89 -0
- package/dist/internal/domain/edge/identity.js +1 -1
- package/dist/internal/domain/edge/index.d.ts +2 -0
- package/dist/internal/domain/edge/index.js +2 -0
- package/dist/internal/domain/edge/mimic_token.d.ts +13 -0
- package/dist/internal/domain/edge/mimic_token.js +58 -0
- package/dist/internal/domain/edge/personalization-core.d.ts +69 -5
- package/dist/internal/domain/edge/personalization-core.js +693 -58
- package/dist/internal/domain/edge/record/codec.d.ts +5 -2
- package/dist/internal/domain/edge/record/codec.js +85 -12
- package/dist/internal/domain/edge/resolve.js +38 -18
- 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 +52 -9
- package/dist/internal/domain/edge/types.js +3 -3
- package/dist/mimic/core.d.ts +2 -0
- package/dist/mimic/core.js +31 -0
- package/dist/next/error-seat-payload.js +1 -1
- package/dist/next/hosted-page-target.d.ts +7 -0
- package/dist/next/hosted-page-target.js +39 -0
- package/dist/next/index.d.ts +0 -1
- package/dist/next/index.js +0 -1
- package/dist/next/page.d.ts +29 -6
- package/dist/next/page.js +47 -25
- package/dist/next/server.js +28 -1
- package/dist/next/vercel-edge-projection-v1.d.ts +0 -9
- package/dist/next/vercel-edge-projection-v1.js +9 -539
- package/dist/preview/core.d.ts +1 -0
- package/dist/preview/core.js +27 -0
- package/dist/providers/s3.js +4 -3
- package/dist/react/VideoPlayer.d.ts +1 -1
- package/dist/react/index.d.ts +1 -1
- package/dist/types/block-ref.d.ts +2 -0
- package/dist/types/block-ref.js +1 -0
- package/package.json +16 -7
- package/src/cli/compile-registry.mjs +24 -10
- package/src/cli/validate-content.mjs +52 -13
- package/src/types/block-ref.test.ts +45 -0
- package/src/types/block-ref.ts +2 -0
- package/templates/astro/starter-content/src/fixtures/atomicBlocks.ts +7 -6
- package/templates/astro/starter-content/src/fixtures/composition.ts +3 -2
- package/templates/astro/starter-content/src/fixtures/sections.ts +2 -1
- package/templates/astro/template/package.json.tmpl +1 -1
- package/templates/astro/template/src/layouts/SiteLayout.astro +2 -2
- package/templates/common/.agents/skills/aci/SKILL.md +6 -0
- package/templates/common/.agents/skills/authoring/SKILL.md +11 -3
- package/templates/common/.agents/skills/authoring/references/block-slots.md +3 -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/templates/common/.content/pages/{home/_index.json → _index.json} +10 -0
- package/templates/common/.content/schemas/site.schema.json +1 -1
- package/templates/common/.nvmrc +1 -1
- package/templates/common/starter-content/.content/.rules-baseline.json +8 -0
- package/templates/common/starter-content/.content/config/site.json +1 -1
- package/templates/common/starter-content/.content/content-rules/no-public-resource-paths.json +6 -0
- package/templates/common/starter-content/.content/fragments/navbar/_index.json +23 -20
- package/templates/common/starter-content/.content/fragments/site-footer/_index.json +59 -56
- package/templates/common/starter-content/.content/pages/404/_index.json +1 -0
- package/templates/common/starter-content/.content/pages/about/_index.json +3 -0
- package/templates/common/starter-content/.content/pages/error/_index.json +1 -0
- package/templates/common/starter-content/.content/pages/home/_index.json +9 -0
- package/templates/common/starter-content/.content/patterns/components/button.json +55 -0
- package/templates/common/starter-content/.content/patterns/components/container.json +62 -0
- package/templates/common/starter-content/.content/patterns/components/feature_card.json +30 -0
- package/templates/common/starter-content/.content/patterns/components/feature_grid.json +57 -0
- package/templates/common/starter-content/.content/patterns/components/home_hero.json +142 -0
- package/templates/common/starter-content/.content/patterns/components/personalization_choice.json +42 -0
- package/templates/common/starter-content/.content/patterns/components/site-footer.json +203 -0
- package/templates/common/starter-content/.content/patterns/components/site-navigation.json +115 -0
- package/templates/common/starter-content/.content/patterns/components/stats_bar.json +45 -0
- package/templates/common/starter-content/.content/patterns/layouts/default.json +14 -0
- package/templates/common/starter-content/.content/schemas/site.schema.json +1 -1
- package/templates/nextjs/template/.aci.yaml +10 -0
- package/templates/nextjs/template/package.json.tmpl +4 -3
- package/templates/nextjs/template/src/app/_components/c15t-bridge.ts +30 -0
- package/templates/nextjs/template/src/app/_components/c15t-consent-ui.tsx +37 -0
- package/templates/nextjs/template/src/app/_components/consent-manager.css +63 -0
- package/templates/nextjs/template/src/app/_components/consent-manager.tsx +76 -0
- package/templates/nextjs/template/src/app/layout.tsx +2 -0
- package/dist/edge/personalization-core.d.ts +0 -11
- package/dist/edge/personalization-core.js +0 -269
- package/dist/next/collect-route.d.ts +0 -19
- package/dist/next/collect-route.js +0 -459
- package/dist/next/envelope-digest.d.ts +0 -21
- package/dist/next/envelope-digest.js +0 -58
- package/dist/next/middleware.d.ts +0 -40
- package/dist/next/middleware.js +0 -475
- package/dist/next/personalization-host.d.ts +0 -134
- package/dist/next/personalization-host.js +0 -270
- 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 -79
- package/dist/next/vercel-s3-personalization.js +0 -570
- /package/templates/common/.content/overlays/audience/preference-blue/pages/{home/_index.json → _index.json} +0 -0
- /package/templates/common/.content/overlays/audience/preference-red/pages/{home/_index.json → _index.json} +0 -0
|
@@ -5,12 +5,6 @@
|
|
|
5
5
|
// This is the pure decision algorithm — no I/O, no cookies, no identity
|
|
6
6
|
// minting. The caller resolves identity and claims, then calls this.
|
|
7
7
|
import { variantValuesFromKey } from './record/codec.js';
|
|
8
|
-
// Build-time constant. The CloudFront Functions bundle replaces this with
|
|
9
|
-
// true and uses the runtime's built-in crypto module; other host bundles
|
|
10
|
-
// replace it with false and retain the portable implementation.
|
|
11
|
-
export function selectPersonalizationVariant(request, route, claims, experiments, identity) {
|
|
12
|
-
return resolvePersonalizationDecision(request, route, claims, experiments, identity).variant;
|
|
13
|
-
}
|
|
14
8
|
export function resolvePersonalizationDecision(request, route, claims, experiments, identity) {
|
|
15
9
|
if (!route)
|
|
16
10
|
return { variant: null, experiment: null };
|
|
@@ -20,6 +14,7 @@ export function resolvePersonalizationDecision(request, route, claims, experimen
|
|
|
20
14
|
const candidates = Object.values(experiments)
|
|
21
15
|
.filter((item) => (item.kind === 'ab' || item.kind === 'abn') && item.status === 'running')
|
|
22
16
|
.filter((item) => routeHasExperiment(route, item.id))
|
|
17
|
+
.filter((item) => experimentEligible(item, claims))
|
|
23
18
|
.sort((left, right) => (right.priority || 0) - (left.priority || 0)
|
|
24
19
|
|| String(left.id || '').localeCompare(String(right.id || '')));
|
|
25
20
|
for (let index = 0; index < candidates.length; index++) {
|
|
@@ -30,6 +25,13 @@ export function resolvePersonalizationDecision(request, route, claims, experimen
|
|
|
30
25
|
continue;
|
|
31
26
|
if (assignment && !candidate.variantKey)
|
|
32
27
|
candidate.variantKey = assignment.variantKey;
|
|
28
|
+
// A bucket assignment is candidacy, not entitlement: an arm variant
|
|
29
|
+
// carrying a rule must pass the same serve-time evaluation the rule
|
|
30
|
+
// leg applies (ADR 0040). A rejection fires the onRuleRejected sink
|
|
31
|
+
// inside evaluateRule; the candidate is skipped — never served, and
|
|
32
|
+
// never left behind as a stale experiment decision.
|
|
33
|
+
if (candidate.rule && !evaluateRule(request, candidate.rule, claims, candidate.variantKey))
|
|
34
|
+
continue;
|
|
33
35
|
if (!variant || betterVariant(candidate.priority || 0, candidate.variantKey, variant.priority || 0, variant.variantKey)) {
|
|
34
36
|
variant = candidate;
|
|
35
37
|
experiment = assignment;
|
|
@@ -50,13 +52,14 @@ function selectRuleVariant(request, route, claims, experiments) {
|
|
|
50
52
|
const values = variant.variantValues || variantValuesFromKey(key);
|
|
51
53
|
const experimentID = String(values.experiment || '').trim();
|
|
52
54
|
if (experimentID && (!experiments || !experiments[experimentID]
|
|
53
|
-
|| experiments[experimentID].status !== 'running'
|
|
55
|
+
|| experiments[experimentID].status !== 'running'
|
|
56
|
+
|| !experimentEligible(experiments[experimentID], claims)))
|
|
54
57
|
continue;
|
|
55
58
|
if (!requestDimensionsMatch(request, values))
|
|
56
59
|
continue;
|
|
57
60
|
const membershipMatch = (values.audience && claims && (claims.aud || []).includes(values.audience))
|
|
58
61
|
|| (values.segment && claims && (claims.seg || []).includes(values.segment));
|
|
59
|
-
const ruleMatch = variant.rule ? evaluateRule(request, variant.rule) : false;
|
|
62
|
+
const ruleMatch = variant.rule ? evaluateRule(request, variant.rule, claims, variant.variantKey) : false;
|
|
60
63
|
if (membershipMatch || ruleMatch)
|
|
61
64
|
matches.push(variant);
|
|
62
65
|
}
|
|
@@ -64,6 +67,16 @@ function selectRuleVariant(request, route, claims, experiments) {
|
|
|
64
67
|
|| String(left.variantKey || '').localeCompare(String(right.variantKey || '')));
|
|
65
68
|
return matches[0] || null;
|
|
66
69
|
}
|
|
70
|
+
// The eligibility gate (audience-scoped-experiments ASE-F4/F5): an
|
|
71
|
+
// experiment declaring eligibilityAudienceId is skipped entirely — before
|
|
72
|
+
// bucketing or arm candidacy — unless the verified claims carry the ID.
|
|
73
|
+
// Ungated experiments return true, preserving byte-identical behavior.
|
|
74
|
+
function experimentEligible(item, claims) {
|
|
75
|
+
const audienceID = String(item.eligibilityAudienceId || '').trim();
|
|
76
|
+
if (!audienceID)
|
|
77
|
+
return true;
|
|
78
|
+
return !!claims && (claims.aud || []).includes(audienceID);
|
|
79
|
+
}
|
|
67
80
|
function routeHasExperiment(route, experimentID) {
|
|
68
81
|
for (const key in route.variants || {}) {
|
|
69
82
|
const values = route.variants[key].variantValues || variantValuesFromKey(key);
|
|
@@ -127,52 +140,565 @@ function requestDimensionsMatch(request, values) {
|
|
|
127
140
|
}
|
|
128
141
|
return true;
|
|
129
142
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
143
|
+
const MAX_EXPRESSION_CONDITIONS = 64;
|
|
144
|
+
// Authored nesting depth (ADR 0040). The top-level envelope is depth 1 and a
|
|
145
|
+
// group node is one deeper than the list containing it. Pinned to the
|
|
146
|
+
// capability artifact by the drift test, like the grammars above.
|
|
147
|
+
const MAX_EXPRESSION_DEPTH = 5;
|
|
148
|
+
// A member carrying `all` or `any` is an envelope, not a condition. Presence
|
|
149
|
+
// decides — never the value's type — so all three runtimes classify the same
|
|
150
|
+
// member the same way.
|
|
151
|
+
function isExpressionGroup(member) {
|
|
152
|
+
// Null-guarded because this runs on a viewer request path: a malformed
|
|
153
|
+
// member must fail closed to the base experience, never throw. A non-object
|
|
154
|
+
// member is classified as a leaf and rejected by ordinary validation.
|
|
155
|
+
return !!member && typeof member === 'object'
|
|
156
|
+
&& (Object.prototype.hasOwnProperty.call(member, 'all')
|
|
157
|
+
|| Object.prototype.hasOwnProperty.call(member, 'any'));
|
|
158
|
+
}
|
|
159
|
+
// expressionMembers returns a group node's member list: [] when the key is
|
|
160
|
+
// absent, null when it is present but not an array. Go rejects that document
|
|
161
|
+
// at decode; here it fails closed as a diagnostic rather than letting the
|
|
162
|
+
// member reclassify as a leaf.
|
|
163
|
+
function expressionMembers(member, group) {
|
|
164
|
+
if (!Object.prototype.hasOwnProperty.call(member, group))
|
|
165
|
+
return [];
|
|
166
|
+
const value = member[group];
|
|
167
|
+
if (!Array.isArray(value))
|
|
168
|
+
return null;
|
|
169
|
+
return value;
|
|
170
|
+
}
|
|
171
|
+
// countExpressionLeaves counts leaves across the tree, applying the same depth
|
|
172
|
+
// cutoff the validating walk does so the two bounds cannot disagree about one
|
|
173
|
+
// expression.
|
|
174
|
+
function countExpressionLeaves(members, depth) {
|
|
175
|
+
let total = 0;
|
|
176
|
+
for (const member of members) {
|
|
177
|
+
if (!isExpressionGroup(member)) {
|
|
178
|
+
total++;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (depth + 1 > MAX_EXPRESSION_DEPTH)
|
|
182
|
+
continue;
|
|
183
|
+
total += countExpressionLeaves(expressionMembers(member, 'all') || [], depth + 1)
|
|
184
|
+
+ countExpressionLeaves(expressionMembers(member, 'any') || [], depth + 1);
|
|
185
|
+
}
|
|
186
|
+
return total;
|
|
187
|
+
}
|
|
188
|
+
/** Evaluate one request expression and produce the same redacted trace used for qualification. */
|
|
189
|
+
export function evaluateRequestExpression(rule, resolve) {
|
|
190
|
+
const all = (Array.isArray(rule.all) ? rule.all : []);
|
|
191
|
+
const any = (Array.isArray(rule.any) ? rule.any : []);
|
|
192
|
+
const empty = all.length + any.length === 0;
|
|
193
|
+
const diagnostics = validateRequestExpression(rule, all, any);
|
|
194
|
+
const trace = { scope: 'request', empty, groups: [], leaves: [] };
|
|
195
|
+
if (diagnostics.length > 0)
|
|
196
|
+
return { qualified: false, diagnostics, trace };
|
|
197
|
+
const qualified = qualifyRequestExpression(all, any, resolve, trace);
|
|
198
|
+
return { qualified, diagnostics, trace };
|
|
199
|
+
}
|
|
200
|
+
function evaluateRule(request, rule, claims, variantKey) {
|
|
201
|
+
const all = (Array.isArray(rule.all) ? rule.all : []);
|
|
202
|
+
const any = (Array.isArray(rule.any) ? rule.any : []);
|
|
203
|
+
const diagnostics = validateRequestExpression(rule, all, any);
|
|
204
|
+
if (diagnostics.length > 0) {
|
|
205
|
+
// Records pass the write-time gate before they ship, so a rule rejected
|
|
206
|
+
// HERE — at serve time — is always version skew or a projection bug, never
|
|
207
|
+
// ordinary authoring error. That makes it a precise signal, and discarding
|
|
208
|
+
// it silently is what made the predicted incident (author previews a rule
|
|
209
|
+
// successfully while a stale lane serves base) undebuggable (ADR 0040).
|
|
210
|
+
// Codes and paths only; never a fact, operand, or request value.
|
|
211
|
+
if (request.onRuleRejected) {
|
|
212
|
+
request.onRuleRejected({
|
|
213
|
+
variantKey: variantKey || '',
|
|
214
|
+
diagnosticCode: diagnostics[0].code,
|
|
215
|
+
diagnosticPath: diagnostics[0].path,
|
|
216
|
+
diagnosticCount: diagnostics.length,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
// audience.memberships resolves from the verified (or mimic) claims object
|
|
222
|
+
// the decision entry points already hold, never from the request bag — an
|
|
223
|
+
// unverified cookie can never impersonate membership (ADR 0039).
|
|
224
|
+
return qualifyRequestExpression(all, any, (field) => field === 'audience.memberships'
|
|
225
|
+
? membershipsFact(claims)
|
|
226
|
+
: signalFact(request, field));
|
|
227
|
+
}
|
|
228
|
+
// No claims → unavailable: an unconsented visitor is not a known non-member,
|
|
229
|
+
// so every operator fails closed, exists:false included. Verified claims with
|
|
230
|
+
// no memberships → absent, so exists:false selects exactly the consented
|
|
231
|
+
// visitor who belongs to nothing (ADR 0039).
|
|
232
|
+
function membershipsFact(claims) {
|
|
233
|
+
if (!claims)
|
|
234
|
+
return { status: 'unavailable' };
|
|
235
|
+
// Array.isArray, not || []: a malformed truthy aud (a scalar from a buggy
|
|
236
|
+
// claims writer) must degrade to absent, never reach the matcher's
|
|
237
|
+
// .some() and throw on a viewer-request path.
|
|
238
|
+
const memberships = Array.isArray(claims.aud) ? claims.aud : [];
|
|
239
|
+
return memberships.length > 0 ? { status: 'present', values: memberships } : { status: 'absent' };
|
|
240
|
+
}
|
|
241
|
+
function validateRequestExpression(rule, all, any) {
|
|
242
|
+
const diagnostics = [];
|
|
133
243
|
if (all.length + any.length === 0)
|
|
244
|
+
diagnostics.push({ code: 'expression.empty', path: 'expression', scope: 'request', message: 'expression must include at least one all or any condition' });
|
|
245
|
+
if (countExpressionLeaves(all, 1) + countExpressionLeaves(any, 1) > MAX_EXPRESSION_CONDITIONS)
|
|
246
|
+
diagnostics.push({ code: 'expression.conditions.too_many', path: 'expression', scope: 'request', message: `expression must contain at most ${MAX_EXPRESSION_CONDITIONS} conditions` });
|
|
247
|
+
Object.keys(rule).filter((group) => group !== 'all' && group !== 'any').sort().forEach((group) => {
|
|
248
|
+
diagnostics.push({ code: 'expression.group.unsupported', path: `expression.${group}`, scope: 'request', message: `expression.${group} is not a supported expression group` });
|
|
249
|
+
});
|
|
250
|
+
for (const [group, members] of [['all', all], ['any', any]]) {
|
|
251
|
+
validateExpressionMembers('expression', group, members, 1, diagnostics);
|
|
252
|
+
}
|
|
253
|
+
return diagnostics;
|
|
254
|
+
}
|
|
255
|
+
// validateExpressionMembers validates one envelope's member list. depth is the
|
|
256
|
+
// depth of the envelope owning these members; the top-level envelope is 1.
|
|
257
|
+
function validateExpressionMembers(path, group, members, depth, diagnostics) {
|
|
258
|
+
members.forEach((member, index) => {
|
|
259
|
+
const memberPath = `${path}.${group}[${index}]`;
|
|
260
|
+
if (!member || typeof member !== 'object' || !isExpressionGroup(member)) {
|
|
261
|
+
validateRequestCondition(member || {}, memberPath, diagnostics);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
// One diagnostic per broken node, and no descent past it: a too-deep
|
|
265
|
+
// branch reports its depth once rather than once per buried condition.
|
|
266
|
+
if (depth + 1 > MAX_EXPRESSION_DEPTH) {
|
|
267
|
+
diagnostics.push({ code: 'expression.depth.exceeded', path: memberPath, scope: 'request', message: `${memberPath} must not nest more than ${MAX_EXPRESSION_DEPTH} levels deep` });
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
const nestedAll = expressionMembers(member, 'all');
|
|
271
|
+
const nestedAny = expressionMembers(member, 'any');
|
|
272
|
+
if (nestedAll === null || nestedAny === null) {
|
|
273
|
+
const malformed = nestedAll === null ? 'all' : 'any';
|
|
274
|
+
diagnostics.push({ code: 'expression.group.unsupported', path: `${memberPath}.${malformed}`, scope: 'request', message: `${memberPath}.${malformed} must be an array of conditions or groups` });
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (nestedAll.length + nestedAny.length === 0) {
|
|
278
|
+
diagnostics.push({ code: 'expression.empty', path: memberPath, scope: 'request', message: `${memberPath} must include at least one all or any condition` });
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
Object.keys(member).filter((key) => key !== 'all' && key !== 'any').sort().forEach((key) => {
|
|
282
|
+
// Name the mistake, not its symptom: telling an author that `field` "is
|
|
283
|
+
// not a supported group" describes the classification, not the error.
|
|
284
|
+
const message = key === 'field' || key === 'operator' || key === 'value'
|
|
285
|
+
? `${memberPath}.${key} is not supported: a member carrying all or any is a group, not a condition, so it cannot also carry ${key}`
|
|
286
|
+
: `${memberPath}.${key} is not a supported expression group`;
|
|
287
|
+
diagnostics.push({ code: 'expression.group.unsupported', path: `${memberPath}.${key}`, scope: 'request', message });
|
|
288
|
+
});
|
|
289
|
+
validateExpressionMembers(memberPath, 'all', nestedAll, depth + 1, diagnostics);
|
|
290
|
+
validateExpressionMembers(memberPath, 'any', nestedAny, depth + 1, diagnostics);
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
// qualifyRequestExpression applies the envelope truth table to one node's
|
|
294
|
+
// member lists, recursing through group nodes (ADR 0040). Evaluation stays
|
|
295
|
+
// exhaustive so the trace holds every leaf outcome.
|
|
296
|
+
//
|
|
297
|
+
// topLevel carries nesting's one asymmetry: the top envelope always records
|
|
298
|
+
// both group rows because they express the scope's empty-envelope policy,
|
|
299
|
+
// while a nested node records only for a list it carries members in — which
|
|
300
|
+
// also keeps the trace stable across a codec that drops empty lists.
|
|
301
|
+
function qualifyRequestExpression(all, any, resolve, trace, path = 'expression', topLevel = true) {
|
|
302
|
+
let qualified = true;
|
|
303
|
+
for (const [group, members] of [['all', all], ['any', any]]) {
|
|
304
|
+
if (!topLevel && members.length === 0)
|
|
305
|
+
continue;
|
|
306
|
+
let matched = 0;
|
|
307
|
+
members.forEach((member, index) => {
|
|
308
|
+
const memberPath = `${path}.${group}[${index}]`;
|
|
309
|
+
let passed = false;
|
|
310
|
+
if (isExpressionGroup(member)) {
|
|
311
|
+
passed = qualifyRequestExpression(expressionMembers(member, 'all') || [], expressionMembers(member, 'any') || [], resolve, trace, memberPath, false);
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
const fact = normalizeFact(resolve(String(member.field || '')));
|
|
315
|
+
const outcome = matchRequestCondition(member, fact);
|
|
316
|
+
passed = outcome.matched;
|
|
317
|
+
if (trace)
|
|
318
|
+
trace.leaves.push({
|
|
319
|
+
path: memberPath, group, index,
|
|
320
|
+
field: String(member.field || ''), operator: String(member.operator || ''),
|
|
321
|
+
status: fact.status, matched: outcome.matched, reason: outcome.reason,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
if (passed)
|
|
325
|
+
matched++;
|
|
326
|
+
});
|
|
327
|
+
const passed = group === 'all' ? matched === members.length : members.length === 0 || matched > 0;
|
|
328
|
+
if (trace)
|
|
329
|
+
trace.groups.push({ path: `${path}.${group}`, group, total: members.length, matched, passed });
|
|
330
|
+
qualified = qualified && passed;
|
|
331
|
+
}
|
|
332
|
+
return qualified;
|
|
333
|
+
}
|
|
334
|
+
function validateRequestCondition(condition, path, diagnostics) {
|
|
335
|
+
Object.keys(condition).filter((property) => !['field', 'operator', 'value'].includes(property)).sort().forEach((property) => {
|
|
336
|
+
diagnostics.push({ code: 'expression.property.unsupported', path: `${path}.${property}`, scope: 'request', message: `${path}.${property} is not a supported condition property` });
|
|
337
|
+
});
|
|
338
|
+
const field = String(condition.field || '');
|
|
339
|
+
if (field !== 'referrer' && field !== 'device.class' && field !== 'time.now' && field !== 'audience.memberships' && !['query.', 'header.', 'cookie.', 'geo.', 'utm.'].some((prefix) => field.startsWith(prefix) && field.length > prefix.length)) {
|
|
340
|
+
diagnostics.push({ code: 'expression.field.unsupported', path: `${path}.field`, scope: 'request', message: `${path}.field ${JSON.stringify(field)} is not supported` });
|
|
341
|
+
}
|
|
342
|
+
const operator = String(condition.operator || '');
|
|
343
|
+
if (!EXPRESSION_OPERATORS.includes(operator)) {
|
|
344
|
+
diagnostics.push({ code: 'expression.operator.unsupported', path: `${path}.operator`, scope: 'request', message: `${path}.operator ${JSON.stringify(operator)} is not supported` });
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
// device.class compares against a closed four-value set, so substring
|
|
348
|
+
// operators express nothing the set cannot — and admitting them would
|
|
349
|
+
// re-create the user-agent-substring habit inside the safe field (ADR 0037).
|
|
350
|
+
if (field === 'device.class' && !['equals', 'in', 'exists'].includes(operator)) {
|
|
351
|
+
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` });
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
// time.now is a sub-second-churning clock: string operators are silently
|
|
355
|
+
// inert on it and the numeric family can never match an RFC 3339 string
|
|
356
|
+
// (ADR 0038).
|
|
357
|
+
if (field === 'time.now' && !['before', 'after', 'between', 'exists'].includes(operator)) {
|
|
358
|
+
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 supports only before, after, between, exists` });
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
// audience.memberships is an opaque-ID list: equals is already
|
|
362
|
+
// any-member-equals under the multi-valued match loop, so the substring
|
|
363
|
+
// family would only add silent overmatching of prefixed IDs, and the
|
|
364
|
+
// numeric/instant families can never match an ID (ADR 0039).
|
|
365
|
+
if (field === 'audience.memberships' && !['equals', 'in', 'exists'].includes(operator)) {
|
|
366
|
+
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 supports only equals, in, exists` });
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
const value = condition.value;
|
|
370
|
+
if (operator === 'exists') {
|
|
371
|
+
if (value !== undefined && value !== null && typeof value !== 'boolean') {
|
|
372
|
+
diagnostics.push({ code: 'expression.value.invalid', path: `${path}.value`, scope: 'request', message: `${path}.value must be a boolean for operator exists` });
|
|
373
|
+
}
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
if (value === undefined || value === null) {
|
|
377
|
+
diagnostics.push({ code: 'expression.value.required', path: `${path}.value`, scope: 'request', message: `${path}.value is required for operator ${operator}` });
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
if (['equals', 'contains', 'startsWith'].includes(operator)) {
|
|
381
|
+
if (!['string', 'number', 'boolean'].includes(typeof value)
|
|
382
|
+
|| (typeof value === 'number' && !expressionNumberInDomain(value))) {
|
|
383
|
+
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` });
|
|
384
|
+
}
|
|
385
|
+
else if (field === 'device.class' && DEVICE_CLASSES.indexOf(canonicalScalar(value)) < 0) {
|
|
386
|
+
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)}` });
|
|
387
|
+
}
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
if (['greaterThan', 'greaterThanOrEqual', 'lessThan', 'lessThanOrEqual'].includes(operator)) {
|
|
391
|
+
if (typeof value !== 'number' || !expressionNumberInDomain(value)) {
|
|
392
|
+
diagnostics.push({ code: 'expression.value.invalid', path: `${path}.value`, scope: 'request', message: `${path}.value must be a finite JSON number using only safe integers for operator ${operator}` });
|
|
393
|
+
}
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
if (operator === 'before' || operator === 'after') {
|
|
397
|
+
if (typeof value !== 'string' || parseExpressionTimestamp(value) === null) {
|
|
398
|
+
diagnostics.push({ code: 'expression.value.invalid', path: `${path}.value`, scope: 'request', message: `${path}.value must be an RFC 3339 timestamp with an explicit UTC offset for operator ${operator}` });
|
|
399
|
+
}
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
if (operator === 'between') {
|
|
403
|
+
if (!validExpressionRange(value)) {
|
|
404
|
+
diagnostics.push({ code: 'expression.value.invalid', path: `${path}.value`, scope: 'request', message: `${path}.value must be a min/max object whose bounds are both numbers or both timestamps with min not above max` });
|
|
405
|
+
}
|
|
406
|
+
else if (field === 'time.now' && typeof value.min === 'number') {
|
|
407
|
+
// An instant field's observed values are instants: a numeric range on
|
|
408
|
+
// it would validate and then sit silently inert at execution.
|
|
409
|
+
diagnostics.push({ code: 'expression.value.invalid', path: `${path}.value`, scope: 'request', message: `${path}.value bounds must be timestamps for field ${JSON.stringify(field)}` });
|
|
410
|
+
}
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
// in: bounded, non-empty, every member one canonicalizable scalar, all
|
|
414
|
+
// members one JSON type — a mixed list is almost always an authoring bug.
|
|
415
|
+
const members = homogeneousExpressionMembers(value);
|
|
416
|
+
if (members === null) {
|
|
417
|
+
diagnostics.push({ code: 'expression.value.invalid', path: `${path}.value`, scope: 'request', message: `${path}.value must be an array of 1 to ${MAX_IN_VALUES} scalars sharing one type` });
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
if (field === 'device.class') {
|
|
421
|
+
for (const member of members) {
|
|
422
|
+
if (DEVICE_CLASSES.indexOf(member) < 0) {
|
|
423
|
+
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)}` });
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
function matchRequestCondition(condition, fact) {
|
|
430
|
+
if (fact.status === 'unavailable')
|
|
431
|
+
return { matched: false, reason: 'unavailable' };
|
|
432
|
+
if (condition.operator === 'exists') {
|
|
433
|
+
const expected = typeof condition.value === 'boolean' ? condition.value : true;
|
|
434
|
+
const matched = (fact.status === 'present') === expected;
|
|
435
|
+
return { matched, reason: matched ? 'matched' : fact.status === 'absent' ? 'absent' : 'not_matched' };
|
|
436
|
+
}
|
|
437
|
+
if (fact.status === 'absent')
|
|
438
|
+
return { matched: false, reason: 'absent' };
|
|
439
|
+
const matcher = expressionConditionMatcher(condition);
|
|
440
|
+
if (!matcher)
|
|
441
|
+
return { matched: false, reason: 'not_matched' };
|
|
442
|
+
const matched = (fact.values || []).some(matcher);
|
|
443
|
+
return { matched, reason: matched ? 'matched' : 'not_matched' };
|
|
444
|
+
}
|
|
445
|
+
export const EXPRESSION_OPERATORS = [
|
|
446
|
+
'equals', 'contains', 'startsWith', 'exists',
|
|
447
|
+
'greaterThan', 'greaterThanOrEqual', 'lessThan', 'lessThanOrEqual',
|
|
448
|
+
'before', 'after', 'between', 'in',
|
|
449
|
+
];
|
|
450
|
+
const MAX_IN_VALUES = 32;
|
|
451
|
+
// Observed values are gated on the published grammars before any host parser
|
|
452
|
+
// runs (ADR 0038): Number() and Date.parse accept supersets (hex, empty
|
|
453
|
+
// string, vendor date formats) that would let the same fact match here and
|
|
454
|
+
// not in Go. Keep these literals identical to the capability artifact — the
|
|
455
|
+
// drift test compares them byte-for-byte.
|
|
456
|
+
export const EXPRESSION_NUMBER_GRAMMAR = /^-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?$/;
|
|
457
|
+
export const EXPRESSION_TIMESTAMP_GRAMMAR = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$/;
|
|
458
|
+
function expressionNumberInDomain(value) {
|
|
459
|
+
if (!Number.isFinite(value))
|
|
134
460
|
return false;
|
|
135
|
-
|
|
136
|
-
&& (any.length === 0 || any.some((condition) => evaluateCondition(request, condition)));
|
|
137
|
-
}
|
|
138
|
-
function evaluateCondition(request, condition) {
|
|
139
|
-
const values = signalValues(request, String(condition.field || ''));
|
|
140
|
-
if (condition.operator === 'exists')
|
|
141
|
-
return values.length > 0;
|
|
142
|
-
if (values.length === 0 || typeof condition.value !== 'string' && typeof condition.value !== 'number' && typeof condition.value !== 'boolean')
|
|
461
|
+
if (Number.isInteger(value) && !Number.isSafeInteger(value))
|
|
143
462
|
return false;
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
if (
|
|
148
|
-
return
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
463
|
+
return true;
|
|
464
|
+
}
|
|
465
|
+
function parseObservedExpressionNumber(value) {
|
|
466
|
+
if (!EXPRESSION_NUMBER_GRAMMAR.test(value))
|
|
467
|
+
return null;
|
|
468
|
+
const parsed = Number(value);
|
|
469
|
+
return expressionNumberInDomain(parsed) ? parsed : null;
|
|
470
|
+
}
|
|
471
|
+
// Explicit calendar validation runs before Date arithmetic because Date.UTC
|
|
472
|
+
// (like Go's time.Date) silently normalizes impossible dates. Instants
|
|
473
|
+
// compare as epoch milliseconds; fractional seconds truncate to milliseconds.
|
|
474
|
+
function parseExpressionTimestamp(value) {
|
|
475
|
+
const match = EXPRESSION_TIMESTAMP_GRAMMAR.exec(value);
|
|
476
|
+
if (!match)
|
|
477
|
+
return null;
|
|
478
|
+
const year = Number(match[1]), month = Number(match[2]), day = Number(match[3]);
|
|
479
|
+
const hour = Number(match[4]), minute = Number(match[5]), second = Number(match[6]);
|
|
480
|
+
if (month < 1 || month > 12 || day < 1 || day > expressionDaysInMonth(year, month))
|
|
481
|
+
return null;
|
|
482
|
+
if (hour > 23 || minute > 59 || second > 59)
|
|
483
|
+
return null;
|
|
484
|
+
let offsetMinutes = 0;
|
|
485
|
+
if (match[8] !== 'Z') {
|
|
486
|
+
const offsetHour = Number(match[8].slice(1, 3)), offsetMinute = Number(match[8].slice(4, 6));
|
|
487
|
+
if (offsetHour > 23 || offsetMinute > 59)
|
|
488
|
+
return null;
|
|
489
|
+
offsetMinutes = offsetHour * 60 + offsetMinute;
|
|
490
|
+
if (match[8][0] === '-')
|
|
491
|
+
offsetMinutes = -offsetMinutes;
|
|
492
|
+
}
|
|
493
|
+
const fractionMillis = match[7] ? Number((match[7].slice(1) + '00').slice(0, 3)) : 0;
|
|
494
|
+
// setUTCFullYear, not Date.UTC: Date.UTC maps two-digit years to 1900+.
|
|
495
|
+
const date = new Date(0);
|
|
496
|
+
date.setUTCFullYear(year, month - 1, day);
|
|
497
|
+
date.setUTCHours(hour, minute, second, 0);
|
|
498
|
+
return date.getTime() - offsetMinutes * 60000 + fractionMillis;
|
|
499
|
+
}
|
|
500
|
+
function expressionDaysInMonth(year, month) {
|
|
501
|
+
if (month === 4 || month === 6 || month === 9 || month === 11)
|
|
502
|
+
return 30;
|
|
503
|
+
if (month === 2)
|
|
504
|
+
return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28;
|
|
505
|
+
return 31;
|
|
506
|
+
}
|
|
507
|
+
function scalarExpressionOperand(value) {
|
|
508
|
+
if (value === undefined || value === null)
|
|
509
|
+
return null;
|
|
510
|
+
const kind = typeof value;
|
|
511
|
+
if (kind !== 'string' && kind !== 'number' && kind !== 'boolean')
|
|
512
|
+
return null;
|
|
513
|
+
if (kind === 'number' && !expressionNumberInDomain(value))
|
|
514
|
+
return null;
|
|
515
|
+
return canonicalScalar(value);
|
|
516
|
+
}
|
|
517
|
+
function validExpressionRange(value) {
|
|
518
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
519
|
+
return false;
|
|
520
|
+
const record = value;
|
|
521
|
+
if (Object.keys(record).some((key) => key !== 'min' && key !== 'max'))
|
|
522
|
+
return false;
|
|
523
|
+
const min = record.min, max = record.max;
|
|
524
|
+
if (min === undefined || min === null || max === undefined || max === null)
|
|
525
|
+
return false;
|
|
526
|
+
if (typeof min === 'number' && expressionNumberInDomain(min)) {
|
|
527
|
+
return typeof max === 'number' && expressionNumberInDomain(max) && min <= max;
|
|
528
|
+
}
|
|
529
|
+
const minMillis = typeof min === 'string' ? parseExpressionTimestamp(min) : null;
|
|
530
|
+
if (minMillis === null)
|
|
531
|
+
return false;
|
|
532
|
+
const maxMillis = typeof max === 'string' ? parseExpressionTimestamp(max) : null;
|
|
533
|
+
return maxMillis !== null && minMillis <= maxMillis;
|
|
534
|
+
}
|
|
535
|
+
function homogeneousExpressionMembers(value) {
|
|
536
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > MAX_IN_VALUES)
|
|
537
|
+
return null;
|
|
538
|
+
let memberKind = '';
|
|
539
|
+
const members = [];
|
|
540
|
+
for (const member of value) {
|
|
541
|
+
const kind = typeof member;
|
|
542
|
+
if (kind !== 'string' && kind !== 'number' && kind !== 'boolean')
|
|
543
|
+
return null;
|
|
544
|
+
if (kind === 'number' && !expressionNumberInDomain(member))
|
|
545
|
+
return null;
|
|
546
|
+
if (!memberKind)
|
|
547
|
+
memberKind = kind;
|
|
548
|
+
else if (memberKind !== kind)
|
|
549
|
+
return null;
|
|
550
|
+
members.push(canonicalScalar(member));
|
|
551
|
+
}
|
|
552
|
+
return members;
|
|
553
|
+
}
|
|
554
|
+
// expressionConditionMatcher builds the per-observed-value predicate for one
|
|
555
|
+
// condition. Null means the authored side cannot be interpreted — validation
|
|
556
|
+
// rejects those expressions first, so this only fails closed for direct use.
|
|
557
|
+
function expressionConditionMatcher(condition) {
|
|
558
|
+
const operator = String(condition.operator || '');
|
|
559
|
+
const operand = condition.value;
|
|
560
|
+
switch (operator) {
|
|
561
|
+
case 'equals':
|
|
562
|
+
case 'contains':
|
|
563
|
+
case 'startsWith': {
|
|
564
|
+
const expected = scalarExpressionOperand(operand);
|
|
565
|
+
if (expected === null)
|
|
566
|
+
return null;
|
|
567
|
+
return (value) => operator === 'equals' ? value === expected
|
|
568
|
+
: operator === 'contains' ? value.includes(expected) : value.startsWith(expected);
|
|
569
|
+
}
|
|
570
|
+
case 'greaterThan':
|
|
571
|
+
case 'greaterThanOrEqual':
|
|
572
|
+
case 'lessThan':
|
|
573
|
+
case 'lessThanOrEqual': {
|
|
574
|
+
if (typeof operand !== 'number' || !expressionNumberInDomain(operand))
|
|
575
|
+
return null;
|
|
576
|
+
return (value) => {
|
|
577
|
+
const observed = parseObservedExpressionNumber(value);
|
|
578
|
+
if (observed === null)
|
|
579
|
+
return false;
|
|
580
|
+
return operator === 'greaterThan' ? observed > operand
|
|
581
|
+
: operator === 'greaterThanOrEqual' ? observed >= operand
|
|
582
|
+
: operator === 'lessThan' ? observed < operand : observed <= operand;
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
case 'before':
|
|
586
|
+
case 'after': {
|
|
587
|
+
const operandMillis = typeof operand === 'string' ? parseExpressionTimestamp(operand) : null;
|
|
588
|
+
if (operandMillis === null)
|
|
589
|
+
return null;
|
|
590
|
+
return (value) => {
|
|
591
|
+
const observed = parseExpressionTimestamp(value);
|
|
592
|
+
if (observed === null)
|
|
593
|
+
return false;
|
|
594
|
+
return operator === 'after' ? observed > operandMillis : observed < operandMillis;
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
case 'between': {
|
|
598
|
+
if (!validExpressionRange(operand))
|
|
599
|
+
return null;
|
|
600
|
+
const record = operand;
|
|
601
|
+
if (typeof record.min === 'number') {
|
|
602
|
+
const min = record.min, max = record.max;
|
|
603
|
+
return (value) => {
|
|
604
|
+
const observed = parseObservedExpressionNumber(value);
|
|
605
|
+
return observed !== null && observed >= min && observed <= max;
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
const min = parseExpressionTimestamp(record.min);
|
|
609
|
+
const max = parseExpressionTimestamp(record.max);
|
|
610
|
+
return (value) => {
|
|
611
|
+
const observed = parseExpressionTimestamp(value);
|
|
612
|
+
return observed !== null && observed >= min && observed <= max;
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
case 'in': {
|
|
616
|
+
if (!Array.isArray(operand) || operand.length === 0 || operand.length > MAX_IN_VALUES)
|
|
617
|
+
return null;
|
|
618
|
+
const members = [];
|
|
619
|
+
for (const member of operand) {
|
|
620
|
+
const canonical = scalarExpressionOperand(member);
|
|
621
|
+
if (canonical === null)
|
|
622
|
+
return null;
|
|
623
|
+
members.push(canonical);
|
|
624
|
+
}
|
|
625
|
+
return (value) => members.includes(value);
|
|
626
|
+
}
|
|
627
|
+
default:
|
|
628
|
+
return null;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
function canonicalScalar(value) {
|
|
632
|
+
if (typeof value === 'number')
|
|
633
|
+
return Object.is(value, -0) ? '0' : JSON.stringify(value);
|
|
634
|
+
return String(value);
|
|
152
635
|
}
|
|
153
|
-
function
|
|
636
|
+
function normalizeFact(fact) {
|
|
637
|
+
if (!fact || !['present', 'absent', 'unavailable'].includes(fact.status))
|
|
638
|
+
return { status: 'unavailable' };
|
|
639
|
+
return fact;
|
|
640
|
+
}
|
|
641
|
+
function signalFact(request, field) {
|
|
642
|
+
const own = (record, key) => !!record && Object.prototype.hasOwnProperty.call(record, key);
|
|
154
643
|
const d = field.indexOf('.');
|
|
155
|
-
if (d < 0)
|
|
156
|
-
|
|
644
|
+
if (d < 0) {
|
|
645
|
+
if (field !== 'referrer')
|
|
646
|
+
return { status: 'absent' };
|
|
647
|
+
const key = own(request.headers, 'referer') ? 'referer' : 'referrer';
|
|
648
|
+
return own(request.headers, key) ? { status: 'present', values: [request.headers[key]] } : { status: 'absent' };
|
|
649
|
+
}
|
|
157
650
|
const p = field.slice(0, d), k = field.slice(d + 1);
|
|
158
651
|
if (p === 'query')
|
|
159
|
-
return request.query[k]
|
|
160
|
-
if (p === 'utm')
|
|
161
|
-
|
|
652
|
+
return own(request.query, k) ? { status: 'present', values: request.query[k] } : { status: 'absent' };
|
|
653
|
+
if (p === 'utm') {
|
|
654
|
+
const key = 'utm_' + k;
|
|
655
|
+
return own(request.query, key) ? { status: 'present', values: request.query[key] } : { status: 'absent' };
|
|
656
|
+
}
|
|
162
657
|
if (p === 'header') {
|
|
163
|
-
const
|
|
164
|
-
return
|
|
658
|
+
const key = k.toLowerCase();
|
|
659
|
+
return own(request.headers, key) ? { status: 'present', values: [request.headers[key]] } : { status: 'absent' };
|
|
165
660
|
}
|
|
166
|
-
if (p === 'cookie')
|
|
167
|
-
|
|
168
|
-
|
|
661
|
+
if (p === 'cookie')
|
|
662
|
+
return own(request.cookies, k) ? { status: 'present', values: [request.cookies[k]] } : { status: 'absent' };
|
|
663
|
+
if (p === 'device') {
|
|
664
|
+
// Derived, not injected: no signals plumbing, so preview, edge, and SDK
|
|
665
|
+
// agree by construction. A request with no user-agent header is absent.
|
|
666
|
+
if (k !== 'class')
|
|
667
|
+
return { status: 'absent' };
|
|
668
|
+
const deviceClass = cachedDeviceClass(request);
|
|
669
|
+
return deviceClass ? { status: 'present', values: [deviceClass] } : { status: 'absent' };
|
|
169
670
|
}
|
|
170
671
|
if (p === 'geo') {
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
672
|
+
const record = request.signals?.geo;
|
|
673
|
+
if (!record)
|
|
674
|
+
return { status: 'unavailable' };
|
|
675
|
+
return own(record, k) ? { status: 'present', values: [record[k]] } : { status: 'absent' };
|
|
676
|
+
}
|
|
677
|
+
if (p === 'time') {
|
|
678
|
+
// The one clock fact (ADR 0038): resolved from the runtime's own clock,
|
|
679
|
+
// one RFC 3339 UTC value, so the kernel stays clock-free. Hosts set
|
|
680
|
+
// request.now (epoch ms) once per request for determinism.
|
|
681
|
+
if (k !== 'now')
|
|
682
|
+
return { status: 'absent' };
|
|
683
|
+
return { status: 'present', values: [cachedTimeNow(request)] };
|
|
174
684
|
}
|
|
175
|
-
return
|
|
685
|
+
return { status: 'absent' };
|
|
686
|
+
}
|
|
687
|
+
const TIME_NOW_CACHE = new WeakMap();
|
|
688
|
+
// cachedTimeNow resolves the clock ONCE per request context, like
|
|
689
|
+
// cachedDeviceClass: every time.now leaf in one evaluation reads the same
|
|
690
|
+
// instant. A host-supplied request.now outside Date's representable range
|
|
691
|
+
// falls back to the runtime clock rather than throwing from a viewer-request
|
|
692
|
+
// path — this module fails closed, never open.
|
|
693
|
+
function cachedTimeNow(request) {
|
|
694
|
+
const hit = TIME_NOW_CACHE.get(request);
|
|
695
|
+
if (hit !== undefined)
|
|
696
|
+
return hit;
|
|
697
|
+
const pinned = typeof request.now === 'number' && Number.isFinite(request.now)
|
|
698
|
+
&& Math.abs(request.now) <= 8640000000000000 ? request.now : Date.now();
|
|
699
|
+
const value = new Date(pinned).toISOString();
|
|
700
|
+
TIME_NOW_CACHE.set(request, value);
|
|
701
|
+
return value;
|
|
176
702
|
}
|
|
177
703
|
// SHA-256 mirrors experiment/assignment.go: the first eight digest bytes are
|
|
178
704
|
// interpreted big-endian and reduced modulo 10,000 basis points. Keep the
|
|
@@ -264,9 +790,10 @@ function utf8(value) {
|
|
|
264
790
|
// Phase 2 for cheap set-membership lookups per route.
|
|
265
791
|
//
|
|
266
792
|
// Phase 2 (decide): for a given route record, check which variants match
|
|
267
|
-
// the classification (set membership + priority).
|
|
268
|
-
//
|
|
269
|
-
//
|
|
793
|
+
// the classification (set membership + priority). Audience/segment rules
|
|
794
|
+
// were evaluated in Phase 1; an experiment arm variant's own rule is
|
|
795
|
+
// evaluated here at commit time, because a bucket assignment is candidacy,
|
|
796
|
+
// not entitlement (ADR 0040).
|
|
270
797
|
//
|
|
271
798
|
// The per-route record stays small: just variant keys (e.g.
|
|
272
799
|
// "audience=trade") + priorities. Rules live in the classification data,
|
|
@@ -279,14 +806,18 @@ function utf8(value) {
|
|
|
279
806
|
* @param data Release-level classification data (audiences, segments, experiments)
|
|
280
807
|
* @param identity Visitor identity (for experiment bucketing). Pass undefined
|
|
281
808
|
* if no identity or privacy denied.
|
|
809
|
+
* @param claims The verified claims the caller already holds (eligibility
|
|
810
|
+
* gate input, ASE-F4). Undefined/null means "no claims" —
|
|
811
|
+
* gated experiments are skipped.
|
|
282
812
|
* @returns Classification — matched audiences, segments, and experiment arm assignments
|
|
283
813
|
*/
|
|
284
|
-
export function classify(ctx, data, identity) {
|
|
285
|
-
// Evaluate audience rules
|
|
814
|
+
export function classify(ctx, data, identity, claims) {
|
|
815
|
+
// Evaluate audience rules. Claims feed the audience.memberships fact
|
|
816
|
+
// (ADR 0039) exactly as they do in the request-scoped resolver path.
|
|
286
817
|
const matchedAudiences = [];
|
|
287
818
|
for (const key in data.audiences) {
|
|
288
819
|
const def = data.audiences[key];
|
|
289
|
-
if (def.rule && evaluateRule(ctx, def.rule)) {
|
|
820
|
+
if (def.rule && evaluateRule(ctx, def.rule, claims, key)) {
|
|
290
821
|
matchedAudiences.push(key);
|
|
291
822
|
}
|
|
292
823
|
}
|
|
@@ -294,7 +825,7 @@ export function classify(ctx, data, identity) {
|
|
|
294
825
|
const matchedSegments = [];
|
|
295
826
|
for (const key in data.segments) {
|
|
296
827
|
const def = data.segments[key];
|
|
297
|
-
if (def.rule && evaluateRule(ctx, def.rule)) {
|
|
828
|
+
if (def.rule && evaluateRule(ctx, def.rule, claims, key)) {
|
|
298
829
|
matchedSegments.push(key);
|
|
299
830
|
}
|
|
300
831
|
}
|
|
@@ -307,6 +838,8 @@ export function classify(ctx, data, identity) {
|
|
|
307
838
|
continue;
|
|
308
839
|
if (exp.kind !== 'ab' && exp.kind !== 'abn')
|
|
309
840
|
continue;
|
|
841
|
+
if (!experimentEligible(exp, claims))
|
|
842
|
+
continue;
|
|
310
843
|
const assignment = assignExperiment(exp, identity);
|
|
311
844
|
if (assignment) {
|
|
312
845
|
experimentArms[expId] = assignment;
|
|
@@ -319,13 +852,20 @@ export function classify(ctx, data, identity) {
|
|
|
319
852
|
* Phase 2: Decide the winning variant for a specific route.
|
|
320
853
|
*
|
|
321
854
|
* Uses the classification from Phase 1 for cheap set-membership lookups.
|
|
322
|
-
*
|
|
855
|
+
* Audience/segment rules were already evaluated in Phase 1; the one rule
|
|
856
|
+
* evaluated here is an experiment arm variant's own rule, because the arm
|
|
857
|
+
* assignment is candidacy, not entitlement (ADR 0040).
|
|
323
858
|
*
|
|
324
859
|
* @param record The route entry (path, dispatch, variants)
|
|
325
860
|
* @param classification The visitor's classification from Phase 1
|
|
861
|
+
* @param ctx The request context (signal facts + rejection sink)
|
|
862
|
+
* @param claims The verified claims the caller already holds — the
|
|
863
|
+
* same object passed to classify() — so an arm variant's
|
|
864
|
+
* audience.memberships rule resolves identically to the
|
|
865
|
+
* resolver path and fails closed without it (ADR 0039).
|
|
326
866
|
* @returns The decision — winning variant + experiment assignment, or nulls
|
|
327
867
|
*/
|
|
328
|
-
export function decide(record, classification, ctx) {
|
|
868
|
+
export function decide(record, classification, ctx, claims) {
|
|
329
869
|
if (!record.variants)
|
|
330
870
|
return { variant: null, experiment: null };
|
|
331
871
|
let bestVariant = null;
|
|
@@ -348,23 +888,118 @@ export function decide(record, classification, ctx) {
|
|
|
348
888
|
if (!matched && values.segment && classification.matchedSegments.includes(values.segment)) {
|
|
349
889
|
matched = true;
|
|
350
890
|
}
|
|
351
|
-
// Experiment arm assignment
|
|
891
|
+
// Experiment arm assignment. The arm is a candidate only — committing it
|
|
892
|
+
// inside the win block prevents a losing arm variant from leaving a
|
|
893
|
+
// stale experiment decision behind when another variant wins.
|
|
894
|
+
let armMatch = null;
|
|
352
895
|
if (!matched && experimentID) {
|
|
353
896
|
const arm = classification.experimentArms[experimentID];
|
|
354
897
|
if (arm && arm.variantKey === variant.variantKey) {
|
|
355
|
-
|
|
356
|
-
|
|
898
|
+
// Same ADR 0040 posture as the resolver's experiment leg: the arm
|
|
899
|
+
// assignment is candidacy only. A rule rejected at serve time (or
|
|
900
|
+
// unmatched) fires the onRuleRejected sink inside evaluateRule and
|
|
901
|
+
// skips the variant — never served, no stale experiment decision.
|
|
902
|
+
if (!variant.rule || evaluateRule(ctx, variant.rule, claims, variant.variantKey)) {
|
|
903
|
+
matched = true;
|
|
904
|
+
armMatch = arm;
|
|
905
|
+
}
|
|
357
906
|
}
|
|
358
907
|
}
|
|
359
908
|
if (matched) {
|
|
360
909
|
if (!bestVariant || betterVariant(variant.priority || 0, variant.variantKey, bestVariant.priority || 0, bestVariant.variantKey)) {
|
|
361
910
|
bestVariant = variant;
|
|
362
|
-
|
|
363
|
-
if (!experimentID || !classification.experimentArms[experimentID]) {
|
|
364
|
-
bestExperiment = null;
|
|
365
|
-
}
|
|
911
|
+
bestExperiment = armMatch;
|
|
366
912
|
}
|
|
367
913
|
}
|
|
368
914
|
}
|
|
369
915
|
return { variant: bestVariant, experiment: bestExperiment };
|
|
370
916
|
}
|
|
917
|
+
// ── Derived request device class (ADR 0037) ──────────────────────────
|
|
918
|
+
//
|
|
919
|
+
// device.class is the one request fact that is neither transcribed from the
|
|
920
|
+
// request nor injected as a signal: it is classified here from headers the
|
|
921
|
+
// request already carries, so this module, the released SDK copy, and the Go
|
|
922
|
+
// preview evaluator agree without three separate plumbing points.
|
|
923
|
+
//
|
|
924
|
+
// The ORDER below is the contract, not an implementation detail. It is fixed
|
|
925
|
+
// by internal/domain/audience/testdata/device-class.v1.json, which Go and both
|
|
926
|
+
// TypeScript copies execute. Change the order only by changing that fixture.
|
|
927
|
+
//
|
|
928
|
+
// The result is approximate and derived from client-controlled input: a
|
|
929
|
+
// personalization convenience, never an authorization or entitlement input.
|
|
930
|
+
export const DEVICE_CLASSES = ['desktop', 'mobile', 'tablet', 'unknown'];
|
|
931
|
+
// Mirrors MaxDeviceHeaderBytes in internal/domain/audience/device.go.
|
|
932
|
+
const MAX_DEVICE_HEADER_BYTES = 512;
|
|
933
|
+
// Tablet runs first because Android tablet agents contain "android" (mobile)
|
|
934
|
+
// and every Android agent contains "linux" (desktop); iPad agents contain
|
|
935
|
+
// neither. Sec-CH-UA-Mobile is a tiebreaker rather than the primary source
|
|
936
|
+
// because UA-CH cannot express tablet at all.
|
|
937
|
+
const DEVICE_TABLET_SIGNATURES = ['ipad', 'tablet', 'playbook', 'kindle', 'silk'];
|
|
938
|
+
// 'iemobile' is deliberately absent: it contains 'mobi' and could never
|
|
939
|
+
// match. Every signature must be reachable given the ones before it.
|
|
940
|
+
const DEVICE_MOBILE_SIGNATURES = ['iphone', 'ipod', 'mobi', 'windows phone', 'blackberry', 'opera mini'];
|
|
941
|
+
const DEVICE_DESKTOP_SIGNATURES = ['windows nt', 'macintosh', 'mac os x', 'x11', 'cros', 'linux'];
|
|
942
|
+
/**
|
|
943
|
+
* Classifies the request's device class from its own headers. Returns '' when
|
|
944
|
+
* the request carried neither User-Agent nor Sec-CH-UA-Mobile, which the fact
|
|
945
|
+
* resolver reports as ABSENT — the same status the missing header itself
|
|
946
|
+
* resolves to, and deliberately distinct from the 'unknown' class, which means
|
|
947
|
+
* classified and unrecognized.
|
|
948
|
+
*/
|
|
949
|
+
export function deviceClassFromHeaders(headers) {
|
|
950
|
+
const userAgent = deviceHeader(headers, 'user-agent');
|
|
951
|
+
const uaMobile = deviceHeader(headers, 'sec-ch-ua-mobile');
|
|
952
|
+
if (!userAgent && !uaMobile)
|
|
953
|
+
return '';
|
|
954
|
+
const lower = userAgent.toLowerCase();
|
|
955
|
+
if (DEVICE_TABLET_SIGNATURES.some((token) => lower.indexOf(token) >= 0))
|
|
956
|
+
return 'tablet';
|
|
957
|
+
// An Android agent without a "mobi" token is a tablet: Chrome on Android
|
|
958
|
+
// phones appends "Mobile", Android tablets do not. The explicit !"mobi"
|
|
959
|
+
// guard is required, not redundant — the mobile pass runs AFTER this one.
|
|
960
|
+
if (lower.indexOf('android') >= 0 && lower.indexOf('mobi') < 0)
|
|
961
|
+
return 'tablet';
|
|
962
|
+
if (DEVICE_MOBILE_SIGNATURES.some((token) => lower.indexOf(token) >= 0))
|
|
963
|
+
return 'mobile';
|
|
964
|
+
if (uaMobile === '?1')
|
|
965
|
+
return 'mobile';
|
|
966
|
+
if (DEVICE_DESKTOP_SIGNATURES.some((token) => lower.indexOf(token) >= 0))
|
|
967
|
+
return 'desktop';
|
|
968
|
+
if (uaMobile === '?0')
|
|
969
|
+
return 'desktop';
|
|
970
|
+
return 'unknown';
|
|
971
|
+
}
|
|
972
|
+
// deviceHeader reads one header so that Go's audience.ClassifyDeviceClass
|
|
973
|
+
// cannot disagree with this function about the same request.
|
|
974
|
+
//
|
|
975
|
+
// Keys are lowercase here: every context builder flattens through the Fetch
|
|
976
|
+
// API, which lowercases. There is deliberately NO case-insensitive fallback —
|
|
977
|
+
// the header.* branch above has none either, and adding one for this field
|
|
978
|
+
// only would mean a device rule resolves where an equivalent header rule
|
|
979
|
+
// silently does not.
|
|
980
|
+
//
|
|
981
|
+
// Trimming is ASCII-only on purpose. String.prototype.trim strips U+FEFF but
|
|
982
|
+
// not U+0085; Go's strings.TrimSpace does the reverse. Neither is "right";
|
|
983
|
+
// they only have to match, so both sides trim the same ASCII set. The length
|
|
984
|
+
// bound matches MaxDeviceHeaderBytes in device.go — no signature lives past
|
|
985
|
+
// it, and the header is attacker-sized on an unauthenticated path.
|
|
986
|
+
function deviceHeader(headers, key) {
|
|
987
|
+
const raw = (headers && headers[key]) || '';
|
|
988
|
+
if (typeof raw !== 'string')
|
|
989
|
+
return '';
|
|
990
|
+
return raw.slice(0, MAX_DEVICE_HEADER_BYTES).replace(/^[ \t\r\n\f\v]+|[ \t\r\n\f\v]+$/g, '');
|
|
991
|
+
}
|
|
992
|
+
// The class describes the REQUEST, so it is identical for every leaf. An
|
|
993
|
+
// envelope may carry up to 64 conditions and a route many candidate variants,
|
|
994
|
+
// each building its own resolver — without this, one page view could lowercase
|
|
995
|
+
// and scan the user agent hundreds of times. Keyed on the context object, so
|
|
996
|
+
// classify()/decide() reuse it too.
|
|
997
|
+
const DEVICE_CLASS_CACHE = new WeakMap();
|
|
998
|
+
function cachedDeviceClass(request) {
|
|
999
|
+
const hit = DEVICE_CLASS_CACHE.get(request);
|
|
1000
|
+
if (hit !== undefined)
|
|
1001
|
+
return hit;
|
|
1002
|
+
const deviceClass = deviceClassFromHeaders(request.headers);
|
|
1003
|
+
DEVICE_CLASS_CACHE.set(request, deviceClass);
|
|
1004
|
+
return deviceClass;
|
|
1005
|
+
}
|