@gradial/aci 0.1.42 → 0.1.43

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 (63) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +4 -4
  3. package/dist/audience-data-models.d.ts +286 -0
  4. package/dist/audience-data-models.js +46 -0
  5. package/dist/cli/compile-registry.d.mts +5 -1
  6. package/dist/cli/compile-registry.mjs +25 -10
  7. package/dist/cli/validate-content.d.mts +31 -1
  8. package/dist/cli/validate-content.mjs +50 -12
  9. package/dist/collect/aci.js +1 -0
  10. package/dist/collect/aci.js.sha256 +1 -0
  11. package/dist/collect/batcher.d.ts +2 -0
  12. package/dist/collect/batcher.js +18 -6
  13. package/dist/collect/consent/envelope.d.ts +9 -3
  14. package/dist/collect/consent/envelope.js +58 -40
  15. package/dist/collect/index.js +9 -2
  16. package/dist/collect/linkclicks.js +4 -2
  17. package/dist/collect/transport.d.ts +2 -0
  18. package/dist/collect/transport.js +6 -3
  19. package/dist/consent/portable-envelope.d.ts +9 -0
  20. package/dist/consent/portable-envelope.js +110 -0
  21. package/dist/consent-banner/index.js +2 -1
  22. package/dist/content/assets.d.ts +1 -0
  23. package/dist/content/assets.js +1 -0
  24. package/dist/content/resolve-slots.js +1 -0
  25. package/dist/content/types.d.ts +2 -0
  26. package/dist/next/collect-route.d.ts +2 -2
  27. package/dist/next/collect-route.js +90 -19
  28. package/dist/next/envelope-digest.d.ts +21 -0
  29. package/dist/next/envelope-digest.js +58 -0
  30. package/dist/next/middleware.d.ts +16 -2
  31. package/dist/next/middleware.js +190 -10
  32. package/dist/next/personalization-host.d.ts +16 -7
  33. package/dist/next/personalization-host.js +14 -33
  34. package/dist/next/server.d.ts +1 -0
  35. package/dist/next/server.js +3 -0
  36. package/dist/next/vercel-collect-sdk-route.d.ts +27 -0
  37. package/dist/next/vercel-collect-sdk-route.js +205 -0
  38. package/dist/next/vercel-personalization.d.ts +3 -2
  39. package/dist/next/vercel-personalization.js +3 -2
  40. package/dist/next/vercel-s3-personalization.d.ts +25 -4
  41. package/dist/next/vercel-s3-personalization.js +368 -14
  42. package/dist/providers/s3.d.ts +19 -4
  43. package/dist/providers/s3.js +92 -22
  44. package/dist/testing/conformance/checks.js +75 -4
  45. package/dist/types/block-ref.d.ts +2 -0
  46. package/dist/types/block-ref.js +1 -0
  47. package/package.json +27 -16
  48. package/src/cli/compile-registry.mjs +24 -10
  49. package/src/cli/validate-content.mjs +52 -13
  50. package/src/types/block-ref.test.ts +45 -0
  51. package/src/types/block-ref.ts +2 -0
  52. package/templates/astro/starter-content/src/fixtures/atomicBlocks.ts +7 -6
  53. package/templates/astro/starter-content/src/fixtures/composition.ts +3 -2
  54. package/templates/astro/starter-content/src/fixtures/sections.ts +2 -1
  55. package/templates/common/.agents/skills/authoring/SKILL.md +11 -3
  56. package/templates/common/.agents/skills/authoring/references/block-slots.md +3 -0
  57. package/templates/common/.content/pages/home/_index.json +10 -0
  58. package/templates/common/starter-content/.content/pages/about/_index.json +3 -0
  59. package/templates/common/starter-content/.content/pages/home/_index.json +9 -0
  60. package/templates/nextjs/starter-content/src/middleware.ts +31 -1
  61. package/templates/nextjs/template/src/app/collect/route.ts +2 -2
  62. package/templates/nextjs/template/src/middleware.ts +31 -1
  63. package/templates/nextjs/template/src/middleware.ts.tmpl +31 -1
