@gradial/aci 0.1.41 → 0.1.42

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.
Files changed (102) hide show
  1. package/CHANGELOG.md +9 -13
  2. package/README.md +4 -4
  3. package/dist/astro/index.d.ts +1 -1
  4. package/dist/astro/index.js +1 -1
  5. package/dist/astro/page.d.ts +6 -0
  6. package/dist/astro/page.js +3 -2
  7. package/dist/cli/compile-registry.d.mts +2 -0
  8. package/dist/cli/compile-registry.mjs +759 -0
  9. package/dist/cli/materialize-template.d.mts +19 -0
  10. package/dist/cli/materialize-template.mjs +128 -0
  11. package/dist/cli/validate-content.d.mts +2 -0
  12. package/dist/cli/validate-content.mjs +426 -0
  13. package/dist/cli/verify-next-cdn-routes.d.mts +2 -0
  14. package/dist/cli/verify-next-cdn-routes.mjs +47 -0
  15. package/dist/collect/assignments.d.ts +65 -0
  16. package/dist/collect/assignments.js +138 -0
  17. package/dist/collect/batcher.d.ts +21 -0
  18. package/dist/collect/batcher.js +10 -0
  19. package/dist/collect/declarative.d.ts +1 -1
  20. package/dist/collect/declarative.js +7 -1
  21. package/dist/collect/index.js +30 -4
  22. package/dist/collect/pageviews.d.ts +15 -3
  23. package/dist/collect/pageviews.js +28 -13
  24. package/dist/collect/transport.d.ts +2 -0
  25. package/dist/collect/transport.js +32 -0
  26. package/dist/consent-banner/index.d.ts +33 -0
  27. package/dist/consent-banner/index.js +99 -0
  28. package/dist/content/personalization.d.ts +35 -0
  29. package/dist/content/personalization.js +57 -0
  30. package/dist/content/types.d.ts +7 -1
  31. package/dist/edge/personalization-core.d.ts +11 -0
  32. package/dist/edge/personalization-core.js +265 -0
  33. package/dist/index.d.ts +1 -0
  34. package/dist/index.js +1 -0
  35. package/dist/next/assignments-script.d.ts +14 -0
  36. package/dist/next/assignments-script.js +17 -0
  37. package/dist/next/collect-route.d.ts +19 -0
  38. package/dist/next/collect-route.js +351 -0
  39. package/dist/next/index.d.ts +1 -0
  40. package/dist/next/index.js +1 -0
  41. package/dist/next/middleware.d.ts +15 -1
  42. package/dist/next/middleware.js +71 -27
  43. package/dist/next/page.js +2 -1
  44. package/dist/next/personalization-host.d.ts +118 -0
  45. package/dist/next/personalization-host.js +276 -0
  46. package/dist/next/server.js +6 -6
  47. package/dist/next/vercel-personalization.d.ts +3 -0
  48. package/dist/next/vercel-personalization.js +3 -0
  49. package/dist/next/vercel-s3-personalization.d.ts +58 -0
  50. package/dist/next/vercel-s3-personalization.js +220 -0
  51. package/dist/providers/s3.d.ts +1 -1
  52. package/dist/providers/s3.js +1 -1
  53. package/dist/react/VideoPlayer.d.ts +6 -6
  54. package/dist/react/index.d.ts +6 -6
  55. package/dist/types/page.d.ts +1 -1
  56. package/package.json +19 -1
  57. package/src/astro/ConsentBanner.astro +62 -0
  58. package/src/cli/verify-next-cdn-routes.mjs +5 -4
  59. package/src/consent-banner/styles.css +110 -0
  60. package/src/types/page.ts +1 -1
  61. package/templates/astro/starter-content/src/cms/contracts/components/index.ts +2 -0
  62. package/templates/astro/starter-content/src/cms/contracts/components/personalizationChoice.contract.ts +17 -0
  63. package/templates/astro/starter-content/src/cms/registry.ts +3 -0
  64. package/templates/astro/starter-content/src/components/sections/PersonalizationChoice.astro +41 -0
  65. package/templates/astro/starter-content/src/render/RenderPage.astro +3 -2
  66. package/templates/astro/template/src/layouts/SiteLayout.astro +3 -1
  67. package/templates/astro/template/src/pages/[...slug].astro +1 -0
  68. package/templates/common/.content/audiences/preference-blue/_index.json +11 -0
  69. package/templates/common/.content/audiences/preference-red/_index.json +11 -0
  70. package/templates/common/.content/overlays/audience/preference-blue/pages/home/_index.json +13 -0
  71. package/templates/common/.content/overlays/audience/preference-red/pages/home/_index.json +13 -0
  72. package/templates/common/.content/pages/home/_index.json +114 -4
  73. package/templates/common/.content/segments/preference-blue/_index.json +20 -0
  74. package/templates/common/.content/segments/preference-red/_index.json +20 -0
  75. package/templates/common/contracts/components/index.ts +21 -1
  76. package/templates/common/contracts/components/personalizationChoice.contract.ts +17 -0
  77. package/templates/nextjs/starter-content/src/cms/contracts/components/index.ts +3 -1
  78. package/templates/nextjs/starter-content/src/cms/contracts/components/personalizationChoice.contract.ts +17 -0
  79. package/templates/nextjs/starter-content/src/cms/registry.ts +3 -0
  80. package/templates/nextjs/starter-content/src/components/sections/PersonalizationChoice.tsx +53 -0
  81. package/templates/nextjs/starter-content/src/middleware.ts +11 -0
  82. package/templates/nextjs/template/src/app/collect/route.ts +18 -0
  83. package/templates/nextjs/template/src/middleware.ts +6 -2
  84. package/templates/astro/template/.aci.yaml +0 -28
  85. package/templates/common/.content/.gradial-dam/blobs/sha256/7f/bf/7fbfc7fe230ff12688c496f660119e41d9725c4504d82e310c15665c73c09292 +0 -0
  86. package/templates/common/.content/.gradial-dam/blobs/sha256/a0/f5/a0f506689de9b15a7055f70103a3f02cd3ba105771ae2de6754df2ede1210d3a +0 -0
  87. package/templates/common/.content/.gradial-dam/blobs/sha256/dd/b7/ddb7e9642be08317bcd4b2bd26b58ba8d0d34dd7239e03e684a4f3634fa3845b +0 -0
  88. package/templates/common/.content/.gradial-dam/blobs/sha256/fb/32/fb3260e8286ea8231ea1c77b9c0be92d6edd9026565731eb14cfdcfd6b1b75b3 +0 -0
  89. package/templates/common/.content/config/robots.txt +0 -2
  90. package/templates/common/.content/config/site.json +0 -197
  91. package/templates/common/.content/dam/folders/seed/ast_site_about_hero/asset.json +0 -9
  92. package/templates/common/.content/dam/folders/seed/ast_site_about_hero/editorial.json +0 -14
  93. package/templates/common/.content/dam/folders/seed/ast_site_about_hero/governance.json +0 -23
  94. package/templates/common/.content/dam/folders/seed/ast_site_about_hero/versions/v0001.json +0 -37
  95. package/templates/common/.content/dam/folders/seed/ast_site_home_hero/asset.json +0 -9
  96. package/templates/common/.content/dam/folders/seed/ast_site_home_hero/editorial.json +0 -14
  97. package/templates/common/.content/dam/folders/seed/ast_site_home_hero/governance.json +0 -23
  98. package/templates/common/.content/dam/folders/seed/ast_site_home_hero/versions/v0001.json +0 -37
  99. package/templates/common/.content/fragments/navbar/_index.json +0 -25
  100. package/templates/common/.content/fragments/site-footer/_index.json +0 -62
  101. package/templates/common/.content/pages/about/_index.json +0 -51
  102. package/templates/nextjs/template/.aci.yaml +0 -26
