@gradial/aci 0.1.40 → 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 (95) hide show
  1. package/dist/astro/index.d.ts +1 -1
  2. package/dist/astro/index.js +1 -1
  3. package/dist/astro/page.d.ts +6 -0
  4. package/dist/astro/page.js +3 -2
  5. package/dist/cli/compile-registry.d.mts +2 -0
  6. package/dist/cli/compile-registry.mjs +759 -0
  7. package/dist/cli/materialize-template.d.mts +19 -0
  8. package/dist/cli/materialize-template.mjs +128 -0
  9. package/dist/cli/validate-content.d.mts +2 -0
  10. package/dist/cli/validate-content.mjs +426 -0
  11. package/dist/cli/verify-next-cdn-routes.d.mts +2 -0
  12. package/dist/cli/verify-next-cdn-routes.mjs +47 -0
  13. package/dist/collect/assignments.d.ts +65 -0
  14. package/dist/collect/assignments.js +138 -0
  15. package/dist/collect/batcher.d.ts +21 -0
  16. package/dist/collect/batcher.js +10 -0
  17. package/dist/collect/declarative.d.ts +1 -1
  18. package/dist/collect/declarative.js +7 -1
  19. package/dist/collect/index.js +30 -4
  20. package/dist/collect/pageviews.d.ts +15 -3
  21. package/dist/collect/pageviews.js +28 -13
  22. package/dist/collect/transport.d.ts +2 -0
  23. package/dist/collect/transport.js +32 -0
  24. package/dist/content/personalization.d.ts +35 -0
  25. package/dist/content/personalization.js +57 -0
  26. package/dist/content/types.d.ts +7 -1
  27. package/dist/edge/personalization-core.d.ts +11 -0
  28. package/dist/edge/personalization-core.js +265 -0
  29. package/dist/next/assignments-script.d.ts +14 -0
  30. package/dist/next/assignments-script.js +17 -0
  31. package/dist/next/collect-route.d.ts +19 -0
  32. package/dist/next/collect-route.js +351 -0
  33. package/dist/next/index.d.ts +1 -0
  34. package/dist/next/index.js +1 -0
  35. package/dist/next/middleware.d.ts +15 -1
  36. package/dist/next/middleware.js +71 -27
  37. package/dist/next/page.js +2 -1
  38. package/dist/next/personalization-host.d.ts +118 -0
  39. package/dist/next/personalization-host.js +276 -0
  40. package/dist/next/server.js +6 -6
  41. package/dist/next/vercel-personalization.d.ts +3 -0
  42. package/dist/next/vercel-personalization.js +3 -0
  43. package/dist/next/vercel-s3-personalization.d.ts +58 -0
  44. package/dist/next/vercel-s3-personalization.js +220 -0
  45. package/dist/providers/s3.d.ts +1 -1
  46. package/dist/providers/s3.js +1 -1
  47. package/dist/react/VideoPlayer.d.ts +4 -4
  48. package/dist/react/index.d.ts +4 -4
  49. package/dist/types/page.d.ts +1 -1
  50. package/package.json +24 -16
  51. package/src/cli/validate-content.mjs +0 -0
  52. package/src/cli/verify-next-cdn-routes.mjs +5 -4
  53. package/src/types/page.ts +1 -1
  54. package/templates/astro/starter-content/src/cms/contracts/components/index.ts +2 -0
  55. package/templates/astro/starter-content/src/cms/contracts/components/personalizationChoice.contract.ts +17 -0
  56. package/templates/astro/starter-content/src/cms/registry.ts +3 -0
  57. package/templates/astro/starter-content/src/components/sections/PersonalizationChoice.astro +41 -0
  58. package/templates/astro/starter-content/src/render/RenderPage.astro +3 -2
  59. package/templates/astro/template/src/layouts/SiteLayout.astro +3 -1
  60. package/templates/astro/template/src/pages/[...slug].astro +1 -0
  61. package/templates/common/.content/audiences/preference-blue/_index.json +11 -0
  62. package/templates/common/.content/audiences/preference-red/_index.json +11 -0
  63. package/templates/common/.content/overlays/audience/preference-blue/pages/home/_index.json +13 -0
  64. package/templates/common/.content/overlays/audience/preference-red/pages/home/_index.json +13 -0
  65. package/templates/common/.content/pages/home/_index.json +114 -4
  66. package/templates/common/.content/segments/preference-blue/_index.json +20 -0
  67. package/templates/common/.content/segments/preference-red/_index.json +20 -0
  68. package/templates/common/contracts/components/index.ts +21 -1
  69. package/templates/common/contracts/components/personalizationChoice.contract.ts +17 -0
  70. package/templates/nextjs/starter-content/src/cms/contracts/components/index.ts +3 -1
  71. package/templates/nextjs/starter-content/src/cms/contracts/components/personalizationChoice.contract.ts +17 -0
  72. package/templates/nextjs/starter-content/src/cms/registry.ts +3 -0
  73. package/templates/nextjs/starter-content/src/components/sections/PersonalizationChoice.tsx +53 -0
  74. package/templates/nextjs/starter-content/src/middleware.ts +11 -0
  75. package/templates/nextjs/template/src/app/collect/route.ts +18 -0
  76. package/templates/nextjs/template/src/middleware.ts +6 -2
  77. package/templates/astro/template/.aci.yaml +0 -28
  78. package/templates/common/.content/.gradial-dam/blobs/sha256/7f/bf/7fbfc7fe230ff12688c496f660119e41d9725c4504d82e310c15665c73c09292 +0 -0
  79. package/templates/common/.content/.gradial-dam/blobs/sha256/a0/f5/a0f506689de9b15a7055f70103a3f02cd3ba105771ae2de6754df2ede1210d3a +0 -0
  80. package/templates/common/.content/.gradial-dam/blobs/sha256/dd/b7/ddb7e9642be08317bcd4b2bd26b58ba8d0d34dd7239e03e684a4f3634fa3845b +0 -0
  81. package/templates/common/.content/.gradial-dam/blobs/sha256/fb/32/fb3260e8286ea8231ea1c77b9c0be92d6edd9026565731eb14cfdcfd6b1b75b3 +0 -0
  82. package/templates/common/.content/config/robots.txt +0 -2
  83. package/templates/common/.content/config/site.json +0 -197
  84. package/templates/common/.content/dam/folders/seed/ast_site_about_hero/asset.json +0 -9
  85. package/templates/common/.content/dam/folders/seed/ast_site_about_hero/editorial.json +0 -14
  86. package/templates/common/.content/dam/folders/seed/ast_site_about_hero/governance.json +0 -23
  87. package/templates/common/.content/dam/folders/seed/ast_site_about_hero/versions/v0001.json +0 -37
  88. package/templates/common/.content/dam/folders/seed/ast_site_home_hero/asset.json +0 -9
  89. package/templates/common/.content/dam/folders/seed/ast_site_home_hero/editorial.json +0 -14
  90. package/templates/common/.content/dam/folders/seed/ast_site_home_hero/governance.json +0 -23
  91. package/templates/common/.content/dam/folders/seed/ast_site_home_hero/versions/v0001.json +0 -37
  92. package/templates/common/.content/fragments/navbar/_index.json +0 -25
  93. package/templates/common/.content/fragments/site-footer/_index.json +0 -62
  94. package/templates/common/.content/pages/about/_index.json +0 -51
  95. package/templates/nextjs/template/.aci.yaml +0 -26