@@ -0,0 +1,21 @@
1
+ export interface EnvelopeDigestInput {
2
+ siteId: string;
3
+ acceptedBody: Uint8Array;
4
+ identity: {
5
+ namespace: string;
6
+ hash: string;
7
+ };
8
+ consent: {
9
+ version: number;
10
+ purposes: readonly string[];
11
+ grantedAt?: string;
12
+ source?: string;
13
+ };
14
+ }
15
+ /**
16
+ * Computes the cross-runtime digest used to distinguish an identical
17
+ * envelope retry from reuse of the same envelope ID with different accepted
18
+ * inputs. Every field is framed with an unsigned 8-byte big-endian length so
19
+ * concatenation cannot create ambiguous inputs.
20
+ */
21
+ export declare function computeEnvelopeDigest(input: EnvelopeDigestInput): Promise<string>;
@@ -0,0 +1,58 @@
1
+ const ENVELOPE_DIGEST_ALGORITHM = 'aci-envelope-digest-v1';
2
+ const RFC3339 = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|[+-]\d{2}:\d{2})$/;
3
+ const encoder = new TextEncoder();
4
+ const CONSENT_EDGE_WHITESPACE = /^[\t\n\v\f\r ]+|[\t\n\v\f\r ]+$/g;
5
+ /**
6
+ * Computes the cross-runtime digest used to distinguish an identical
7
+ * envelope retry from reuse of the same envelope ID with different accepted
8
+ * inputs. Every field is framed with an unsigned 8-byte big-endian length so
9
+ * concatenation cannot create ambiguous inputs.
10
+ */
11
+ export async function computeEnvelopeDigest(input) {
12
+ if (!Number.isInteger(input.consent.version)) {
13
+ throw new Error('consent version must be an integer');
14
+ }
15
+ const purposes = input.consent.purposes
16
+ .map((purpose) => trimConsentWhitespace(purpose).toLowerCase())
17
+ .sort();
18
+ const fields = [
19
+ encoder.encode(ENVELOPE_DIGEST_ALGORITHM),
20
+ encoder.encode(input.siteId.trim()),
21
+ input.acceptedBody,
22
+ encoder.encode(input.identity.namespace.trim()),
23
+ encoder.encode(input.identity.hash.trim()),
24
+ encoder.encode(String(input.consent.version)),
25
+ encoder.encode(String(purposes.length)),
26
+ ...purposes.map((purpose) => encoder.encode(purpose)),
27
+ encoder.encode(canonicalGrantedAt(input.consent.grantedAt || '')),
28
+ encoder.encode(trimConsentWhitespace(input.consent.source || '')),
29
+ ];
30
+ const byteLength = fields.reduce((total, field) => total + 8 + field.byteLength, 0);
31
+ const framed = new Uint8Array(byteLength);
32
+ const view = new DataView(framed.buffer);
33
+ let offset = 0;
34
+ for (const field of fields) {
35
+ view.setBigUint64(offset, BigInt(field.byteLength), false);
36
+ offset += 8;
37
+ framed.set(field, offset);
38
+ offset += field.byteLength;
39
+ }
40
+ const digest = await globalThis.crypto.subtle.digest('SHA-256', framed);
41
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');
42
+ }
43
+ function canonicalGrantedAt(value) {
44
+ const clean = trimConsentWhitespace(value);
45
+ if (!clean)
46
+ return '';
47
+ const match = RFC3339.exec(clean);
48
+ const epochMillis = Date.parse(clean);
49
+ if (!match || Number.isNaN(epochMillis)) {
50
+ throw new Error('consent grantedAt must be an RFC3339 timestamp');
51
+ }
52
+ const fraction = (match[7] || '').replace(/0+$/, '');
53
+ const seconds = new Date(epochMillis).toISOString().slice(0, 19);
54
+ return `${seconds}${fraction ? `.${fraction}` : ''}Z`;
55
+ }
56
+ function trimConsentWhitespace(value) {
57
+ return value.replace(CONSENT_EDGE_WHITESPACE, '');
58
+ }
@@ -6,6 +6,8 @@ export interface GradialMiddlewareConfig {
6
6
  previewSignKey?: string;
7
7
  deploymentId?: string;
8
8
  deploymentProtectionBypass?: string;
9
+ /** Site-scoped token shared only with the outer ACI framework proxy. */
10
+ frameworkOriginAuthToken?: string;
9
11
  personalization?: Omit<PersonalizationHostConfig, 'siteId'> & {
10
12
  siteId?: string;
11
13
  };
@@ -19,8 +21,20 @@ export declare function createGradialMiddleware(config?: GradialMiddlewareConfig
19
21
  * cache-keyed query parameter, not in cookies.
20
22
  */
21
23
  export declare function enforceCookieCacheRule(response: NextResponse): void;
22
- export declare const aciMiddlewareMatcher: string[];
24
+ export declare const aciMiddlewareMatcher: (string | {
25
+ source: string;
26
+ has: {
27
+ type: string;
28
+ key: string;
29
+ }[];
30
+ })[];
23
31
  export declare const config: {
24
32
  runtime: string;
25
- matcher: string[];
33
+ matcher: (string | {
34
+ source: string;
35
+ has: {
36
+ type: string;
37
+ key: string;
38
+ }[];
39
+ })[];
26
40
  };
@@ -1,14 +1,40 @@
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
+ import { CONTENT_PATH_HEADER, CONTENT_PATH_CACHE_PARAM, resolvePersonalizationRequest, } from './personalization-host.js';
5
5
  const RELEASE_SCOPED_PREFIX = '/__r';
6
+ const FRAMEWORK_ORIGIN_AUTH_HEADER = 'x-gradial-framework-origin-auth';
7
+ const FRAMEWORK_SITE_ID_HEADER = 'x-baremetal-site-id';
8
+ const FRAMEWORK_RELEASE_ID_HEADER = 'x-baremetal-release-id';
9
+ const FRAMEWORK_CONTENT_REF_HEADER = 'x-aci-content-ref';
10
+ const FRAMEWORK_CONTENT_BASE_URL_HEADER = 'x-baremetal-content-base-url';
11
+ const textEncoder = new TextEncoder();
6
12
  const CMS_ASSET_PATHS = new Set(['/robots.txt', '/sitemap.xml']);
13
+ const FRAMEWORK_RUNTIME_PATHS = new Set(['/favicon.ico', '/_health']);
14
+ const FRAMEWORK_RUNTIME_PREFIXES = [
15
+ '/_next',
16
+ '/api',
17
+ '/collect',
18
+ '/gradial/assets',
19
+ '/.gradial-dam',
20
+ ];
7
21
  const PUBLIC_PATHS = new Set(typeof __ACI_PUBLIC_PATHS__ !== 'undefined' ? __ACI_PUBLIC_PATHS__ : []);
8
22
  const PASSTHROUGH_ROUTES = (typeof __ACI_PASSTHROUGH_ROUTES__ !== 'undefined' ? __ACI_PASSTHROUGH_ROUTES__ : []);
9
23
  export function createGradialMiddleware(config = {}) {
10
24
  return async function middleware(request) {
11
25
  const url = request.nextUrl;
26
+ const frameworkDispatch = await resolveFrameworkDispatch(request, config);
27
+ if (frameworkDispatch.kind === 'rejected') {
28
+ return frameworkDispatch.response;
29
+ }
30
+ // The outer framework proxy authenticates every request with reserved
31
+ // dispatch headers, including Next/API/static runtime paths. Match those
32
+ // paths so the handoff is validated and reserved headers are stripped.
33
+ // Document rewrites retain only the validated, canonical content-path
34
+ // query because Vercel uses it as the selected payload's cache identity.
35
+ if (isFrameworkRuntimePath(url.pathname)) {
36
+ return passThroughResponse(request, frameworkDispatch);
37
+ }
12
38
  // ?leave_preview=1 → clear cookies, redirect to clean URL
13
39
  if (url.searchParams.get('leave_preview') === '1') {
14
40
  const cleanUrl = new URL(url.pathname, url.origin);
@@ -18,13 +44,13 @@ export function createGradialMiddleware(config = {}) {
18
44
  return response;
19
45
  }
20
46
  if (PUBLIC_PATHS.has(url.pathname)) {
21
- return NextResponse.next();
47
+ return passThroughResponse(request, frameworkDispatch);
22
48
  }
23
49
  if (CMS_ASSET_PATHS.has(url.pathname)) {
24
- return NextResponse.next();
50
+ return passThroughResponse(request, frameworkDispatch);
25
51
  }
26
52
  if (isPassthroughRoute(url.pathname)) {
27
- return NextResponse.next();
53
+ return passThroughResponse(request, frameworkDispatch);
28
54
  }
29
55
  // Block direct access to /__r/... — only middleware rewrites may use it.
30
56
  if (isReleaseScopedPath(url.pathname, RELEASE_SCOPED_PREFIX)) {
@@ -33,6 +59,14 @@ export function createGradialMiddleware(config = {}) {
33
59
  headers: { 'Cache-Control': 'private, no-store' },
34
60
  });
35
61
  }
62
+ if (frameworkDispatch.kind === 'trusted') {
63
+ const response = rewriteToReleaseScopedPath(request, frameworkDispatch.releaseId, false, {
64
+ contentPath: frameworkDispatch.contentPath,
65
+ requestHeaders: frameworkRequestHeaders(request, frameworkDispatch.contentPath),
66
+ });
67
+ enforceCookieCacheRule(response);
68
+ return response;
69
+ }
36
70
  // Try preview token first; if valid, use that release.
37
71
  // If no token or invalid, fall through to active release from edge config.
38
72
  const preview = await resolvePreviewFromRequest(request, config);
@@ -105,7 +139,12 @@ export function createGradialMiddleware(config = {}) {
105
139
  });
106
140
  }
107
141
  }
108
- const response = rewriteToReleaseScopedPath(request, releaseId, Boolean(preview?.isPreview), personalization);
142
+ const response = rewriteToReleaseScopedPath(request, releaseId, Boolean(preview?.isPreview), personalization
143
+ ? {
144
+ contentPath: personalization.contentPath,
145
+ requestHeaders: personalization.requestHeaders,
146
+ }
147
+ : undefined);
109
148
  // Persist preview token as cookie so navigation stays in preview
110
149
  if (preview?.token && preview.isPreview) {
111
150
  for (const cookie of createPreviewCookies(preview.token, releaseId)) {
@@ -144,15 +183,15 @@ export function enforceCookieCacheRule(response) {
144
183
  response.headers.delete('CDN-Cache-Control');
145
184
  response.headers.delete('Vercel-CDN-Cache-Control');
146
185
  }
147
- function rewriteToReleaseScopedPath(request, releaseId, isPreview, personalization) {
186
+ function rewriteToReleaseScopedPath(request, releaseId, isPreview, options) {
148
187
  const rewriteURL = new URL(request.url);
149
188
  rewriteURL.pathname = releaseScopedPath(RELEASE_SCOPED_PREFIX, releaseId, request.nextUrl.pathname);
150
189
  rewriteURL.searchParams.delete(CONTENT_PATH_CACHE_PARAM);
151
- if (personalization?.contentPath) {
152
- rewriteURL.searchParams.set(CONTENT_PATH_CACHE_PARAM, personalization.contentPath);
190
+ if (options?.contentPath) {
191
+ rewriteURL.searchParams.set(CONTENT_PATH_CACHE_PARAM, options.contentPath);
153
192
  }
154
- const response = personalization
155
- ? NextResponse.rewrite(rewriteURL, { request: { headers: personalization.requestHeaders } })
193
+ const response = options?.requestHeaders
194
+ ? NextResponse.rewrite(rewriteURL, { request: { headers: options.requestHeaders } })
156
195
  : NextResponse.rewrite(rewriteURL);
157
196
  if (isPreview) {
158
197
  response.headers.set('Cache-Control', 'private, no-store');
@@ -164,6 +203,113 @@ function rewriteToReleaseScopedPath(request, releaseId, isPreview, personalizati
164
203
  }
165
204
  return response;
166
205
  }
206
+ async function resolveFrameworkDispatch(request, config) {
207
+ // The outer edge has already minted identity and selected a release/payload.
208
+ // A trusted handoff must use that exact decision; falling through would mint
209
+ // a second identity and may select a different experiment arm.
210
+ const originAuth = request.headers.get(FRAMEWORK_ORIGIN_AUTH_HEADER);
211
+ const forwardedSiteId = request.headers.get(FRAMEWORK_SITE_ID_HEADER);
212
+ const forwardedReleaseId = request.headers.get(FRAMEWORK_RELEASE_ID_HEADER);
213
+ const forwardedContentRef = request.headers.get(FRAMEWORK_CONTENT_REF_HEADER);
214
+ const contentPathValues = request.nextUrl.searchParams.getAll(CONTENT_PATH_CACHE_PARAM);
215
+ const hasReservedDispatch = originAuth !== null
216
+ || forwardedSiteId !== null
217
+ || forwardedReleaseId !== null
218
+ || forwardedContentRef !== null
219
+ || request.headers.has(FRAMEWORK_CONTENT_BASE_URL_HEADER)
220
+ || request.headers.has(CONTENT_PATH_HEADER)
221
+ || contentPathValues.length > 0;
222
+ if (!hasReservedDispatch) {
223
+ return { kind: 'direct' };
224
+ }
225
+ const expectedOriginAuth = config.frameworkOriginAuthToken
226
+ || process.env.ACI_FRAMEWORK_ORIGIN_AUTH_TOKEN
227
+ || '';
228
+ if (!expectedOriginAuth
229
+ || originAuth === null
230
+ || !(await secureStringEqual(expectedOriginAuth, originAuth))) {
231
+ return rejectedFrameworkDispatch(403, 'framework dispatch forbidden');
232
+ }
233
+ if (!forwardedSiteId || !forwardedReleaseId) {
234
+ return rejectedFrameworkDispatch(400, 'invalid framework dispatch');
235
+ }
236
+ const expectedSiteId = siteIdForConfig(config);
237
+ if (!expectedSiteId || forwardedSiteId !== expectedSiteId) {
238
+ return rejectedFrameworkDispatch(403, 'framework dispatch forbidden');
239
+ }
240
+ if (forwardedContentRef === null && contentPathValues.length === 0) {
241
+ return {
242
+ kind: 'trusted',
243
+ releaseId: forwardedReleaseId,
244
+ contentPath: '',
245
+ };
246
+ }
247
+ if (!forwardedContentRef
248
+ || contentPathValues.length !== 1
249
+ || contentPathValues[0] !== forwardedContentRef) {
250
+ return rejectedFrameworkDispatch(400, 'invalid framework dispatch');
251
+ }
252
+ return {
253
+ kind: 'trusted',
254
+ releaseId: forwardedReleaseId,
255
+ contentPath: forwardedContentRef,
256
+ };
257
+ }
258
+ function passThroughResponse(request, frameworkDispatch) {
259
+ if (frameworkDispatch.kind !== 'trusted') {
260
+ return NextResponse.next();
261
+ }
262
+ if (frameworkDispatch.contentPath) {
263
+ return new Response('invalid framework dispatch', {
264
+ status: 400,
265
+ headers: { 'Cache-Control': 'private, no-store' },
266
+ });
267
+ }
268
+ return NextResponse.next({
269
+ request: {
270
+ headers: sanitizedFrameworkRequestHeaders(request),
271
+ },
272
+ });
273
+ }
274
+ function frameworkRequestHeaders(request, contentPath) {
275
+ const requestHeaders = sanitizedFrameworkRequestHeaders(request);
276
+ if (contentPath) {
277
+ requestHeaders.set(CONTENT_PATH_HEADER, contentPath);
278
+ }
279
+ return requestHeaders;
280
+ }
281
+ function sanitizedFrameworkRequestHeaders(request) {
282
+ const requestHeaders = new Headers(request.headers);
283
+ requestHeaders.delete(FRAMEWORK_ORIGIN_AUTH_HEADER);
284
+ requestHeaders.delete(FRAMEWORK_SITE_ID_HEADER);
285
+ requestHeaders.delete(FRAMEWORK_RELEASE_ID_HEADER);
286
+ requestHeaders.delete(FRAMEWORK_CONTENT_REF_HEADER);
287
+ requestHeaders.delete(FRAMEWORK_CONTENT_BASE_URL_HEADER);
288
+ requestHeaders.delete(CONTENT_PATH_HEADER);
289
+ return requestHeaders;
290
+ }
291
+ function rejectedFrameworkDispatch(status, message) {
292
+ return {
293
+ kind: 'rejected',
294
+ response: new Response(message, {
295
+ status,
296
+ headers: { 'Cache-Control': 'private, no-store' },
297
+ }),
298
+ };
299
+ }
300
+ async function secureStringEqual(expected, actual) {
301
+ const [expectedDigest, actualDigest] = await Promise.all([
302
+ crypto.subtle.digest('SHA-256', textEncoder.encode(expected)),
303
+ crypto.subtle.digest('SHA-256', textEncoder.encode(actual)),
304
+ ]);
305
+ const expectedBytes = new Uint8Array(expectedDigest);
306
+ const actualBytes = new Uint8Array(actualDigest);
307
+ let different = 0;
308
+ for (let index = 0; index < expectedBytes.length; index += 1) {
309
+ different |= expectedBytes[index] ^ actualBytes[index];
310
+ }
311
+ return different === 0;
312
+ }
167
313
  function isReleaseScopedPath(pathname, prefix) {
168
314
  return pathname === prefix || pathname.startsWith(`${prefix}/`);
169
315
  }
@@ -296,6 +442,12 @@ function edgeKeySegment(value) {
296
442
  function isPassthroughRoute(pathname) {
297
443
  return PASSTHROUGH_ROUTES.some((route) => routeMatches(pathname, route));
298
444
  }
445
+ function isFrameworkRuntimePath(pathname) {
446
+ if (FRAMEWORK_RUNTIME_PATHS.has(pathname)) {
447
+ return true;
448
+ }
449
+ return FRAMEWORK_RUNTIME_PREFIXES.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`));
450
+ }
299
451
  function routeMatches(pathname, route) {
300
452
  const cleanPath = normalizeRoutePath(pathname);
301
453
  const cleanRoute = normalizeRoutePath(route);
@@ -320,6 +472,34 @@ function normalizeRoutePath(value) {
320
472
  }
321
473
  export const aciMiddlewareMatcher = [
322
474
  '/((?!_next(?:/|$)|api(?:/|$)|collect(?:/|$)|gradial/assets(?:/|$)|favicon\\.ico$).*)',
475
+ {
476
+ source: '/:path*',
477
+ has: [{ type: 'header', key: 'x-gradial-framework-origin-auth' }],
478
+ },
479
+ {
480
+ source: '/:path*',
481
+ has: [{ type: 'header', key: 'x-baremetal-site-id' }],
482
+ },
483
+ {
484
+ source: '/:path*',
485
+ has: [{ type: 'header', key: 'x-baremetal-release-id' }],
486
+ },
487
+ {
488
+ source: '/:path*',
489
+ has: [{ type: 'header', key: 'x-aci-content-ref' }],
490
+ },
491
+ {
492
+ source: '/:path*',
493
+ has: [{ type: 'header', key: 'x-baremetal-content-base-url' }],
494
+ },
495
+ {
496
+ source: '/:path*',
497
+ has: [{ type: 'header', key: 'x-aci-content-path' }],
498
+ },
499
+ {
500
+ source: '/:path*',
501
+ has: [{ type: 'query', key: '__aci_content_path' }],
502
+ },
323
503
  ];
324
504
  export const config = {
325
505
  runtime: 'nodejs',
@@ -1,4 +1,5 @@
1
1
  import { type PersonalizationExperiment } from '../edge/personalization-core.js';
2
+ import { type PortableConsentSnapshot } from '../consent/portable-envelope.js';
2
3
  export declare const CONTENT_PATH_HEADER = "x-aci-content-path";
3
4
  export declare const CONTENT_PATH_CACHE_PARAM = "__aci_content_path";
4
5
  export interface EdgeDispatch {
@@ -37,6 +38,19 @@ export interface EdgeRouteManifest {
37
38
  notFoundPage?: string;
38
39
  errorPage?: string;
39
40
  }
41
+ export interface PersonalizationEnvelopeWrite {
42
+ key: string;
43
+ envelopeId: string;
44
+ siteId: string;
45
+ identityNamespace: string;
46
+ identityHash: string;
47
+ records: readonly unknown[];
48
+ digest: string;
49
+ }
50
+ export declare class IdentityErasureFencedError extends Error {
51
+ readonly name = "IdentityErasureFencedError";
52
+ constructor();
53
+ }
40
54
  /** Immutable release routing state read by an EdgeRouter. */
41
55
  export interface PersonalizationRouteStore {
42
56
  readEdgeManifest(releaseId: string): Promise<EdgeRouteManifest | null>;
@@ -44,7 +58,7 @@ export interface PersonalizationRouteStore {
44
58
  /** Customer-owned collection state. This is deliberately not an EdgeRouter dependency. */
45
59
  export interface PersonalizationCollectionStore {
46
60
  readClaims(siteId: string, namespace: string, identityHash: string): Promise<string | null>;
47
- appendEvent(key: string, record: unknown): Promise<void>;
61
+ appendEnvelope(write: PersonalizationEnvelopeWrite): Promise<void>;
48
62
  putSuppression(key: string, record: unknown): Promise<void>;
49
63
  }
50
64
  /** @deprecated Use the narrower router or collection store at each boundary. */
@@ -91,12 +105,7 @@ export interface PersonalizationResolution {
91
105
  };
92
106
  cookies: CookieMutation[];
93
107
  }
94
- export interface ConsentSnapshot {
95
- version: number;
96
- purposes: string[];
97
- grantedAt: string;
98
- source?: string;
99
- }
108
+ export type ConsentSnapshot = PortableConsentSnapshot;
100
109
  interface ClaimsPayload {
101
110
  aud?: string[];
102
111
  seg?: string[];
@@ -1,9 +1,16 @@
1
1
  import { resolvePersonalizationDecision, } from '../edge/personalization-core.js';
2
+ import { parsePortableConsentEnvelope, portableConsentAllowsPersonalization, trimPortableConsentWhitespace, } from '../consent/portable-envelope.js';
2
3
  const DEFAULT_IDENTITY_TTL_SECONDS = 90 * 24 * 60 * 60;
3
4
  const DEFAULT_CLAIMS_TTL_SECONDS = 24 * 60 * 60;
4
5
  const textEncoder = new TextEncoder();
5
6
  export const CONTENT_PATH_HEADER = 'x-aci-content-path';
6
7
  export const CONTENT_PATH_CACHE_PARAM = '__aci_content_path';
8
+ export class IdentityErasureFencedError extends Error {
9
+ name = 'IdentityErasureFencedError';
10
+ constructor() {
11
+ super('identity collection is fenced by erasure');
12
+ }
13
+ }
7
14
  export async function resolvePersonalizationRequest(request, releaseId, config) {
8
15
  const requestHeaders = new Headers(request.headers);
9
16
  const cookies = parseCookieHeader(request.headers.get('cookie'));
@@ -85,9 +92,13 @@ export function consentSignals(request, cookies) {
85
92
  const privacyDenied = request.headers.get('sec-gpc')?.trim() === '1'
86
93
  || request.headers.get('dnt')?.trim() === '1'
87
94
  || ['deny', 'opt-out', 'off'].includes((request.headers.get('x-gradial-privacy-override') || '').trim().toLowerCase());
88
- const raw = request.headers.get('x-aci-consent') || cookies.get('aci_consent') || '';
89
- const snapshot = parseConsent(raw);
90
- const allows = snapshot?.purposes.some((purpose) => purpose.toLowerCase() === 'personalization') || false;
95
+ const headerConsent = request.headers.get('x-aci-consent');
96
+ const hasHeaderConsent = trimPortableConsentWhitespace(headerConsent || '') !== '';
97
+ const raw = hasHeaderConsent
98
+ ? headerConsent
99
+ : cookies.get('aci_consent') || '';
100
+ const snapshot = parsePortableConsentEnvelope(raw, !hasHeaderConsent);
101
+ const allows = portableConsentAllowsPersonalization(snapshot);
91
102
  return {
92
103
  hasConsent: snapshot !== null,
93
104
  snapshot,
@@ -124,36 +135,6 @@ export async function sha256Hex(value) {
124
135
  export async function identityHashHex(namespace, value) {
125
136
  return sha256Hex(`${namespace.trim().toLowerCase()}\x00${value.trim()}`);
126
137
  }
127
- function parseConsent(raw) {
128
- let decoded = raw.trim();
129
- if (!decoded)
130
- return null;
131
- try {
132
- decoded = decodeURIComponent(decoded);
133
- }
134
- catch {
135
- // Evaluate the original value.
136
- }
137
- const legacy = new Set(['true', 'all', 'granted', 'yes', 'personalization', 'personalized']);
138
- if (legacy.has(decoded.toLowerCase())) {
139
- return { version: 0, purposes: ['personalization'], grantedAt: '' };
140
- }
141
- try {
142
- const parsed = JSON.parse(decoded);
143
- if (!Number.isInteger(parsed.v) || !Array.isArray(parsed.p) || !parsed.p.every((item) => typeof item === 'string') || typeof parsed.t !== 'string') {
144
- return null;
145
- }
146
- return {
147
- version: parsed.v,
148
- purposes: parsed.p,
149
- grantedAt: parsed.t,
150
- source: typeof parsed.src === 'string' ? parsed.src : undefined,
151
- };
152
- }
153
- catch {
154
- return null;
155
- }
156
- }
157
138
  function matchRoute(pathname, manifest) {
158
139
  const normalized = normalizePath(pathname);
159
140
  if (manifest.routes[normalized])
@@ -9,6 +9,7 @@ export interface FetchConfig {
9
9
  accessKeyId?: string;
10
10
  secretAccessKey?: string;
11
11
  sessionToken?: string;
12
+ roleArn?: string;
12
13
  endpoint?: string;
13
14
  releaseId?: string;
14
15
  edgeConfig?: string;
@@ -135,6 +135,7 @@ export async function getReleaseAssetResponse(releaseId, assetPath, config = {})
135
135
  accessKeyId: config.accessKeyId,
136
136
  secretAccessKey: config.secretAccessKey,
137
137
  sessionToken: config.sessionToken,
138
+ roleArn: config.roleArn,
138
139
  endpoint: config.endpoint,
139
140
  });
140
141
  const key = joinKey(provider.config.keyPrefix, 'assets', clean);
@@ -179,6 +180,7 @@ export async function getDamAssetResponse(assetPath, config = {}) {
179
180
  accessKeyId: config.accessKeyId,
180
181
  secretAccessKey: config.secretAccessKey,
181
182
  sessionToken: config.sessionToken,
183
+ roleArn: config.roleArn,
182
184
  endpoint: config.endpoint,
183
185
  });
184
186
  const key = joinKey(provider.config.keyPrefix, clean);
@@ -235,6 +237,7 @@ async function resolveS3Provider(config) {
235
237
  accessKeyId: config.accessKeyId,
236
238
  secretAccessKey: config.secretAccessKey,
237
239
  sessionToken: config.sessionToken,
240
+ roleArn: config.roleArn,
238
241
  endpoint: config.endpoint,
239
242
  });
240
243
  // Pre-warm the manifest with cached version
@@ -0,0 +1,27 @@
1
+ export interface VercelCollectSDKRouteConfig {
2
+ siteId: string;
3
+ edgeConfig: string;
4
+ region: string;
5
+ roleArn: string;
6
+ hostingBucket: string;
7
+ hostingKeyPrefix?: string;
8
+ }
9
+ export interface VercelCollectSDKRouteDependencies {
10
+ s3Client?: VercelCollectSDKS3Client;
11
+ readEdgeConfig?: (connection: string, key: string) => Promise<unknown>;
12
+ onError?: (error: unknown) => void;
13
+ }
14
+ export interface VercelCollectSDKS3Client {
15
+ send(command: {
16
+ input: unknown;
17
+ }): Promise<unknown>;
18
+ }
19
+ export type VercelCollectSDKRoute = (request: Request) => Promise<Response>;
20
+ /**
21
+ * Creates the Node-only stable collect-SDK route used by ACI's injected
22
+ * Vercel Function. The provider pointer selects the immutable release on every
23
+ * uncached invocation, so an old code deployment remains correct while a
24
+ * canonical activation converges through Edge Config and deployment promotion.
25
+ */
26
+ export declare function createVercelCollectSDKRoute(input: VercelCollectSDKRouteConfig, dependencies?: VercelCollectSDKRouteDependencies): VercelCollectSDKRoute;
27
+ export declare function createVercelCollectSDKRouteFromEnv(env?: Record<string, string | undefined>, dependencies?: VercelCollectSDKRouteDependencies): VercelCollectSDKRoute;