@@ -0,0 +1,351 @@
1
+ import { MAX_BATCH_EVENTS } from '../collect/batcher.js';
2
+ import { consentSignals, identityHashHex, parseCookieHeader, verifyClaims, verifyIdentityCookie, } from './personalization-host.js';
3
+ const MAX_COLLECT_BODY_BYTES = 64 * 1024;
4
+ const DEFAULT_RETENTION_SECONDS = 13 * 30 * 24 * 60 * 60;
5
+ const FORBIDDEN_FIELDS = new Set(['aciid', 'audienceids', 'segmentids', 'traits', 'identity']);
6
+ const ULID = /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/i;
7
+ const UTM_KEYS = ['source', 'medium', 'campaign', 'term', 'content', 'id'];
8
+ function identityHashShard(identityHash) {
9
+ const hash = identityHash.trim().toLowerCase();
10
+ if (hash.length < 2)
11
+ throw new Error('identity hash is too short');
12
+ return hash.slice(0, 2);
13
+ }
14
+ export function createPersonalizationCollectRoute(config) {
15
+ return async function collect(request) {
16
+ if (request.method === 'OPTIONS')
17
+ return new Response(null, { status: 204 });
18
+ if (request.method !== 'POST')
19
+ return new Response(null, { status: 405, headers: { allow: 'POST, OPTIONS' } });
20
+ const bytes = new Uint8Array(await request.arrayBuffer());
21
+ if (bytes.byteLength > MAX_COLLECT_BODY_BYTES)
22
+ return new Response(null, { status: 413 });
23
+ let events;
24
+ try {
25
+ const envelope = bytes.byteLength === 0 ? { events: [] } : JSON.parse(new TextDecoder().decode(bytes));
26
+ if (!Array.isArray(envelope.events))
27
+ throw new CollectValidationError('events must be an array');
28
+ // The TS runtime has no cross-instance quota checker (Go's REQ-14/
29
+ // REQ-20 guard); the 64KiB body cap plus this per-batch event cap and
30
+ // the platform WAF are the abuse bounds. The cap is the client
31
+ // batcher's MAX_BATCH_EVENTS — one constant, both sides of the wire.
32
+ if (envelope.events.length > MAX_BATCH_EVENTS)
33
+ throw new CollectValidationError('too many events in one batch');
34
+ events = envelope.events;
35
+ for (const event of events)
36
+ validateEvent(event);
37
+ }
38
+ catch (error) {
39
+ return validationResponse(error);
40
+ }
41
+ const cookies = parseCookieHeader(request.headers.get('cookie'));
42
+ const signals = consentSignals(request, cookies);
43
+ const rawIdentity = await verifyIdentityCookie(cookies.get('aci_id') || '', config.identityKeys);
44
+ const now = (config.now ?? (() => new Date()))();
45
+ const secure = config.secureCookies ?? new URL(request.url).protocol === 'https:';
46
+ const responseHeaders = new Headers({ 'cache-control': 'no-store', 'content-type': 'application/json' });
47
+ try {
48
+ // Withdrawal EVIDENCE (anonymous-experiments REQ-6): a signature-
49
+ // verified aci_claims cookie proves prior identity-tier state exists
50
+ // to suppress. Never-consented visitors present no verifiable claims
51
+ // and stay measurement-only.
52
+ const evidence = rawIdentity
53
+ ? await verifyClaims(cookies.get('aci_claims') || '', config.claimsKeys || {}, now) !== null
54
+ : false;
55
+ const putSuppression = async (trigger) => {
56
+ const hash = await identityHashHex('aci_id', rawIdentity);
57
+ await config.store.putSuppression(`audience/suppression/${identityHashShard(hash)}/${config.siteId}/aci_id/${hash}/${trigger}.json`, { schemaVersion: 1, trigger, observedAt: now.toISOString() });
58
+ };
59
+ if (signals.privacyDenied) {
60
+ if (rawIdentity && evidence) {
61
+ await putSuppression('gpc');
62
+ }
63
+ if (cookies.has('aci_claims'))
64
+ appendCookie(responseHeaders, expiredClaimsCookie(secure));
65
+ return accepted(responseHeaders);
66
+ }
67
+ if (!rawIdentity)
68
+ return accepted(responseHeaders);
69
+ // Measurement tier (anonymous-experiments REQ-5): a verified aci_id
70
+ // stores events even without a personalization grant — the snapshot
71
+ // records what was (not) granted. An explicit denial with verified
72
+ // evidence is an observed withdrawal; measurement storage continues.
73
+ const personalization = signals.allowsIdentityTier;
74
+ if (!personalization && signals.hasConsent && evidence) {
75
+ await putSuppression('withdrawal');
76
+ }
77
+ const identityHash = await identityHashHex('aci_id', rawIdentity);
78
+ for (const event of events) {
79
+ const normalized = normalizeEvent(event, now);
80
+ const collectedAt = now.toISOString();
81
+ const expiresAt = new Date(now.getTime() + (config.retentionSeconds ?? DEFAULT_RETENTION_SECONDS) * 1000).toISOString();
82
+ const key = `audience/first-party/${identityHashShard(identityHash)}/${config.siteId}/aci_id/${identityHash}/${normalized.eventId}.json`;
83
+ await config.store.appendEvent(key, {
84
+ schemaVersion: 1,
85
+ eventId: normalized.eventId,
86
+ eventName: normalized.eventName,
87
+ eventCategory: normalized.eventCategory,
88
+ siteId: config.siteId,
89
+ identity: { namespace: 'aci_id', hash: identityHash },
90
+ // Go decodes `t` as time.Time and CANNOT parse an empty string —
91
+ // an empty-t record permanently poisons that identity's
92
+ // classification and DSAR reads. Omit t unless a real timestamp
93
+ // was observed (legacy bare tokens have none).
94
+ consent: signals.snapshot
95
+ ? {
96
+ v: signals.snapshot.version,
97
+ p: signals.snapshot.purposes,
98
+ ...(signals.snapshot.grantedAt ? { t: signals.snapshot.grantedAt } : {}),
99
+ ...(signals.snapshot.source ? { src: signals.snapshot.source } : {}),
100
+ }
101
+ : { v: 0, p: [] },
102
+ ...(normalized.properties ? { properties: normalized.properties } : {}),
103
+ page: normalized.page,
104
+ ...(normalized.utm ? { utm: normalized.utm } : {}),
105
+ ...(normalized.viewport ? { viewport: normalized.viewport } : {}),
106
+ ...(normalized.experiment ? { experiment: normalized.experiment } : {}),
107
+ occurredAt: normalized.occurredAt,
108
+ collectedAt,
109
+ expiresAt,
110
+ });
111
+ }
112
+ if (personalization) {
113
+ const claims = await config.store.readClaims(config.siteId, 'aci_id', identityHash);
114
+ if (claims)
115
+ appendCookie(responseHeaders, claimsCookie(claims, secure));
116
+ else if (cookies.has('aci_claims'))
117
+ appendCookie(responseHeaders, expiredClaimsCookie(secure));
118
+ }
119
+ else if (cookies.has('aci_claims')) {
120
+ // Claims are personalization; a non-granting touch expires them.
121
+ appendCookie(responseHeaders, expiredClaimsCookie(secure));
122
+ }
123
+ return accepted(responseHeaders);
124
+ }
125
+ catch {
126
+ return Response.json({ error: 'internal error', status: 500 }, { status: 500, headers: { 'cache-control': 'no-store' } });
127
+ }
128
+ };
129
+ }
130
+ function validateEvent(value) {
131
+ if (!value || typeof value !== 'object' || Array.isArray(value))
132
+ throw new CollectValidationError('event must be an object');
133
+ for (const key of Object.keys(value)) {
134
+ if (FORBIDDEN_FIELDS.has(key.toLowerCase()))
135
+ throw new CollectValidationError(`field ${key} is not allowed on the public collect route`);
136
+ }
137
+ if (typeof value.eventId !== 'string' || !ULID.test(value.eventId.trim()))
138
+ throw new CollectValidationError('eventId must be a valid ULID');
139
+ if (typeof value.eventName !== 'string')
140
+ throw new CollectValidationError('event name is required');
141
+ normalizeEventName(value.eventName);
142
+ for (const field of ['url', 'referrer', 'title', 'path']) {
143
+ if (value[field] !== undefined && typeof value[field] !== 'string')
144
+ throw new CollectValidationError(`${field} must be a string`);
145
+ }
146
+ if (value.properties !== undefined && (!value.properties || typeof value.properties !== 'object' || Array.isArray(value.properties))) {
147
+ throw new CollectValidationError('properties must be an object');
148
+ }
149
+ if (value.properties !== undefined)
150
+ sanitizeProperties(value.properties, 'properties', 0);
151
+ if (value.occurredAt !== undefined && (typeof value.occurredAt !== 'string' || Number.isNaN(Date.parse(value.occurredAt)))) {
152
+ throw new CollectValidationError('occurredAt must be an RFC3339 timestamp');
153
+ }
154
+ if (value.utm !== undefined && (!value.utm || typeof value.utm !== 'object' || Array.isArray(value.utm))) {
155
+ throw new CollectValidationError('utm must be an object');
156
+ }
157
+ normalizeViewport(value.viewport);
158
+ normalizeExperiment(value.experiment);
159
+ }
160
+ function normalizeEvent(event, now) {
161
+ const [eventName, eventCategory] = normalizeEventName(String(event.eventName));
162
+ const rawURL = typeof event.url === 'string' ? event.url.trim() : '';
163
+ const pageURL = sanitizeURL(rawURL, 'url');
164
+ const referrer = sanitizeURL(typeof event.referrer === 'string' ? event.referrer.trim() : '', 'referrer');
165
+ const parsedURL = rawURL ? new URL(rawURL, 'https://aci.invalid') : null;
166
+ let path = typeof event.path === 'string' ? event.path.trim() : parsedURL?.pathname || '';
167
+ if (path && !path.startsWith('/'))
168
+ path = `/${path}`;
169
+ const utm = normalizeUTM(event.utm, parsedURL);
170
+ const occurredAt = typeof event.occurredAt === 'string' && !Number.isNaN(Date.parse(event.occurredAt))
171
+ ? new Date(event.occurredAt).toISOString()
172
+ : now.toISOString();
173
+ return {
174
+ eventId: String(event.eventId).trim().toUpperCase(),
175
+ eventName,
176
+ eventCategory,
177
+ occurredAt,
178
+ properties: event.properties === undefined ? undefined : sanitizeProperties(event.properties, 'properties', 0),
179
+ page: {
180
+ ...(pageURL ? { url: pageURL } : {}),
181
+ ...(referrer ? { referrer } : {}),
182
+ ...(typeof event.title === 'string' && event.title.trim() ? { title: event.title.trim() } : {}),
183
+ ...(path ? { path } : {}),
184
+ },
185
+ utm,
186
+ viewport: normalizeViewport(event.viewport),
187
+ experiment: normalizeExperiment(event.experiment),
188
+ };
189
+ }
190
+ function normalizeEventName(value) {
191
+ const name = value.trim().toLowerCase();
192
+ if (!name || name.length > 128 || /[\s/\\]/.test(name))
193
+ throw new CollectValidationError('event name must be dot-delimited');
194
+ const rum = new Set(['rum.page_view', 'rum.link_click', 'rum.variation_view', 'rum.web_vital']);
195
+ const experiment = new Set(['experiment.exposure', 'experiment.conversion', 'experiment.engagement']);
196
+ if (rum.has(name))
197
+ return [name, 'rum'];
198
+ if (experiment.has(name))
199
+ return [name, 'experiment'];
200
+ if (name.startsWith('custom.') && name.length > 'custom.'.length)
201
+ return [name, 'custom'];
202
+ throw new CollectValidationError('event name must be one of the supported rum.* or experiment.* events, or use the custom.* namespace');
203
+ }
204
+ function sanitizeURL(value, field) {
205
+ if (!value)
206
+ return '';
207
+ if (value.length > 2048)
208
+ throw new CollectValidationError(`${field} is too long`);
209
+ let parsed;
210
+ try {
211
+ parsed = new URL(value, 'https://aci.invalid');
212
+ }
213
+ catch {
214
+ throw new CollectValidationError(`${field} is invalid`);
215
+ }
216
+ if (!['http:', 'https:'].includes(parsed.protocol))
217
+ throw new CollectValidationError(`${field} scheme is not allowed`);
218
+ parsed.search = '';
219
+ parsed.hash = '';
220
+ if (parsed.origin === 'https://aci.invalid')
221
+ return parsed.pathname;
222
+ return parsed.toString().replace(/\/$/, parsed.pathname === '/' ? '/' : '');
223
+ }
224
+ function normalizeUTM(value, url) {
225
+ const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
226
+ const out = {};
227
+ for (const key of UTM_KEYS) {
228
+ const raw = source[key] ?? source[`utm_${key}`] ?? url?.searchParams.get(`utm_${key}`);
229
+ if (typeof raw === 'string' && raw.trim())
230
+ out[key] = raw.trim();
231
+ }
232
+ return Object.keys(out).length > 0 ? out : undefined;
233
+ }
234
+ function sanitizeProperties(value, field, depth) {
235
+ if (depth > 4)
236
+ throw new CollectValidationError(`${field} is nested too deeply`);
237
+ if (value === null || typeof value === 'boolean' || typeof value === 'number')
238
+ return value;
239
+ if (typeof value === 'string')
240
+ return sanitizeString(value, field, 2048);
241
+ if (Array.isArray(value)) {
242
+ if (value.length > 32)
243
+ throw new CollectValidationError(`${field} has too many values`);
244
+ return value.map((item, index) => sanitizeProperties(item, `${field}[${index}]`, depth + 1));
245
+ }
246
+ if (typeof value !== 'object')
247
+ throw new CollectValidationError(`${field} has unsupported value type`);
248
+ const entries = Object.entries(value);
249
+ if (entries.length > 64)
250
+ throw new CollectValidationError(`${field} has too many keys`);
251
+ const sanitized = {};
252
+ for (const [rawKey, item] of entries) {
253
+ const key = sanitizeString(rawKey, `${field} key`, 256);
254
+ if (!key)
255
+ throw new CollectValidationError(`${field} contains an empty key`);
256
+ if (secretLikeKey(key))
257
+ throw new CollectValidationError(`${field} contains a sensitive key`);
258
+ sanitized[key] = sanitizeProperties(item, `${field}.${key}`, depth + 1);
259
+ }
260
+ return sanitized;
261
+ }
262
+ function secretLikeKey(key) {
263
+ const normalized = key.toLowerCase().replace(/[-. ]/g, '_');
264
+ const compact = normalized.replace(/_/g, '');
265
+ const normalizedMarkers = [
266
+ 'authorization', 'access_token', 'refresh_token', 'id_token', 'api_key',
267
+ 'private_key', 'secret', 'password', 'passwd', 'credential', 'cookie', 'set_cookie',
268
+ ];
269
+ const compactMarkers = [
270
+ 'authorization', 'accesstoken', 'refreshtoken', 'idtoken', 'apikey',
271
+ 'privatekey', 'secret', 'password', 'passwd', 'credential', 'cookie', 'setcookie',
272
+ ];
273
+ return normalizedMarkers.some((marker) => normalized.includes(marker))
274
+ || compactMarkers.some((marker) => compact.includes(marker));
275
+ }
276
+ function sanitizeString(value, field, limit) {
277
+ const clean = value.trim();
278
+ if (clean.length > limit)
279
+ throw new CollectValidationError(`${field} is too long`);
280
+ if (/[\u0000-\u001F\u007F-\u009F]/u.test(clean))
281
+ throw new CollectValidationError(`${field} contains control characters`);
282
+ return clean;
283
+ }
284
+ function normalizeViewport(value) {
285
+ if (value === undefined || value === null)
286
+ return undefined;
287
+ if (typeof value !== 'object' || Array.isArray(value))
288
+ throw new CollectValidationError('viewport is invalid');
289
+ const viewport = value;
290
+ const width = viewport.width ?? 0;
291
+ const height = viewport.height ?? 0;
292
+ if (!Number.isInteger(width) || !Number.isInteger(height) || Number(width) < 0 || Number(height) < 0 || Number(width) > 20_000 || Number(height) > 20_000) {
293
+ throw new CollectValidationError('viewport is invalid');
294
+ }
295
+ if (width === 0 && height === 0)
296
+ return undefined;
297
+ return { width: Number(width), height: Number(height) };
298
+ }
299
+ function normalizeExperiment(value) {
300
+ if (value === undefined || value === null)
301
+ return undefined;
302
+ if (typeof value !== 'object' || Array.isArray(value))
303
+ throw new CollectValidationError('experiment is invalid');
304
+ const source = value;
305
+ const experimentId = shortOptionalString(source.experimentId, 'experiment.experimentId');
306
+ const armId = shortOptionalString(source.armId, 'experiment.armId');
307
+ const allocationVersion = shortOptionalString(source.allocationVersion, 'experiment.allocationVersion');
308
+ const goalId = shortOptionalString(source.goalId, 'experiment.goalId');
309
+ const bucket = source.assignmentBucket;
310
+ if (!experimentId && !armId && !allocationVersion && !goalId && bucket === undefined)
311
+ return undefined;
312
+ if (!experimentId)
313
+ throw new CollectValidationError('experiment.experimentId is required when experiment context is present');
314
+ if (!armId)
315
+ throw new CollectValidationError('experiment.armId is required when experiment context is present');
316
+ if (bucket !== undefined && (!Number.isInteger(bucket) || Number(bucket) < 0 || Number(bucket) > 9999)) {
317
+ throw new CollectValidationError('experiment.assignmentBucket must be between 0 and 9999');
318
+ }
319
+ return {
320
+ experimentId,
321
+ armId,
322
+ ...(allocationVersion ? { allocationVersion } : {}),
323
+ ...(goalId ? { goalId } : {}),
324
+ ...(bucket !== undefined ? { assignmentBucket: bucket } : {}),
325
+ };
326
+ }
327
+ function shortOptionalString(value, field) {
328
+ if (value === undefined || value === null)
329
+ return '';
330
+ if (typeof value !== 'string')
331
+ throw new CollectValidationError(`${field} must be a string`);
332
+ return sanitizeString(value, field, 128);
333
+ }
334
+ function accepted(headers) {
335
+ return new Response('{"accepted":true}', { status: 202, headers });
336
+ }
337
+ function validationResponse(error) {
338
+ const message = error instanceof CollectValidationError ? error.message : 'invalid JSON body';
339
+ return Response.json({ error: message, status: 400 }, { status: 400, headers: { 'cache-control': 'no-store' } });
340
+ }
341
+ function claimsCookie(value, secure) {
342
+ return `aci_claims=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400${secure ? '; Secure' : ''}`;
343
+ }
344
+ function expiredClaimsCookie(secure) {
345
+ return `aci_claims=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT${secure ? '; Secure' : ''}`;
346
+ }
347
+ function appendCookie(headers, cookie) {
348
+ headers.append('set-cookie', cookie);
349
+ }
350
+ class CollectValidationError extends Error {
351
+ }
@@ -6,3 +6,4 @@ export { getSiteConfig, getFragment, getLayout, getPage, getRenderInput, routeFr
6
6
  export { PageNotFoundError, FragmentNotFoundError, LayoutNotFoundError } from './server.js';
7
7
  export { CollectScript, type CollectScriptProps } from './collect-script.js';
8
8
  export { aciMiddlewareMatcher } from './middleware.js';
9
+ export { AssignmentsScript, type AssignmentsScriptProps } from './assignments-script.js';
@@ -6,3 +6,4 @@ export { getSiteConfig, getFragment, getLayout, getPage, getRenderInput, routeFr
6
6
  export { PageNotFoundError, FragmentNotFoundError, LayoutNotFoundError } from './server.js';
7
7
  export { CollectScript } from './collect-script.js';
8
8
  export { aciMiddlewareMatcher } from './middleware.js';
9
+ export { AssignmentsScript } from './assignments-script.js';
@@ -1,12 +1,26 @@
1
- import { type NextRequest } from 'next/server.js';
1
+ import { NextResponse, type NextRequest } from 'next/server.js';
2
+ import { type PersonalizationHostConfig } from './personalization-host.js';
2
3
  export interface GradialMiddlewareConfig {
4
+ siteId?: string;
3
5
  edgeConfig?: string;
4
6
  previewSignKey?: string;
5
7
  deploymentId?: string;
6
8
  deploymentProtectionBypass?: string;
9
+ personalization?: Omit<PersonalizationHostConfig, 'siteId'> & {
10
+ siteId?: string;
11
+ };
7
12
  }
8
13
  export declare function createGradialMiddleware(config?: GradialMiddlewareConfig): (request: NextRequest) => Promise<Response>;
14
+ /**
15
+ * Any response that writes a cookie is per-visitor and must never enter a
16
+ * shared cache (anonymous-experiments REQ-3(a)): a CDN-cached Set-Cookie
17
+ * replays one visitor's identity/claims to everyone under that cache key.
18
+ * Per-variant CACHING itself stays legitimate — the variant travels in the
19
+ * cache-keyed query parameter, not in cookies.
20
+ */
21
+ export declare function enforceCookieCacheRule(response: NextResponse): void;
9
22
  export declare const aciMiddlewareMatcher: string[];
10
23
  export declare const config: {
24
+ runtime: string;
11
25
  matcher: string[];
12
26
  };
@@ -1,15 +1,11 @@
1
1
  import { NextResponse } from 'next/server.js';
2
2
  import { getUncachedEdgeConfigValue } from './edge-config.js';
3
3
  import { PREVIEW_TOKEN_COOKIE, PREVIEW_RELEASE_COOKIE, extractPreviewToken, resolvePreviewContext, createPreviewCookies, previewSignKey, } from '../preview/core.js';
4
+ import { CONTENT_PATH_CACHE_PARAM, resolvePersonalizationRequest, } from './personalization-host.js';
4
5
  const RELEASE_SCOPED_PREFIX = '/__r';
5
- // CMS document paths (robots.txt, sitemap.xml) pass through the middleware
6
- // and are served by Next.js rewrites configured in withAci().
7
- const CMS_DOCUMENT_PATHS = new Set(['/robots.txt', '/sitemap.xml']);
6
+ const CMS_ASSET_PATHS = new Set(['/robots.txt', '/sitemap.xml']);
8
7
  const PUBLIC_PATHS = new Set(typeof __ACI_PUBLIC_PATHS__ !== 'undefined' ? __ACI_PUBLIC_PATHS__ : []);
9
8
  const PASSTHROUGH_ROUTES = (typeof __ACI_PASSTHROUGH_ROUTES__ !== 'undefined' ? __ACI_PASSTHROUGH_ROUTES__ : []);
10
- const ASSET_PREFIX = typeof __ACI_ASSET_PREFIX__ !== 'undefined'
11
- ? __ACI_ASSET_PREFIX__
12
- : '/.gradial-dam';
13
9
  export function createGradialMiddleware(config = {}) {
14
10
  return async function middleware(request) {
15
11
  const url = request.nextUrl;
@@ -24,12 +20,7 @@ export function createGradialMiddleware(config = {}) {
24
20
  if (PUBLIC_PATHS.has(url.pathname)) {
25
21
  return NextResponse.next();
26
22
  }
27
- if (CMS_DOCUMENT_PATHS.has(url.pathname)) {
28
- return NextResponse.next();
29
- }
30
- // DAM asset requests are NOT release-scoped; they rewrite to S3 via
31
- // withAci() rewrites. Pass them through so the rewrite fires.
32
- if (isAssetPath(url.pathname)) {
23
+ if (CMS_ASSET_PATHS.has(url.pathname)) {
33
24
  return NextResponse.next();
34
25
  }
35
26
  if (isPassthroughRoute(url.pathname)) {
@@ -47,7 +38,7 @@ export function createGradialMiddleware(config = {}) {
47
38
  const preview = await resolvePreviewFromRequest(request, config);
48
39
  // Local file-backed development has no release-indexed artifact tree. A
49
40
  // valid preview token is still useful there—it proves signing, expiry, and
50
- // cookie propagation—but rewriting to /__r/{releaseId}/{path} would create
41
+ // cookie propagation—but rewriting to /__r/{release}/routes would create
51
42
  // a path the file provider cannot serve. Keep the public route and mark
52
43
  // the response private, matching preview's cache semantics.
53
44
  if (process.env.ACI_CONTENT_PROVIDER === 'file') {
@@ -59,6 +50,7 @@ export function createGradialMiddleware(config = {}) {
59
50
  response.cookies.set(cookie.name, cookie.value, cookie.options);
60
51
  }
61
52
  }
53
+ enforceCookieCacheRule(response);
62
54
  return response;
63
55
  }
64
56
  const releaseId = preview?.releaseId || await activeReleaseFromEdgeConfig(config);
@@ -85,25 +77,83 @@ export function createGradialMiddleware(config = {}) {
85
77
  response.cookies.set(cookie.name, cookie.value, cookie.options);
86
78
  }
87
79
  }
80
+ enforceCookieCacheRule(response);
88
81
  return response;
89
82
  }
90
83
  // Rewrite public URL to release-scoped internal path for CDN cache isolation.
91
- // Each release gets a unique URL (/__r/{releaseId}/...) so the CDN
84
+ // Each release gets a unique URL (/__r/{releaseId}/routes/...) so the CDN
92
85
  // cache key is per-release. New release = new URL = cache miss = fresh content.
93
- const response = rewriteToReleaseScopedPath(request, releaseId, Boolean(preview?.isPreview));
86
+ let personalization;
87
+ if (config.personalization) {
88
+ const siteId = config.personalization.siteId || siteIdForConfig(config);
89
+ if (!siteId) {
90
+ return new Response('ACI site id is not configured', {
91
+ status: 500,
92
+ headers: { 'Cache-Control': 'private, no-store' },
93
+ });
94
+ }
95
+ try {
96
+ personalization = await resolvePersonalizationRequest(request, releaseId, {
97
+ ...config.personalization,
98
+ siteId,
99
+ });
100
+ }
101
+ catch {
102
+ return new Response('personalization artifacts unavailable', {
103
+ status: 503,
104
+ headers: { 'Cache-Control': 'private, no-store' },
105
+ });
106
+ }
107
+ }
108
+ const response = rewriteToReleaseScopedPath(request, releaseId, Boolean(preview?.isPreview), personalization);
94
109
  // Persist preview token as cookie so navigation stays in preview
95
110
  if (preview?.token && preview.isPreview) {
96
111
  for (const cookie of createPreviewCookies(preview.token, releaseId)) {
97
112
  response.cookies.set(cookie.name, cookie.value, cookie.options);
98
113
  }
99
114
  }
115
+ if (personalization) {
116
+ for (const cookie of personalization.cookies) {
117
+ response.cookies.set(cookie.name, cookie.value, cookie.options);
118
+ }
119
+ if (personalization.variantKey) {
120
+ response.headers.set('X-ACI-Variant-Key', personalization.variantKey);
121
+ }
122
+ if (personalization.experiment) {
123
+ response.headers.set('X-ACI-Experiment-ID', personalization.experiment.id);
124
+ response.headers.set('X-ACI-Experiment-Arm', personalization.experiment.arm);
125
+ response.headers.set('X-ACI-Experiment-Allocation-Version', personalization.experiment.allocationVersion);
126
+ }
127
+ }
128
+ enforceCookieCacheRule(response);
100
129
  return response;
101
130
  };
102
131
  }
103
- function rewriteToReleaseScopedPath(request, releaseId, isPreview) {
132
+ /**
133
+ * Any response that writes a cookie is per-visitor and must never enter a
134
+ * shared cache (anonymous-experiments REQ-3(a)): a CDN-cached Set-Cookie
135
+ * replays one visitor's identity/claims to everyone under that cache key.
136
+ * Per-variant CACHING itself stays legitimate — the variant travels in the
137
+ * cache-keyed query parameter, not in cookies.
138
+ */
139
+ export function enforceCookieCacheRule(response) {
140
+ if (response.cookies.getAll().length === 0) {
141
+ return;
142
+ }
143
+ response.headers.set('Cache-Control', 'private, no-store');
144
+ response.headers.delete('CDN-Cache-Control');
145
+ response.headers.delete('Vercel-CDN-Cache-Control');
146
+ }
147
+ function rewriteToReleaseScopedPath(request, releaseId, isPreview, personalization) {
104
148
  const rewriteURL = new URL(request.url);
105
149
  rewriteURL.pathname = releaseScopedPath(RELEASE_SCOPED_PREFIX, releaseId, request.nextUrl.pathname);
106
- const response = NextResponse.rewrite(rewriteURL);
150
+ rewriteURL.searchParams.delete(CONTENT_PATH_CACHE_PARAM);
151
+ if (personalization?.contentPath) {
152
+ rewriteURL.searchParams.set(CONTENT_PATH_CACHE_PARAM, personalization.contentPath);
153
+ }
154
+ const response = personalization
155
+ ? NextResponse.rewrite(rewriteURL, { request: { headers: personalization.requestHeaders } })
156
+ : NextResponse.rewrite(rewriteURL);
107
157
  if (isPreview) {
108
158
  response.headers.set('Cache-Control', 'private, no-store');
109
159
  }
@@ -185,8 +235,8 @@ async function activeReleaseFromEdgeConfig(config) {
185
235
  return active.releaseId;
186
236
  return clampRelease(active.releaseId, active.sequence || 0, compatibility);
187
237
  }
188
- function siteIdForConfig(_config) {
189
- return process.env.ACI_SITE_ID || '';
238
+ function siteIdForConfig(config) {
239
+ return config.siteId || process.env.ACI_SITE_ID || '';
190
240
  }
191
241
  async function getEdgeConfigJSON(edgeConfig, key) {
192
242
  const value = await getEdgeConfigValue(edgeConfig, key);
@@ -243,13 +293,6 @@ function clampRelease(activeReleaseId, activeSequence, compatibility) {
243
293
  function edgeKeySegment(value) {
244
294
  return value.replace(/[^A-Za-z0-9_-]/g, '_');
245
295
  }
246
- function isAssetPath(pathname) {
247
- if (pathname === ASSET_PREFIX || pathname.startsWith(`${ASSET_PREFIX}/`))
248
- return true;
249
- if (pathname.startsWith('/gradial/assets/'))
250
- return true;
251
- return false;
252
- }
253
296
  function isPassthroughRoute(pathname) {
254
297
  return PASSTHROUGH_ROUTES.some((route) => routeMatches(pathname, route));
255
298
  }
@@ -276,8 +319,9 @@ function normalizeRoutePath(value) {
276
319
  return pathname.replace(/\/{2,}/g, '/').replace(/\/$/, '') || '/';
277
320
  }
278
321
  export const aciMiddlewareMatcher = [
279
- '/((?!_next(?:/|$)|api(?:/|$)|favicon\\.ico$|\\.gradial-dam(?:/|$)).*)',
322
+ '/((?!_next(?:/|$)|api(?:/|$)|collect(?:/|$)|gradial/assets(?:/|$)|favicon\\.ico$).*)',
280
323
  ];
281
324
  export const config = {
325
+ runtime: 'nodejs',
282
326
  matcher: aciMiddlewareMatcher,
283
327
  };
package/dist/next/page.js CHANGED
@@ -4,6 +4,7 @@ import { Fragment } from 'react';
4
4
  import { PageNotFoundError, getRenderInput, getLayout, getFragment, } from './server.js';
5
5
  import { routeMetadataForContent, resolveSlots, } from '../content/index.js';
6
6
  import { renderBlocks } from './render.js';
7
+ import { AssignmentsScript } from './assignments-script.js';
7
8
  import { CollectScript } from './collect-script.js';
8
9
  /**
9
10
  * Creates the catch-all route handler for a Next.js ACI site.
@@ -80,7 +81,7 @@ export function createPage(registry, options) {
80
81
  }
81
82
  return (_jsx(Fragment, { children: renderedBlocks }, slot.name));
82
83
  }));
83
- return (_jsxs(_Fragment, { children: [renderedSlots.filter(Boolean), _jsx(CollectScript, { siteConfig: input.siteConfig })] }));
84
+ return (_jsxs(_Fragment, { children: [renderedSlots.filter(Boolean), _jsx(AssignmentsScript, { page: input.page }), _jsx(CollectScript, { siteConfig: input.siteConfig })] }));
84
85
  }
85
86
  catch (error) {
86
87
  if (error instanceof PageNotFoundError)