@@ -0,0 +1,265 @@
1
+ // Runtime-neutral personalization selection. Vercel imports this module and
2
+ // the CloudFront Terraform wrapper inlines the same source into its function.
3
+ // Keep this file free of platform APIs and dependencies.
4
+ export function selectPersonalizationVariant(request, route, claims, experiments, identity) {
5
+ return resolvePersonalizationDecision(request, route, claims, experiments, identity).variant;
6
+ }
7
+ export function resolvePersonalizationDecision(request, route, claims, experiments, identity) {
8
+ if (!route)
9
+ return { variant: null, experiment: null };
10
+ let variant = selectRuleVariant(request, route, claims);
11
+ let experiment = null;
12
+ if (identity && identity.usable && route.variants && experiments) {
13
+ const candidates = Object.values(experiments)
14
+ .filter((item) => (item.kind === 'ab' || item.kind === 'abn') && item.status === 'running')
15
+ .filter((item) => routeHasExperiment(route, item.id))
16
+ .sort((left, right) => (right.priority || 0) - (left.priority || 0)
17
+ || String(left.id || '').localeCompare(String(right.id || '')));
18
+ for (let index = 0; index < candidates.length; index++) {
19
+ const item = candidates[index];
20
+ const assignment = assignExperiment(item, identity);
21
+ const candidate = assignment && route.variants[assignment.variantKey];
22
+ if (!candidate)
23
+ continue;
24
+ if (!variant || betterVariant(candidate.priority || 0, candidate.variantKey, variant.priority || 0, variant.variantKey)) {
25
+ variant = candidate;
26
+ experiment = assignment;
27
+ }
28
+ break;
29
+ }
30
+ }
31
+ return { variant: variant || null, experiment };
32
+ }
33
+ function selectRuleVariant(request, route, claims) {
34
+ if (!route.variants)
35
+ return null;
36
+ const matches = [];
37
+ for (const key in route.variants) {
38
+ const variant = route.variants[key];
39
+ const values = variant.variantValues || variantValuesFromKey(key);
40
+ if (!requestDimensionsMatch(request, values))
41
+ continue;
42
+ const membershipMatch = (values.audience && claims && (claims.aud || []).includes(values.audience))
43
+ || (values.segment && claims && (claims.seg || []).includes(values.segment));
44
+ const ruleMatch = variant.rule ? evaluateRule(request, variant.rule) : false;
45
+ if (membershipMatch || ruleMatch)
46
+ matches.push(variant);
47
+ }
48
+ matches.sort((left, right) => (right.priority || 0) - (left.priority || 0)
49
+ || String(left.variantKey || '').localeCompare(String(right.variantKey || '')));
50
+ return matches[0] || null;
51
+ }
52
+ // CloudFront KVS values are capped at 1 KiB. The variant map key is the
53
+ // canonical encoding of its dimensions, so compact route records omit the
54
+ // duplicated variantValues object and recover it at the edge.
55
+ function variantValuesFromKey(key) {
56
+ const values = {};
57
+ const parts = String(key || '').replace(/&/g, ';').split(';');
58
+ for (let index = 0; index < parts.length; index++) {
59
+ const separator = parts[index].indexOf('=');
60
+ if (separator > 0)
61
+ values[parts[index].slice(0, separator)] = parts[index].slice(separator + 1);
62
+ }
63
+ return values;
64
+ }
65
+ function routeHasExperiment(route, experimentID) {
66
+ for (const key in route.variants || {}) {
67
+ const values = route.variants[key].variantValues || variantValuesFromKey(key);
68
+ if (values.experiment === experimentID)
69
+ return true;
70
+ }
71
+ return false;
72
+ }
73
+ function assignExperiment(experiment, identity) {
74
+ const version = String(experiment.allocationVersion || '').trim();
75
+ const salt = String(experiment.salt || '').trim();
76
+ const siteID = String(identity.siteId || '').trim();
77
+ const identityKey = `${String(identity.namespace || '').trim()}:${String(identity.identityHash || '').trim()}`;
78
+ if (!version || !salt || !siteID || identityKey === ':')
79
+ return null;
80
+ const input = [siteID, String(experiment.id || ''), version, identityKey, salt].join('\0');
81
+ const bucket = sha256Bucket(input);
82
+ let cumulative = 0;
83
+ const arms = experiment.arms || [];
84
+ for (let index = 0; index < arms.length; index++) {
85
+ const arm = arms[index];
86
+ if ((arm.allocationBasisPoints || 0) <= 0)
87
+ continue;
88
+ cumulative += arm.allocationBasisPoints;
89
+ if (bucket < cumulative) {
90
+ return {
91
+ experimentId: experiment.id,
92
+ armId: arm.id,
93
+ allocationVersion: version,
94
+ variantKey: arm.variantKey,
95
+ bucket,
96
+ };
97
+ }
98
+ }
99
+ return null;
100
+ }
101
+ function betterVariant(candidatePriority, candidateKey, bestPriority, bestKey) {
102
+ if (candidatePriority !== bestPriority)
103
+ return candidatePriority > bestPriority;
104
+ return String(candidateKey || '').localeCompare(String(bestKey || '')) < 0;
105
+ }
106
+ function requestDimensionsMatch(request, values) {
107
+ for (const rawKey in values) {
108
+ const rawWant = values[rawKey];
109
+ const key = String(rawKey).trim();
110
+ const normalized = key.toLowerCase();
111
+ const want = String(rawWant).trim();
112
+ if (!key || !want || ['audience', 'segment', 'experiment', 'experimentarm'].includes(normalized))
113
+ continue;
114
+ const candidates = (request.query[key] || []).concat(request.query['aci_' + key] || [], request.headers['x-aci-' + normalized] || '', request.headers['x-gradial-' + normalized] || '', request.cookies[key] || '', request.cookies['aci_' + key] || '');
115
+ if (normalized === 'locale') {
116
+ const locales = String(request.headers['accept-language'] || '')
117
+ .split(',').map((part) => part.split(';')[0].trim()).filter(Boolean);
118
+ for (let localeIndex = 0; localeIndex < locales.length; localeIndex++)
119
+ candidates.push(locales[localeIndex]);
120
+ }
121
+ if (!candidates.some((candidate) => normalized === 'locale'
122
+ ? String(candidate).toLowerCase() === want.toLowerCase()
123
+ : String(candidate) === want))
124
+ return false;
125
+ }
126
+ return true;
127
+ }
128
+ function evaluateRule(request, rule) {
129
+ const all = rule.all || [];
130
+ const any = rule.any || [];
131
+ if (all.length + any.length === 0)
132
+ return false;
133
+ return all.every((condition) => evaluateCondition(request, condition))
134
+ && (any.length === 0 || any.some((condition) => evaluateCondition(request, condition)));
135
+ }
136
+ function evaluateCondition(request, condition) {
137
+ const values = signalValues(request, condition.field);
138
+ if (condition.operator === 'exists')
139
+ return values.length > 0;
140
+ if (values.length === 0 || !['string', 'number', 'boolean'].includes(typeof condition.value))
141
+ return false;
142
+ const expected = String(condition.value);
143
+ return values.some((value) => {
144
+ switch (condition.operator) {
145
+ case 'equals': return value === expected;
146
+ case 'contains': return value.includes(expected);
147
+ case 'startsWith': return value.startsWith(expected);
148
+ default: return false;
149
+ }
150
+ });
151
+ }
152
+ function signalValues(request, field) {
153
+ if (field.startsWith('query.'))
154
+ return request.query[field.slice(6)] || [];
155
+ if (field.startsWith('header.'))
156
+ return request.headers[field.slice(7).toLowerCase()] ? [request.headers[field.slice(7).toLowerCase()]] : [];
157
+ if (field.startsWith('cookie.'))
158
+ return request.cookies[field.slice(7)] ? [request.cookies[field.slice(7)]] : [];
159
+ if (field.startsWith('utm.'))
160
+ return request.query['utm_' + field.slice(4)] || [];
161
+ if (field.startsWith('geo.')) {
162
+ const geo = request.signals && request.signals.geo;
163
+ const value = geo && geo[field.slice(4).toLowerCase()];
164
+ return value ? [value] : [];
165
+ }
166
+ if (field === 'referrer') {
167
+ const value = request.headers.referer || request.headers.referrer;
168
+ return value ? [value] : [];
169
+ }
170
+ return [];
171
+ }
172
+ // SHA-256 mirrors experiment.assignment.go: the first eight digest bytes are
173
+ // interpreted big-endian and reduced modulo 10,000 basis points.
174
+ // CLOUDFRONT_STRIP_START — the build script removes this block for CloudFront
175
+ // Functions (experiment bucketing moves to the hosting Lambda to stay under
176
+ // the 10 KiB function size limit).
177
+ function sha256Bucket(value) {
178
+ const bytes = utf8(value);
179
+ const words = new Uint32Array(64);
180
+ const bitLength = bytes.length * 8;
181
+ const paddedLength = ((bytes.length + 9 + 63) >> 6) << 6;
182
+ const data = new Uint8Array(paddedLength);
183
+ data.set(bytes);
184
+ data[bytes.length] = 128;
185
+ // The message length is a 64-bit big-endian integer across the final
186
+ // eight bytes. The previous code wrote a single truncated byte into each
187
+ // word's most significant position, corrupting every digest (caught by
188
+ // the assignment parity vectors, spec experiment-variant-caching REQ-9).
189
+ const bitsHigh = Math.floor(bitLength / 0x100000000);
190
+ const bitsLow = bitLength >>> 0;
191
+ data[paddedLength - 8] = (bitsHigh >>> 24) & 255;
192
+ data[paddedLength - 7] = (bitsHigh >>> 16) & 255;
193
+ data[paddedLength - 6] = (bitsHigh >>> 8) & 255;
194
+ data[paddedLength - 5] = bitsHigh & 255;
195
+ data[paddedLength - 4] = (bitsLow >>> 24) & 255;
196
+ data[paddedLength - 3] = (bitsLow >>> 16) & 255;
197
+ data[paddedLength - 2] = (bitsLow >>> 8) & 255;
198
+ data[paddedLength - 1] = bitsLow & 255;
199
+ let a = 0x6a09e667, b = 0xbb67ae85, c = 0x3c6ef372, d = 0xa54ff53a;
200
+ let e = 0x510e527f, f = 0x9b05688c, g = 0x1f83d9ab, h = 0x5be0cd19;
201
+ const k = [0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2];
202
+ for (let offset = 0; offset < paddedLength; offset += 64) {
203
+ for (let i = 0; i < 16; i++) {
204
+ const p = offset + i * 4;
205
+ words[i] = (data[p] << 24) | (data[p + 1] << 16) | (data[p + 2] << 8) | data[p + 3];
206
+ }
207
+ for (let i = 16; i < 64; i++) {
208
+ const x = words[i - 15], y = words[i - 2];
209
+ words[i] = (words[i - 16] + (rotr(x, 7) ^ rotr(x, 18) ^ (x >>> 3)) + words[i - 7] + (rotr(y, 17) ^ rotr(y, 19) ^ (y >>> 10))) >>> 0;
210
+ }
211
+ let aa = a, bb = b, cc = c, dd = d, ee = e, ff = f, gg = g, hh = h;
212
+ for (let i = 0; i < 64; i++) {
213
+ const s1 = rotr(ee, 6) ^ rotr(ee, 11) ^ rotr(ee, 25);
214
+ const ch = (ee & ff) ^ (~ee & gg);
215
+ const t1 = (hh + s1 + ch + k[i] + words[i]) >>> 0;
216
+ const s0 = rotr(aa, 2) ^ rotr(aa, 13) ^ rotr(aa, 22);
217
+ const maj = (aa & bb) ^ (aa & cc) ^ (bb & cc);
218
+ const t2 = (s0 + maj) >>> 0;
219
+ hh = gg;
220
+ gg = ff;
221
+ ff = ee;
222
+ ee = (dd + t1) >>> 0;
223
+ dd = cc;
224
+ cc = bb;
225
+ bb = aa;
226
+ aa = (t1 + t2) >>> 0;
227
+ }
228
+ a = (a + aa) >>> 0;
229
+ b = (b + bb) >>> 0;
230
+ c = (c + cc) >>> 0;
231
+ d = (d + dd) >>> 0;
232
+ e = (e + ee) >>> 0;
233
+ f = (f + ff) >>> 0;
234
+ g = (g + gg) >>> 0;
235
+ h = (h + hh) >>> 0;
236
+ }
237
+ // Reduce the 64-bit big-endian value modulo 10000 in 32-bit halves:
238
+ // (hi*2^32 + lo) % 10000 == ((hi%10000) * (2^32 % 10000) + lo%10000) % 10000,
239
+ // and 2^32 % 10000 = 7296. Multiplying hi by 2^32 directly exceeds
240
+ // Number.MAX_SAFE_INTEGER and rounds away the low bits — that float error
241
+ // made most JS buckets diverge from assignment.go (caught by the
242
+ // assignment parity vectors, spec experiment-variant-caching REQ-9).
243
+ return (((a >>> 0) % 10000) * 7296 + ((b >>> 0) % 10000)) % 10000;
244
+ }
245
+ function rotr(value, amount) { return (value >>> amount) | (value << (32 - amount)); }
246
+ function utf8(value) {
247
+ const out = [];
248
+ for (let i = 0; i < value.length; i++) {
249
+ let code = value.charCodeAt(i);
250
+ if (code >= 0xd800 && code <= 0xdbff && i + 1 < value.length) {
251
+ const next = value.charCodeAt(++i);
252
+ code = 0x10000 + ((code - 0xd800) << 10) + (next - 0xdc00);
253
+ }
254
+ if (code < 0x80)
255
+ out.push(code);
256
+ else if (code < 0x800)
257
+ out.push(0xc0 | (code >> 6), 0x80 | (code & 63));
258
+ else if (code < 0x10000)
259
+ out.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 63), 0x80 | (code & 63));
260
+ else
261
+ out.push(0xf0 | (code >> 18), 0x80 | ((code >> 12) & 63), 0x80 | ((code >> 6) & 63), 0x80 | (code & 63));
262
+ }
263
+ return new Uint8Array(out);
264
+ }
265
+ // CLOUDFRONT_STRIP_END
@@ -0,0 +1,14 @@
1
+ import { type AssignmentsPayloadSource } from '../content/personalization.js';
2
+ export interface AssignmentsScriptProps {
3
+ page: AssignmentsPayloadSource | null | undefined;
4
+ }
5
+ /**
6
+ * AssignmentsScript renders the page-level experiment assignment tag
7
+ * (`<script type="application/json" data-aci-assignments>` with a
8
+ * {"v":1,"experiments":[…]} body, personalization-reporting design/04) when
9
+ * the page payload carries a build-time `experiments` block, and nothing
10
+ * otherwise. createPage appends it to the page tree exactly once per page;
11
+ * sites composing their own pages can render it directly. The JSON body is
12
+ * HTML-safe (assignmentsScriptJSON \u-escapes angle brackets).
13
+ */
14
+ export declare function AssignmentsScript({ page }: AssignmentsScriptProps): import("react").JSX.Element | null;
@@ -0,0 +1,17 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { assignmentsScriptJSON, } from '../content/personalization.js';
3
+ /**
4
+ * AssignmentsScript renders the page-level experiment assignment tag
5
+ * (`<script type="application/json" data-aci-assignments>` with a
6
+ * {"v":1,"experiments":[…]} body, personalization-reporting design/04) when
7
+ * the page payload carries a build-time `experiments` block, and nothing
8
+ * otherwise. createPage appends it to the page tree exactly once per page;
9
+ * sites composing their own pages can render it directly. The JSON body is
10
+ * HTML-safe (assignmentsScriptJSON \u-escapes angle brackets).
11
+ */
12
+ export function AssignmentsScript({ page }) {
13
+ const json = assignmentsScriptJSON(page);
14
+ if (!json)
15
+ return null;
16
+ return (_jsx("script", { type: "application/json", "data-aci-assignments": "", dangerouslySetInnerHTML: { __html: json } }));
17
+ }
@@ -0,0 +1,19 @@
1
+ import { type IdentitySigningKeys, type PersonalizationBlobStore } from './personalization-host.js';
2
+ export interface PersonalizationCollectRouteConfig {
3
+ siteId: string;
4
+ store: PersonalizationBlobStore;
5
+ identityKeys: IdentitySigningKeys;
6
+ /**
7
+ * Claims verify keys (kid -> secret) for withdrawal EVIDENCE
8
+ * (anonymous-experiments REQ-6): suppression fires only when the request
9
+ * presents a signature-verified aci_claims cookie — presence alone is
10
+ * spoofable, and under universal minting an unverified trigger would
11
+ * suppress every reject-all visitor. Empty = evidence never verifies =
12
+ * suppression disabled (fails safe).
13
+ */
14
+ claimsKeys?: Record<string, string>;
15
+ retentionSeconds?: number;
16
+ secureCookies?: boolean;
17
+ now?: () => Date;
18
+ }
19
+ export declare function createPersonalizationCollectRoute(config: PersonalizationCollectRouteConfig): (request: Request) => Promise<Response>;
@@ -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
+ }