@gradial/aci 0.1.52-rc.13 → 0.1.52-rc.15

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