@gradial/aci 0.1.52-rc.16 → 0.1.52-rc.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/dist/astro/preview.js +2 -17
- package/dist/audience-data-models.d.ts +193 -16
- package/dist/audience-data-models.js +47 -2
- package/dist/collect/batcher.d.ts +6 -5
- package/dist/collect/batcher.js +6 -5
- package/dist/edge/expression-capabilities.v1.d.ts +106 -0
- package/dist/edge/expression-capabilities.v1.js +165 -0
- package/dist/internal/domain/edge/identity.js +1 -1
- package/dist/internal/domain/edge/mimic_token.js +1 -2
- package/dist/internal/domain/edge/personalization-core.d.ts +14 -4
- package/dist/internal/domain/edge/personalization-core.js +472 -54
- package/dist/internal/domain/edge/record/codec.d.ts +5 -2
- package/dist/internal/domain/edge/record/codec.js +83 -10
- package/dist/internal/domain/edge/resolve.js +13 -2
- package/dist/internal/domain/edge/testdata/flat_only_validator_v1.d.ts +10 -0
- package/dist/internal/domain/edge/testdata/flat_only_validator_v1.js +253 -0
- package/dist/internal/domain/edge/types.d.ts +36 -9
- package/dist/internal/domain/edge/types.js +3 -3
- package/dist/mimic/core.d.ts +0 -32
- package/dist/mimic/core.js +12 -108
- package/dist/next/vercel-edge-projection-v1.d.ts +0 -9
- package/dist/next/vercel-edge-projection-v1.js +9 -545
- package/dist/preview/core.d.ts +1 -0
- package/dist/preview/core.js +27 -0
- package/dist/react/VideoPlayer.d.ts +1 -1
- package/dist/react/index.d.ts +1 -1
- package/package.json +16 -7
- package/templates/common/.agents/skills/aci/SKILL.md +6 -0
- package/templates/common/.agents/skills/dam-delivery/SKILL.md +228 -0
- package/templates/common/.agents/skills/dam-delivery/references/parameter-contract.md +107 -0
- package/templates/common/.agents/skills/dam-delivery/references/permalink-grammar.md +131 -0
- package/dist/edge/personalization-core.d.ts +0 -35
- package/dist/edge/personalization-core.js +0 -483
- package/dist/next/collect-route.d.ts +0 -25
- package/dist/next/collect-route.js +0 -479
- package/dist/next/envelope-digest.d.ts +0 -21
- package/dist/next/envelope-digest.js +0 -58
- package/dist/next/personalization-host.d.ts +0 -138
- package/dist/next/personalization-host.js +0 -280
- package/dist/next/root-layout.d.ts +0 -4
- package/dist/next/root-layout.js +0 -5
- package/dist/next/vercel-collect-sdk-route.d.ts +0 -27
- package/dist/next/vercel-collect-sdk-route.js +0 -181
- package/dist/next/vercel-personalization.d.ts +0 -4
- package/dist/next/vercel-personalization.js +0 -4
- package/dist/next/vercel-s3-personalization.d.ts +0 -80
- package/dist/next/vercel-s3-personalization.js +0 -571
|
@@ -10,14 +10,17 @@ interface CompactDispatch {
|
|
|
10
10
|
ap: string;
|
|
11
11
|
}
|
|
12
12
|
interface CompactCondition {
|
|
13
|
-
f
|
|
14
|
-
o
|
|
13
|
+
f?: string;
|
|
14
|
+
o?: string;
|
|
15
15
|
w?: unknown;
|
|
16
|
+
a?: CompactCondition[];
|
|
17
|
+
n?: CompactCondition[];
|
|
16
18
|
}
|
|
17
19
|
interface CompactRule {
|
|
18
20
|
a?: CompactCondition[];
|
|
19
21
|
n?: CompactCondition[];
|
|
20
22
|
}
|
|
23
|
+
export declare const COMPACT_MAX_DEPTH = 32;
|
|
21
24
|
interface CompactVariant {
|
|
22
25
|
pr?: number;
|
|
23
26
|
r?: CompactRule;
|
|
@@ -41,9 +41,13 @@ const DISPATCH_KIND_FROM_COMPACT = {
|
|
|
41
41
|
};
|
|
42
42
|
const OPERATOR_TO_COMPACT = {
|
|
43
43
|
equals: 'eq', contains: 'ct', startsWith: 'sw', exists: 'ex',
|
|
44
|
+
greaterThan: 'gt', greaterThanOrEqual: 'ge', lessThan: 'lt', lessThanOrEqual: 'le',
|
|
45
|
+
before: 'bf', after: 'af', between: 'bt', in: 'in',
|
|
44
46
|
};
|
|
45
47
|
const OPERATOR_FROM_COMPACT = {
|
|
46
48
|
eq: 'equals', ct: 'contains', sw: 'startsWith', ex: 'exists',
|
|
49
|
+
gt: 'greaterThan', ge: 'greaterThanOrEqual', lt: 'lessThan', le: 'lessThanOrEqual',
|
|
50
|
+
bf: 'before', af: 'after', bt: 'between', in: 'in',
|
|
47
51
|
};
|
|
48
52
|
const EXPERIMENT_STATUS_TO_COMPACT = {
|
|
49
53
|
running: 'r', paused: 'p', completed: 'd',
|
|
@@ -78,20 +82,89 @@ function expandDispatch(raw) {
|
|
|
78
82
|
return INVALID;
|
|
79
83
|
return { kind, artifactPath };
|
|
80
84
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
85
|
+
// Structural recursion guard, deliberately looser than the authored MaxDepth
|
|
86
|
+
// (5) the validators enforce. The codec's job is to bound adversarial
|
|
87
|
+
// recursion in a byte-constrained runtime; the authored contract is
|
|
88
|
+
// validation's. Keeping it looser is what lets an over-depth expression
|
|
89
|
+
// round-trip and be rejected by validation identically in every runtime,
|
|
90
|
+
// rather than being erased by the codec first (ADR 0040).
|
|
91
|
+
export const COMPACT_MAX_DEPTH = 32;
|
|
92
|
+
// A member carrying all/any is an envelope, not a condition — the same
|
|
93
|
+
// presence rule the evaluators apply, so the codec cannot disagree with them
|
|
94
|
+
// about what a member is.
|
|
95
|
+
function isGroupMember(cond) {
|
|
96
|
+
return !!cond && (cond.all !== undefined || cond.any !== undefined);
|
|
97
|
+
}
|
|
98
|
+
function projectCondition(cond, depth) {
|
|
99
|
+
// Projection runs at publish time in a trusted context behind write-time
|
|
100
|
+
// validation: a malformed condition or an operator with no compact token is
|
|
101
|
+
// vocabulary/codec drift, and silently dropping the condition would ship a
|
|
102
|
+
// rule missing a leaf — MORE permissive than authored (ADR 0038). The same
|
|
103
|
+
// reasoning covers a group node: dropping one publishes a rule the author
|
|
104
|
+
// did not write. Expansion stays fail-closed for already-deployed bundles.
|
|
105
|
+
if (!cond) {
|
|
106
|
+
throw new Error('edge rule condition must name a field and operator');
|
|
107
|
+
}
|
|
108
|
+
if (isGroupMember(cond)) {
|
|
109
|
+
if (depth > COMPACT_MAX_DEPTH) {
|
|
110
|
+
throw new Error('edge rule nests deeper than the codec can project');
|
|
111
|
+
}
|
|
112
|
+
if (cond.field !== undefined || cond.operator !== undefined || cond.value !== undefined) {
|
|
113
|
+
throw new Error('edge rule member mixes group and leaf keys');
|
|
114
|
+
}
|
|
115
|
+
const group = {};
|
|
116
|
+
if (cond.all && cond.all.length > 0)
|
|
117
|
+
group.a = cond.all.map((member) => projectCondition(member, depth + 1));
|
|
118
|
+
if (cond.any && cond.any.length > 0)
|
|
119
|
+
group.n = cond.any.map((member) => projectCondition(member, depth + 1));
|
|
120
|
+
if (!group.a && !group.n) {
|
|
121
|
+
throw new Error('edge rule group must include at least one member');
|
|
122
|
+
}
|
|
123
|
+
return group;
|
|
124
|
+
}
|
|
125
|
+
if (!cond.field || !cond.operator) {
|
|
126
|
+
throw new Error('edge rule condition must name a field and operator');
|
|
127
|
+
}
|
|
84
128
|
const compactOp = OPERATOR_TO_COMPACT[cond.operator];
|
|
85
|
-
if (!compactOp)
|
|
86
|
-
|
|
129
|
+
if (!compactOp) {
|
|
130
|
+
throw new Error(`edge rule operator has no compact token: ${cond.operator}`);
|
|
131
|
+
}
|
|
87
132
|
const result = { f: cond.field, o: compactOp };
|
|
88
133
|
if (cond.value !== undefined)
|
|
89
134
|
result.w = cond.value;
|
|
90
135
|
return result;
|
|
91
136
|
}
|
|
92
|
-
function expandCondition(raw) {
|
|
137
|
+
function expandCondition(raw, depth) {
|
|
93
138
|
if (!isObj(raw))
|
|
94
139
|
return INVALID;
|
|
140
|
+
if (raw.a !== undefined || raw.n !== undefined) {
|
|
141
|
+
if (depth > COMPACT_MAX_DEPTH)
|
|
142
|
+
return INVALID;
|
|
143
|
+
// A member cannot be both: expanding a mixed record would hand the
|
|
144
|
+
// evaluator something neither a group nor a condition.
|
|
145
|
+
if (raw.f !== undefined || raw.o !== undefined || raw.w !== undefined)
|
|
146
|
+
return INVALID;
|
|
147
|
+
const group = {};
|
|
148
|
+
for (const [compact, key] of [['a', 'all'], ['n', 'any']]) {
|
|
149
|
+
if (raw[compact] === undefined)
|
|
150
|
+
continue;
|
|
151
|
+
if (!Array.isArray(raw[compact]))
|
|
152
|
+
return INVALID;
|
|
153
|
+
const members = [];
|
|
154
|
+
for (const member of raw[compact]) {
|
|
155
|
+
const expanded = expandCondition(member, depth + 1);
|
|
156
|
+
if (expanded === INVALID)
|
|
157
|
+
return INVALID;
|
|
158
|
+
members.push(expanded);
|
|
159
|
+
}
|
|
160
|
+
group[key] = members;
|
|
161
|
+
}
|
|
162
|
+
// Length, not truthiness: [] is truthy, so a group whose only list is
|
|
163
|
+
// empty would otherwise expand into a member with no predicate at all.
|
|
164
|
+
if ((group.all?.length || 0) + (group.any?.length || 0) === 0)
|
|
165
|
+
return INVALID;
|
|
166
|
+
return group;
|
|
167
|
+
}
|
|
95
168
|
const f = raw.f;
|
|
96
169
|
const co = raw.o;
|
|
97
170
|
if (typeof f !== 'string')
|
|
@@ -109,9 +182,9 @@ function projectRule(rule) {
|
|
|
109
182
|
return undefined;
|
|
110
183
|
const result = {};
|
|
111
184
|
if (rule.all && rule.all.length > 0)
|
|
112
|
-
result.a = rule.all.map(
|
|
185
|
+
result.a = rule.all.map((cond) => projectCondition(cond, 1));
|
|
113
186
|
if (rule.any && rule.any.length > 0)
|
|
114
|
-
result.n = rule.any.map(
|
|
187
|
+
result.n = rule.any.map((cond) => projectCondition(cond, 1));
|
|
115
188
|
if (!result.a && !result.n)
|
|
116
189
|
return undefined;
|
|
117
190
|
return result;
|
|
@@ -124,7 +197,7 @@ function expandRule(raw) {
|
|
|
124
197
|
const rule = {};
|
|
125
198
|
if (Array.isArray(raw.a))
|
|
126
199
|
for (const c of raw.a) {
|
|
127
|
-
const r = expandCondition(c);
|
|
200
|
+
const r = expandCondition(c, 1);
|
|
128
201
|
if (r === INVALID)
|
|
129
202
|
return INVALID;
|
|
130
203
|
if (r)
|
|
@@ -132,7 +205,7 @@ function expandRule(raw) {
|
|
|
132
205
|
}
|
|
133
206
|
if (Array.isArray(raw.n))
|
|
134
207
|
for (const c of raw.n) {
|
|
135
|
-
const r = expandCondition(c);
|
|
208
|
+
const r = expandCondition(c, 1);
|
|
136
209
|
if (r === INVALID)
|
|
137
210
|
return INVALID;
|
|
138
211
|
if (r)
|
|
@@ -49,7 +49,7 @@ export async function resolvePersonalizationRequest(request, config) {
|
|
|
49
49
|
: consent.allowsIdentityTier
|
|
50
50
|
? await verifyClaims(signedClaims, config.claimsKeys || {}, now)
|
|
51
51
|
: null;
|
|
52
|
-
const decision = resolvePersonalizationDecision(toPersonalizationRequestContext(request), matchedRoute, claims, config.experiments, identityHash
|
|
52
|
+
const decision = resolvePersonalizationDecision(toPersonalizationRequestContext(request, now, config.onRuleRejected), matchedRoute, claims, config.experiments, identityHash
|
|
53
53
|
? { usable: true, siteId: config.siteId, namespace: 'aci_id', identityHash }
|
|
54
54
|
: undefined);
|
|
55
55
|
if (decision.variant) {
|
|
@@ -62,7 +62,13 @@ export async function resolvePersonalizationRequest(request, config) {
|
|
|
62
62
|
}
|
|
63
63
|
return { variantKey };
|
|
64
64
|
}
|
|
65
|
-
|
|
65
|
+
// The return type is annotated rather than inferred so the contract is stated
|
|
66
|
+
// at the construction site: a misspelled property would then silently never
|
|
67
|
+
// fire at the sink, which is the exact failure mode this context exists to
|
|
68
|
+
// make visible. No tsconfig covers internal/domain/** today (esbuild/tsx strip
|
|
69
|
+
// types without checking), so the annotation is documentation now and becomes
|
|
70
|
+
// real protection under any future typecheck of these trees.
|
|
71
|
+
function toPersonalizationRequestContext(request, now, onRuleRejected) {
|
|
66
72
|
const url = new URL(request.url);
|
|
67
73
|
const cookies = parseCookieHeader(request.headers.get('cookie'));
|
|
68
74
|
const query = {};
|
|
@@ -80,6 +86,11 @@ function toPersonalizationRequestContext(request) {
|
|
|
80
86
|
query,
|
|
81
87
|
headers,
|
|
82
88
|
cookies: Object.fromEntries(cookies.entries()),
|
|
89
|
+
// The host's pinned per-request clock resolves the time.now fact
|
|
90
|
+
// (ADR 0038), so claims verification and window evaluation read one
|
|
91
|
+
// instant — and config.now-injected tests are deterministic.
|
|
92
|
+
now: now.getTime(),
|
|
83
93
|
...(Object.keys(geo).length > 0 ? { signals: { geo } } : {}),
|
|
94
|
+
...(onRuleRejected ? { onRuleRejected } : {}),
|
|
84
95
|
};
|
|
85
96
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface FrozenExpressionDiagnostic {
|
|
2
|
+
code: string;
|
|
3
|
+
path: string;
|
|
4
|
+
scope: 'request';
|
|
5
|
+
message: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function frozenValidateRequestExpression(rule: {
|
|
8
|
+
all?: unknown[];
|
|
9
|
+
any?: unknown[];
|
|
10
|
+
}): FrozenExpressionDiagnostic[];
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// FROZEN FIXTURE — the request-expression validation path as it stood before
|
|
2
|
+
// ADR 0040 added nested boolean expression trees, vendored from
|
|
3
|
+
// `internal/domain/edge/personalization-core.ts` at commit 1b785e4ac^ (the
|
|
4
|
+
// parent of the ADR 0040 landing commit). Do NOT update this file to track the
|
|
5
|
+
// live validator: its whole value is that it is stale.
|
|
6
|
+
//
|
|
7
|
+
// It exists so one test can reproduce the incident ADR 0040 predicts and says
|
|
8
|
+
// it could not previously debug — a lane running a flat-only evaluator meeting
|
|
9
|
+
// a record carrying a valid nested rule. That evaluator has no concept of a
|
|
10
|
+
// group node, so it classifies `{all:[...]}` as a leaf condition and reports
|
|
11
|
+
// the leaf vocabulary as unsupported. The resulting diagnostics are what the
|
|
12
|
+
// host recorder actually writes during real version skew, and they are NOT the
|
|
13
|
+
// same as those produced by a current, nesting-aware validator refusing a
|
|
14
|
+
// malformed rule.
|
|
15
|
+
//
|
|
16
|
+
// What is vendored: the COMPLETE validation path — `validateRequestExpression`
|
|
17
|
+
// and `validateRequestCondition` with every branch (per-field operator
|
|
18
|
+
// restrictions for device.class / time.now / audience.memberships, and every
|
|
19
|
+
// value check), plus each helper those functions reach
|
|
20
|
+
// (`expressionNumberInDomain`, `parseExpressionTimestamp`,
|
|
21
|
+
// `expressionDaysInMonth`, `canonicalScalar`, `validExpressionRange`,
|
|
22
|
+
// `homogeneousExpressionMembers`, and the DEVICE_CLASSES / MAX_IN_VALUES /
|
|
23
|
+
// grammar constants). Evaluation, matching, and the fact vocabulary are
|
|
24
|
+
// irrelevant to the record's shape and are deliberately absent.
|
|
25
|
+
//
|
|
26
|
+
// Adaptations from the source commit (everything not listed here is verbatim):
|
|
27
|
+
// 1. Entry-point signature. The original `validateRequestExpression` was
|
|
28
|
+
// 3-argument — its callers pre-extracted `all`/`any` with `rule.all || []`.
|
|
29
|
+
// The frozen export is 1-argument and derives them itself with
|
|
30
|
+
// `Array.isArray` guards (strictly stronger than the original `|| []`) so
|
|
31
|
+
// the fixture is safe to call directly on untrusted record data.
|
|
32
|
+
// 2. The diagnostic type is renamed `FrozenExpressionDiagnostic` (from
|
|
33
|
+
// `ExpressionDiagnostic`) so a frozen diagnostic can never be confused
|
|
34
|
+
// with a live one at a call site.
|
|
35
|
+
// 3. Export keywords were removed from the vendored internals; only the
|
|
36
|
+
// entry point and its diagnostic type are exported.
|
|
37
|
+
const MAX_EXPRESSION_CONDITIONS = 64;
|
|
38
|
+
const EXPRESSION_OPERATORS = [
|
|
39
|
+
'equals', 'contains', 'startsWith', 'exists',
|
|
40
|
+
'greaterThan', 'greaterThanOrEqual', 'lessThan', 'lessThanOrEqual',
|
|
41
|
+
'before', 'after', 'between', 'in',
|
|
42
|
+
];
|
|
43
|
+
const MAX_IN_VALUES = 32;
|
|
44
|
+
// Observed values are gated on the published grammars before any host parser
|
|
45
|
+
// runs (ADR 0038): Number() and Date.parse accept supersets (hex, empty
|
|
46
|
+
// string, vendor date formats) that would let the same fact match here and
|
|
47
|
+
// not in Go. Keep these literals identical to the capability artifact — the
|
|
48
|
+
// drift test compares them byte-for-byte.
|
|
49
|
+
const EXPRESSION_NUMBER_GRAMMAR = /^-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?$/;
|
|
50
|
+
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})$/;
|
|
51
|
+
const DEVICE_CLASSES = ['desktop', 'mobile', 'tablet', 'unknown'];
|
|
52
|
+
// Adaptation 1 (see header): the original was 3-argument with caller-extracted
|
|
53
|
+
// `all`/`any`; this 1-argument form derives them with Array.isArray guards.
|
|
54
|
+
// The body below this line is verbatim from the source commit.
|
|
55
|
+
export function frozenValidateRequestExpression(rule) {
|
|
56
|
+
const all = (Array.isArray(rule.all) ? rule.all : []);
|
|
57
|
+
const any = (Array.isArray(rule.any) ? rule.any : []);
|
|
58
|
+
const diagnostics = [];
|
|
59
|
+
if (all.length + any.length === 0)
|
|
60
|
+
diagnostics.push({ code: 'expression.empty', path: 'expression', scope: 'request', message: 'expression must include at least one all or any condition' });
|
|
61
|
+
if (all.length + any.length > MAX_EXPRESSION_CONDITIONS)
|
|
62
|
+
diagnostics.push({ code: 'expression.conditions.too_many', path: 'expression', scope: 'request', message: `expression must contain at most ${MAX_EXPRESSION_CONDITIONS} conditions` });
|
|
63
|
+
Object.keys(rule).filter((group) => group !== 'all' && group !== 'any').sort().forEach((group) => {
|
|
64
|
+
diagnostics.push({ code: 'expression.group.unsupported', path: `expression.${group}`, scope: 'request', message: `expression.${group} is not a supported expression group` });
|
|
65
|
+
});
|
|
66
|
+
for (const [group, conditions] of [['all', all], ['any', any]]) {
|
|
67
|
+
conditions.forEach((condition, index) => frozenValidateRequestCondition(condition, `expression.${group}[${index}]`, diagnostics));
|
|
68
|
+
}
|
|
69
|
+
return diagnostics;
|
|
70
|
+
}
|
|
71
|
+
// Verbatim from the source commit: the full flat-era condition validator,
|
|
72
|
+
// including the per-field operator restrictions and every value check.
|
|
73
|
+
function frozenValidateRequestCondition(condition, path, diagnostics) {
|
|
74
|
+
Object.keys(condition).filter((property) => !['field', 'operator', 'value'].includes(property)).sort().forEach((property) => {
|
|
75
|
+
diagnostics.push({ code: 'expression.property.unsupported', path: `${path}.${property}`, scope: 'request', message: `${path}.${property} is not a supported condition property` });
|
|
76
|
+
});
|
|
77
|
+
const field = String(condition.field || '');
|
|
78
|
+
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)) {
|
|
79
|
+
diagnostics.push({ code: 'expression.field.unsupported', path: `${path}.field`, scope: 'request', message: `${path}.field ${JSON.stringify(field)} is not supported` });
|
|
80
|
+
}
|
|
81
|
+
const operator = String(condition.operator || '');
|
|
82
|
+
if (!EXPRESSION_OPERATORS.includes(operator)) {
|
|
83
|
+
diagnostics.push({ code: 'expression.operator.unsupported', path: `${path}.operator`, scope: 'request', message: `${path}.operator ${JSON.stringify(operator)} is not supported` });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
// device.class compares against a closed four-value set, so substring
|
|
87
|
+
// operators express nothing the set cannot — and admitting them would
|
|
88
|
+
// re-create the user-agent-substring habit inside the safe field (ADR 0037).
|
|
89
|
+
if (field === 'device.class' && !['equals', 'in', 'exists'].includes(operator)) {
|
|
90
|
+
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` });
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
// time.now is a sub-second-churning clock: string operators are silently
|
|
94
|
+
// inert on it and the numeric family can never match an RFC 3339 string
|
|
95
|
+
// (ADR 0038).
|
|
96
|
+
if (field === 'time.now' && !['before', 'after', 'between', 'exists'].includes(operator)) {
|
|
97
|
+
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` });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
// audience.memberships is an opaque-ID list: equals is already
|
|
101
|
+
// any-member-equals under the multi-valued match loop, so the substring
|
|
102
|
+
// family would only add silent overmatching of prefixed IDs, and the
|
|
103
|
+
// numeric/instant families can never match an ID (ADR 0039).
|
|
104
|
+
if (field === 'audience.memberships' && !['equals', 'in', 'exists'].includes(operator)) {
|
|
105
|
+
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` });
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const value = condition.value;
|
|
109
|
+
if (operator === 'exists') {
|
|
110
|
+
if (value !== undefined && value !== null && typeof value !== 'boolean') {
|
|
111
|
+
diagnostics.push({ code: 'expression.value.invalid', path: `${path}.value`, scope: 'request', message: `${path}.value must be a boolean for operator exists` });
|
|
112
|
+
}
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (value === undefined || value === null) {
|
|
116
|
+
diagnostics.push({ code: 'expression.value.required', path: `${path}.value`, scope: 'request', message: `${path}.value is required for operator ${operator}` });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (['equals', 'contains', 'startsWith'].includes(operator)) {
|
|
120
|
+
if (!['string', 'number', 'boolean'].includes(typeof value)
|
|
121
|
+
|| (typeof value === 'number' && !expressionNumberInDomain(value))) {
|
|
122
|
+
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` });
|
|
123
|
+
}
|
|
124
|
+
else if (field === 'device.class' && DEVICE_CLASSES.indexOf(canonicalScalar(value)) < 0) {
|
|
125
|
+
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)}` });
|
|
126
|
+
}
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (['greaterThan', 'greaterThanOrEqual', 'lessThan', 'lessThanOrEqual'].includes(operator)) {
|
|
130
|
+
if (typeof value !== 'number' || !expressionNumberInDomain(value)) {
|
|
131
|
+
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}` });
|
|
132
|
+
}
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (operator === 'before' || operator === 'after') {
|
|
136
|
+
if (typeof value !== 'string' || parseExpressionTimestamp(value) === null) {
|
|
137
|
+
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}` });
|
|
138
|
+
}
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (operator === 'between') {
|
|
142
|
+
if (!validExpressionRange(value)) {
|
|
143
|
+
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` });
|
|
144
|
+
}
|
|
145
|
+
else if (field === 'time.now' && typeof value.min === 'number') {
|
|
146
|
+
// An instant field's observed values are instants: a numeric range on
|
|
147
|
+
// it would validate and then sit silently inert at execution.
|
|
148
|
+
diagnostics.push({ code: 'expression.value.invalid', path: `${path}.value`, scope: 'request', message: `${path}.value bounds must be timestamps for field ${JSON.stringify(field)}` });
|
|
149
|
+
}
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
// in: bounded, non-empty, every member one canonicalizable scalar, all
|
|
153
|
+
// members one JSON type — a mixed list is almost always an authoring bug.
|
|
154
|
+
const members = homogeneousExpressionMembers(value);
|
|
155
|
+
if (members === null) {
|
|
156
|
+
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` });
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (field === 'device.class') {
|
|
160
|
+
for (const member of members) {
|
|
161
|
+
if (DEVICE_CLASSES.indexOf(member) < 0) {
|
|
162
|
+
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)}` });
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// ── Validation helpers, verbatim from the source commit ──────────────────
|
|
169
|
+
function expressionNumberInDomain(value) {
|
|
170
|
+
if (!Number.isFinite(value))
|
|
171
|
+
return false;
|
|
172
|
+
if (Number.isInteger(value) && !Number.isSafeInteger(value))
|
|
173
|
+
return false;
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
// Explicit calendar validation runs before Date arithmetic because Date.UTC
|
|
177
|
+
// (like Go's time.Date) silently normalizes impossible dates. Instants
|
|
178
|
+
// compare as epoch milliseconds; fractional seconds truncate to milliseconds.
|
|
179
|
+
function parseExpressionTimestamp(value) {
|
|
180
|
+
const match = EXPRESSION_TIMESTAMP_GRAMMAR.exec(value);
|
|
181
|
+
if (!match)
|
|
182
|
+
return null;
|
|
183
|
+
const year = Number(match[1]), month = Number(match[2]), day = Number(match[3]);
|
|
184
|
+
const hour = Number(match[4]), minute = Number(match[5]), second = Number(match[6]);
|
|
185
|
+
if (month < 1 || month > 12 || day < 1 || day > expressionDaysInMonth(year, month))
|
|
186
|
+
return null;
|
|
187
|
+
if (hour > 23 || minute > 59 || second > 59)
|
|
188
|
+
return null;
|
|
189
|
+
let offsetMinutes = 0;
|
|
190
|
+
if (match[8] !== 'Z') {
|
|
191
|
+
const offsetHour = Number(match[8].slice(1, 3)), offsetMinute = Number(match[8].slice(4, 6));
|
|
192
|
+
if (offsetHour > 23 || offsetMinute > 59)
|
|
193
|
+
return null;
|
|
194
|
+
offsetMinutes = offsetHour * 60 + offsetMinute;
|
|
195
|
+
if (match[8][0] === '-')
|
|
196
|
+
offsetMinutes = -offsetMinutes;
|
|
197
|
+
}
|
|
198
|
+
const fractionMillis = match[7] ? Number((match[7].slice(1) + '00').slice(0, 3)) : 0;
|
|
199
|
+
// setUTCFullYear, not Date.UTC: Date.UTC maps two-digit years to 1900+.
|
|
200
|
+
const date = new Date(0);
|
|
201
|
+
date.setUTCFullYear(year, month - 1, day);
|
|
202
|
+
date.setUTCHours(hour, minute, second, 0);
|
|
203
|
+
return date.getTime() - offsetMinutes * 60000 + fractionMillis;
|
|
204
|
+
}
|
|
205
|
+
function expressionDaysInMonth(year, month) {
|
|
206
|
+
if (month === 4 || month === 6 || month === 9 || month === 11)
|
|
207
|
+
return 30;
|
|
208
|
+
if (month === 2)
|
|
209
|
+
return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28;
|
|
210
|
+
return 31;
|
|
211
|
+
}
|
|
212
|
+
function canonicalScalar(value) {
|
|
213
|
+
if (typeof value === 'number')
|
|
214
|
+
return Object.is(value, -0) ? '0' : JSON.stringify(value);
|
|
215
|
+
return String(value);
|
|
216
|
+
}
|
|
217
|
+
function validExpressionRange(value) {
|
|
218
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
219
|
+
return false;
|
|
220
|
+
const record = value;
|
|
221
|
+
if (Object.keys(record).some((key) => key !== 'min' && key !== 'max'))
|
|
222
|
+
return false;
|
|
223
|
+
const min = record.min, max = record.max;
|
|
224
|
+
if (min === undefined || min === null || max === undefined || max === null)
|
|
225
|
+
return false;
|
|
226
|
+
if (typeof min === 'number' && expressionNumberInDomain(min)) {
|
|
227
|
+
return typeof max === 'number' && expressionNumberInDomain(max) && min <= max;
|
|
228
|
+
}
|
|
229
|
+
const minMillis = typeof min === 'string' ? parseExpressionTimestamp(min) : null;
|
|
230
|
+
if (minMillis === null)
|
|
231
|
+
return false;
|
|
232
|
+
const maxMillis = typeof max === 'string' ? parseExpressionTimestamp(max) : null;
|
|
233
|
+
return maxMillis !== null && minMillis <= maxMillis;
|
|
234
|
+
}
|
|
235
|
+
function homogeneousExpressionMembers(value) {
|
|
236
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > MAX_IN_VALUES)
|
|
237
|
+
return null;
|
|
238
|
+
let memberKind = '';
|
|
239
|
+
const members = [];
|
|
240
|
+
for (const member of value) {
|
|
241
|
+
const kind = typeof member;
|
|
242
|
+
if (kind !== 'string' && kind !== 'number' && kind !== 'boolean')
|
|
243
|
+
return null;
|
|
244
|
+
if (kind === 'number' && !expressionNumberInDomain(member))
|
|
245
|
+
return null;
|
|
246
|
+
if (!memberKind)
|
|
247
|
+
memberKind = kind;
|
|
248
|
+
else if (memberKind !== kind)
|
|
249
|
+
return null;
|
|
250
|
+
members.push(canonicalScalar(member));
|
|
251
|
+
}
|
|
252
|
+
return members;
|
|
253
|
+
}
|
|
@@ -4,10 +4,17 @@ export interface EdgeDispatch {
|
|
|
4
4
|
/** Release-relative immutable HTML artifact selected for this route. */
|
|
5
5
|
artifactPath: string;
|
|
6
6
|
}
|
|
7
|
+
/**
|
|
8
|
+
* One member of an expression group: a leaf condition, or a nested envelope
|
|
9
|
+
* when it carries `all`/`any` (ADR 0040). Presence of a group key decides
|
|
10
|
+
* which it is, so flat records stay a byte-identical subset.
|
|
11
|
+
*/
|
|
7
12
|
export interface EdgeRuleCondition {
|
|
8
|
-
field
|
|
9
|
-
operator
|
|
13
|
+
field?: string;
|
|
14
|
+
operator?: 'exists' | 'equals' | 'contains' | 'startsWith' | 'greaterThan' | 'greaterThanOrEqual' | 'lessThan' | 'lessThanOrEqual' | 'before' | 'after' | 'between' | 'in' | string;
|
|
10
15
|
value?: unknown;
|
|
16
|
+
all?: EdgeRuleCondition[];
|
|
17
|
+
any?: EdgeRuleCondition[];
|
|
11
18
|
}
|
|
12
19
|
export interface EdgeRuleExpression {
|
|
13
20
|
all?: EdgeRuleCondition[];
|
|
@@ -48,9 +55,28 @@ export interface PersonalizationRequestContext {
|
|
|
48
55
|
signals?: {
|
|
49
56
|
geo?: Record<string, string>;
|
|
50
57
|
};
|
|
58
|
+
/**
|
|
59
|
+
* Epoch milliseconds resolving the time.now clock fact (ADR 0038).
|
|
60
|
+
* Defaults to Date.now(); hosts may pin it once per request so every
|
|
61
|
+
* expression in one evaluation reads the same instant.
|
|
62
|
+
*/
|
|
63
|
+
now?: number;
|
|
51
64
|
identityHash?: string;
|
|
52
65
|
identityNamespace?: string;
|
|
53
66
|
siteId?: string;
|
|
67
|
+
/**
|
|
68
|
+
* Called when a variant's rule fails validation during serving (ADR 0040).
|
|
69
|
+
* Records pass the write-time gate before shipping, so this only fires on
|
|
70
|
+
* version skew or a projection bug — a precise signal that must not vanish.
|
|
71
|
+
* Receives routing facts and diagnostic codes only, never request data.
|
|
72
|
+
*/
|
|
73
|
+
onRuleRejected?: (event: RuleRejectionEvent) => void;
|
|
74
|
+
}
|
|
75
|
+
export interface RuleRejectionEvent {
|
|
76
|
+
variantKey: string;
|
|
77
|
+
diagnosticCode: string;
|
|
78
|
+
diagnosticPath: string;
|
|
79
|
+
diagnosticCount: number;
|
|
54
80
|
}
|
|
55
81
|
export interface PersonalizationClaims {
|
|
56
82
|
aud?: string[];
|
|
@@ -58,13 +84,7 @@ export interface PersonalizationClaims {
|
|
|
58
84
|
iat: number;
|
|
59
85
|
exp: number;
|
|
60
86
|
}
|
|
61
|
-
export
|
|
62
|
-
variantKey: string;
|
|
63
|
-
variantValues?: Record<string, string>;
|
|
64
|
-
priority?: number;
|
|
65
|
-
rule?: EdgeRuleExpression;
|
|
66
|
-
dispatch: EdgeDispatch;
|
|
67
|
-
}
|
|
87
|
+
export type PersonalizationVariant = EdgeRouteVariant;
|
|
68
88
|
export interface PersonalizationRoute {
|
|
69
89
|
variants?: Record<string, PersonalizationVariant>;
|
|
70
90
|
}
|
|
@@ -174,6 +194,13 @@ export interface PersonalizationHostConfig {
|
|
|
174
194
|
aud?: string[];
|
|
175
195
|
seg?: string[];
|
|
176
196
|
};
|
|
197
|
+
/**
|
|
198
|
+
* Passed straight through to the request context's rejection sink so a host
|
|
199
|
+
* can record a variant skipped for a rule it could not validate (ADR 0040).
|
|
200
|
+
* Unset means the skip stays silent, which is what made the skew incident
|
|
201
|
+
* the ADR predicts undebuggable.
|
|
202
|
+
*/
|
|
203
|
+
onRuleRejected?: (event: RuleRejectionEvent) => void;
|
|
177
204
|
}
|
|
178
205
|
export interface PersonalizationResolution {
|
|
179
206
|
variantKey: string;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Shared edge-decisioning types.
|
|
2
2
|
//
|
|
3
3
|
// These types are the canonical shape for the edge decisioning algorithm.
|
|
4
|
-
// All host adapters (AWS, Cloudflare, Vercel, emulator) import from here
|
|
5
|
-
//
|
|
6
|
-
//
|
|
4
|
+
// All host adapters (AWS, Cloudflare, Vercel, emulator) import from here,
|
|
5
|
+
// and the SDK build compiles this module into its dist/internal tree — there
|
|
6
|
+
// is no SDK-side mirror (ADR 0041 retired the hand-ported copy).
|
|
7
7
|
export { IdentityErasureFencedError } from '../../platform/collectionstore/errors.js';
|
package/dist/mimic/core.d.ts
CHANGED
|
@@ -1,34 +1,2 @@
|
|
|
1
|
-
export interface MimicClaims {
|
|
2
|
-
scope: string;
|
|
3
|
-
siteId: string;
|
|
4
|
-
aud?: string[];
|
|
5
|
-
seg?: string[];
|
|
6
|
-
sub?: string;
|
|
7
|
-
jti?: string;
|
|
8
|
-
iat: string;
|
|
9
|
-
expiresAt: string;
|
|
10
|
-
}
|
|
11
|
-
export declare const MIMIC_PARAM = "aci_mimic";
|
|
12
|
-
export declare const MIMIC_COOKIE = "aci_mimic";
|
|
13
|
-
export declare const MIMIC_EXIT = "exit";
|
|
14
|
-
export declare const MIMIC_MAX_MEMBERSHIPS = 32;
|
|
15
|
-
export declare const MIMIC_MAX_TOKEN_BYTES = 2048;
|
|
16
|
-
export declare function validateMimicToken(token: string, signKey: string, siteId: string): Promise<MimicClaims | null>;
|
|
17
|
-
export interface MimicCookie {
|
|
18
|
-
name: string;
|
|
19
|
-
value: string;
|
|
20
|
-
options: {
|
|
21
|
-
httpOnly: boolean;
|
|
22
|
-
sameSite: 'lax';
|
|
23
|
-
maxAge: number;
|
|
24
|
-
path: string;
|
|
25
|
-
secure?: boolean;
|
|
26
|
-
};
|
|
27
|
-
}
|
|
28
|
-
export declare function createMimicEntryCookie(token: string, expiresAt: string, secure?: boolean): MimicCookie;
|
|
29
|
-
export declare function createMimicClearCookie(secure?: boolean): MimicCookie;
|
|
30
|
-
export declare function createMimicBannerHTML(claims: Pick<MimicClaims, 'aud' | 'seg'>, expiresAt: string): string;
|
|
31
|
-
export declare function injectMimicBannerIntoHTML(html: string, banner: string): string;
|
|
32
1
|
export declare const MIMIC_BANNER_HEADER = "x-aci-mimic-banner";
|
|
33
|
-
export declare function encodeMimicBanner(claims: MimicClaims): string;
|
|
34
2
|
export declare function decodeMimicBanner(encoded: string): string;
|