@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
@@ -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)
@@ -0,0 +1,118 @@
1
+ import { type PersonalizationExperiment } from '../edge/personalization-core.js';
2
+ export declare const CONTENT_PATH_HEADER = "x-aci-content-path";
3
+ export declare const CONTENT_PATH_CACHE_PARAM = "__aci_content_path";
4
+ export interface EdgeDispatch {
5
+ kind: 'static-direct' | 'compose-origin' | 'framework-origin' | string;
6
+ artifactPath?: string;
7
+ contentPath?: string;
8
+ }
9
+ export interface EdgeRuleCondition {
10
+ field: string;
11
+ operator: 'exists' | 'equals' | 'contains' | 'startsWith' | string;
12
+ value?: unknown;
13
+ }
14
+ export interface EdgeRuleExpression {
15
+ all?: EdgeRuleCondition[];
16
+ any?: EdgeRuleCondition[];
17
+ }
18
+ export interface EdgeRouteVariant {
19
+ variantKey: string;
20
+ variantValues?: Record<string, string>;
21
+ priority?: number;
22
+ rule?: EdgeRuleExpression;
23
+ dispatch: EdgeDispatch;
24
+ }
25
+ export interface EdgeRouteEntry {
26
+ pattern: string;
27
+ priority: number;
28
+ dispatch: EdgeDispatch;
29
+ variants?: Record<string, EdgeRouteVariant>;
30
+ }
31
+ export interface EdgeRouteManifest {
32
+ manifestVersion: string;
33
+ releaseId: string;
34
+ siteId: string;
35
+ routes: Record<string, EdgeRouteEntry>;
36
+ experiments?: Record<string, PersonalizationExperiment>;
37
+ notFoundPage?: string;
38
+ errorPage?: string;
39
+ }
40
+ /** Immutable release routing state read by an EdgeRouter. */
41
+ export interface PersonalizationRouteStore {
42
+ readEdgeManifest(releaseId: string): Promise<EdgeRouteManifest | null>;
43
+ }
44
+ /** Customer-owned collection state. This is deliberately not an EdgeRouter dependency. */
45
+ export interface PersonalizationCollectionStore {
46
+ readClaims(siteId: string, namespace: string, identityHash: string): Promise<string | null>;
47
+ appendEvent(key: string, record: unknown): Promise<void>;
48
+ putSuppression(key: string, record: unknown): Promise<void>;
49
+ }
50
+ /** @deprecated Use the narrower router or collection store at each boundary. */
51
+ export interface PersonalizationBlobStore extends PersonalizationRouteStore, PersonalizationCollectionStore {
52
+ }
53
+ export interface SigningKey {
54
+ kid: string;
55
+ secret: string;
56
+ }
57
+ export interface IdentitySigningKeys {
58
+ current: SigningKey;
59
+ previous?: SigningKey;
60
+ }
61
+ export interface PersonalizationHostConfig {
62
+ siteId: string;
63
+ store: PersonalizationRouteStore;
64
+ identityKeys: IdentitySigningKeys;
65
+ claimsKeys?: Record<string, string>;
66
+ identityCookieTTLSeconds?: number;
67
+ secureCookies?: boolean;
68
+ now?: () => Date;
69
+ randomBytes?: (length: number) => Uint8Array;
70
+ }
71
+ export interface CookieMutation {
72
+ name: string;
73
+ value: string;
74
+ options: {
75
+ path: '/';
76
+ httpOnly?: boolean;
77
+ secure?: boolean;
78
+ sameSite: 'lax';
79
+ maxAge: number;
80
+ expires?: Date;
81
+ };
82
+ }
83
+ export interface PersonalizationResolution {
84
+ requestHeaders: Headers;
85
+ contentPath: string;
86
+ variantKey: string;
87
+ experiment?: {
88
+ id: string;
89
+ arm: string;
90
+ allocationVersion: string;
91
+ };
92
+ cookies: CookieMutation[];
93
+ }
94
+ export interface ConsentSnapshot {
95
+ version: number;
96
+ purposes: string[];
97
+ grantedAt: string;
98
+ source?: string;
99
+ }
100
+ interface ClaimsPayload {
101
+ aud?: string[];
102
+ seg?: string[];
103
+ iat: number;
104
+ exp: number;
105
+ }
106
+ export declare function resolvePersonalizationRequest(request: Request, releaseId: string, config: PersonalizationHostConfig): Promise<PersonalizationResolution>;
107
+ export declare function consentSignals(request: Request, cookies: Map<string, string>): {
108
+ hasConsent: boolean;
109
+ snapshot: ConsentSnapshot | null;
110
+ allowsIdentityTier: boolean;
111
+ privacyDenied: boolean;
112
+ };
113
+ export declare function parseCookieHeader(value: string | null): Map<string, string>;
114
+ export declare function verifyIdentityCookie(value: string, keys: IdentitySigningKeys): Promise<string | null>;
115
+ export declare function sha256Hex(value: string): Promise<string>;
116
+ export declare function identityHashHex(namespace: string, value: string): Promise<string>;
117
+ export declare function verifyClaims(value: string, keys: Record<string, string>, now: Date): Promise<ClaimsPayload | null>;
118
+ export {};
@@ -0,0 +1,276 @@
1
+ import { resolvePersonalizationDecision, } from '../edge/personalization-core.js';
2
+ const DEFAULT_IDENTITY_TTL_SECONDS = 90 * 24 * 60 * 60;
3
+ const DEFAULT_CLAIMS_TTL_SECONDS = 24 * 60 * 60;
4
+ const textEncoder = new TextEncoder();
5
+ export const CONTENT_PATH_HEADER = 'x-aci-content-path';
6
+ export const CONTENT_PATH_CACHE_PARAM = '__aci_content_path';
7
+ export async function resolvePersonalizationRequest(request, releaseId, config) {
8
+ const requestHeaders = new Headers(request.headers);
9
+ const cookies = parseCookieHeader(request.headers.get('cookie'));
10
+ const consent = consentSignals(request, cookies);
11
+ const mutations = [];
12
+ const now = (config.now ?? (() => new Date()))();
13
+ const secure = config.secureCookies ?? new URL(request.url).protocol === 'https:';
14
+ // Universal minting (spec anonymous-experiments REQ-1 / ADR 0016):
15
+ // identity existence is privacy-signal gated, never consent gated.
16
+ // GPC/DNT deny FULLY — no mint, no resolve, even with a valid cookie or a
17
+ // stored granting envelope (D-2). An unverifiable cookie self-heals by
18
+ // re-minting (REQ-4). aci_m is issue-only (D-11): a resolve-path refresh
19
+ // would put a Set-Cookie on every returning visitor's response, defeating
20
+ // the any-Set-Cookie => private, no-store cache rule.
21
+ let rawIdentity = consent.privacyDenied
22
+ ? null
23
+ : await verifyIdentityCookie(cookies.get('aci_id') || '', config.identityKeys);
24
+ if (!consent.privacyDenied && !rawIdentity) {
25
+ rawIdentity = randomIdentity(config.randomBytes);
26
+ const signed = await signIdentity(rawIdentity, config.identityKeys.current);
27
+ const maxAge = config.identityCookieTTLSeconds ?? DEFAULT_IDENTITY_TTL_SECONDS;
28
+ mutations.push(cookieMutation('aci_id', signed, maxAge, now, secure, true));
29
+ mutations.push(cookieMutation('aci_m', '1', maxAge, now, secure, false));
30
+ }
31
+ // The identity hash feeds experiment bucketing for EVERY identified
32
+ // visitor. Claims are a signed cookie written by /collect; the EdgeRouter
33
+ // never reads the customer object store to refresh or revoke them.
34
+ let signedClaims = cookies.get('aci_claims') || '';
35
+ let identityHash = '';
36
+ if (rawIdentity) {
37
+ identityHash = await identityHashHex('aci_id', rawIdentity);
38
+ }
39
+ if (rawIdentity && consent.allowsIdentityTier && signedClaims) {
40
+ if (!await verifyClaims(signedClaims, config.claimsKeys || {}, now)) {
41
+ signedClaims = '';
42
+ }
43
+ }
44
+ if ((!consent.allowsIdentityTier || !rawIdentity || !signedClaims) && cookies.has('aci_claims')) {
45
+ mutations.push(expireCookie('aci_claims', secure));
46
+ }
47
+ const manifest = await config.store.readEdgeManifest(releaseId);
48
+ const route = manifest ? matchRoute(new URL(request.url).pathname, manifest) : null;
49
+ let contentPath = route?.dispatch.contentPath || '';
50
+ let variantKey = '';
51
+ if (route) {
52
+ const claims = consent.allowsIdentityTier
53
+ ? await verifyClaims(signedClaims, config.claimsKeys || {}, now)
54
+ : null;
55
+ const decision = resolvePersonalizationDecision(toPersonalizationRequestContext(request), route, claims, manifest?.experiments, identityHash
56
+ ? { usable: true, siteId: config.siteId, namespace: 'aci_id', identityHash }
57
+ : undefined);
58
+ if (decision.variant) {
59
+ variantKey = decision.variant.variantKey;
60
+ contentPath = decision.variant.dispatch.contentPath || contentPath;
61
+ }
62
+ if (decision.experiment) {
63
+ requestHeaders.set('X-ACI-Experiment-ID', decision.experiment.experimentId);
64
+ requestHeaders.set('X-ACI-Experiment-Arm', decision.experiment.armId);
65
+ requestHeaders.set('X-ACI-Experiment-Allocation-Version', decision.experiment.allocationVersion);
66
+ }
67
+ const experiment = decision.experiment
68
+ ? { id: decision.experiment.experimentId, arm: decision.experiment.armId, allocationVersion: decision.experiment.allocationVersion }
69
+ : undefined;
70
+ if (contentPath)
71
+ requestHeaders.set(CONTENT_PATH_HEADER, contentPath);
72
+ if (variantKey)
73
+ requestHeaders.set('x-aci-variant-key', variantKey);
74
+ return { requestHeaders, contentPath, variantKey, experiment, cookies: mutations };
75
+ }
76
+ if (contentPath) {
77
+ requestHeaders.set(CONTENT_PATH_HEADER, contentPath);
78
+ }
79
+ if (variantKey) {
80
+ requestHeaders.set('x-aci-variant-key', variantKey);
81
+ }
82
+ return { requestHeaders, contentPath, variantKey, cookies: mutations };
83
+ }
84
+ export function consentSignals(request, cookies) {
85
+ const privacyDenied = request.headers.get('sec-gpc')?.trim() === '1'
86
+ || request.headers.get('dnt')?.trim() === '1'
87
+ || ['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;
91
+ return {
92
+ hasConsent: snapshot !== null,
93
+ snapshot,
94
+ allowsIdentityTier: !privacyDenied && allows,
95
+ privacyDenied,
96
+ };
97
+ }
98
+ export function parseCookieHeader(value) {
99
+ const cookies = new Map();
100
+ for (const part of (value || '').split(';')) {
101
+ const separator = part.indexOf('=');
102
+ if (separator < 0)
103
+ continue;
104
+ const name = part.slice(0, separator).trim();
105
+ if (name)
106
+ cookies.set(name, part.slice(separator + 1).trim());
107
+ }
108
+ return cookies;
109
+ }
110
+ export async function verifyIdentityCookie(value, keys) {
111
+ const parts = value.trim().split('.');
112
+ if (parts.length !== 3 || parts.some((part) => part === ''))
113
+ return null;
114
+ const [rawID, kid, signature] = parts;
115
+ const key = [keys.current, keys.previous].find((candidate) => candidate?.kid === kid);
116
+ if (!key)
117
+ return null;
118
+ return await verifyHMAC(`${rawID}.${kid}`, signature, key.secret) ? rawID : null;
119
+ }
120
+ export async function sha256Hex(value) {
121
+ const digest = await crypto.subtle.digest('SHA-256', textEncoder.encode(value));
122
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');
123
+ }
124
+ export async function identityHashHex(namespace, value) {
125
+ return sha256Hex(`${namespace.trim().toLowerCase()}\x00${value.trim()}`);
126
+ }
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
+ function matchRoute(pathname, manifest) {
158
+ const normalized = normalizePath(pathname);
159
+ if (manifest.routes[normalized])
160
+ return manifest.routes[normalized];
161
+ const candidates = Object.values(manifest.routes)
162
+ .filter((route) => routePatternMatches(route.pattern, normalized))
163
+ .sort((left, right) => right.priority - left.priority || left.pattern.localeCompare(right.pattern));
164
+ return candidates[0] || null;
165
+ }
166
+ function normalizePath(value) {
167
+ let path = value.trim() || '/';
168
+ if (!path.startsWith('/'))
169
+ path = `/${path}`;
170
+ if (path.length > 1)
171
+ path = path.replace(/\/+$/, '');
172
+ return path;
173
+ }
174
+ function routePatternMatches(pattern, pathname) {
175
+ pattern = normalizePath(pattern);
176
+ if (pattern === pathname)
177
+ return true;
178
+ const expression = pattern
179
+ .replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
180
+ .replace(/\\\[\\\[\\\.\\\.\\\.[^\]]+\\\]\\\]/g, '.*')
181
+ .replace(/\\\[\\\.\\\.\\\.[^\]]+\\\]/g, '.+')
182
+ .replace(/\\\[[^\]]+\\\]/g, '[^/]+')
183
+ .replace(/:[^/]+/g, '[^/]+')
184
+ .replace(/\\\*/g, '.*');
185
+ try {
186
+ return new RegExp(`^${expression}$`).test(pathname);
187
+ }
188
+ catch {
189
+ return false;
190
+ }
191
+ }
192
+ function toPersonalizationRequestContext(request) {
193
+ const url = new URL(request.url);
194
+ const cookies = parseCookieHeader(request.headers.get('cookie'));
195
+ const query = {};
196
+ url.searchParams.forEach((value, key) => {
197
+ (query[key] ||= []).push(value);
198
+ });
199
+ const headers = Object.fromEntries(request.headers.entries());
200
+ return {
201
+ path: url.pathname,
202
+ query,
203
+ headers,
204
+ cookies: Object.fromEntries(cookies.entries()),
205
+ signals: {
206
+ geo: {
207
+ country: headers['x-vercel-ip-country'] || '',
208
+ region: headers['x-vercel-ip-country-region'] || '',
209
+ city: headers['x-vercel-ip-city'] || '',
210
+ },
211
+ },
212
+ };
213
+ }
214
+ export async function verifyClaims(value, keys, now) {
215
+ const parts = value.trim().split('.');
216
+ if (parts.length !== 4 || parts[0] !== 'v1')
217
+ return null;
218
+ const [, kid, body, signature] = parts;
219
+ const secret = keys[kid];
220
+ if (!secret || !await verifyHMAC(`v1.${kid}.${body}`, signature, secret))
221
+ return null;
222
+ try {
223
+ const payload = JSON.parse(new TextDecoder().decode(base64URLDecode(body)));
224
+ if (!Number.isFinite(payload.exp) || payload.exp <= Math.floor(now.getTime() / 1000))
225
+ return null;
226
+ return payload;
227
+ }
228
+ catch {
229
+ return null;
230
+ }
231
+ }
232
+ async function signIdentity(rawID, key) {
233
+ const signature = await hmac(`${rawID}.${key.kid}`, key.secret);
234
+ return `${rawID}.${key.kid}.${signature}`;
235
+ }
236
+ function randomIdentity(random) {
237
+ const bytes = random ? random(16) : crypto.getRandomValues(new Uint8Array(16));
238
+ return base64URLEncode(bytes);
239
+ }
240
+ async function verifyHMAC(value, signature, secret) {
241
+ try {
242
+ const key = await crypto.subtle.importKey('raw', textEncoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']);
243
+ return await crypto.subtle.verify('HMAC', key, base64URLDecode(signature), textEncoder.encode(value));
244
+ }
245
+ catch {
246
+ return false;
247
+ }
248
+ }
249
+ async function hmac(value, secret) {
250
+ const key = await crypto.subtle.importKey('raw', textEncoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
251
+ return base64URLEncode(new Uint8Array(await crypto.subtle.sign('HMAC', key, textEncoder.encode(value))));
252
+ }
253
+ function base64URLEncode(bytes) {
254
+ let binary = '';
255
+ for (const byte of bytes)
256
+ binary += String.fromCharCode(byte);
257
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
258
+ }
259
+ function base64URLDecode(value) {
260
+ const padded = value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - value.length % 4) % 4);
261
+ const binary = atob(padded);
262
+ const bytes = new Uint8Array(binary.length);
263
+ for (let index = 0; index < binary.length; index += 1)
264
+ bytes[index] = binary.charCodeAt(index);
265
+ return bytes;
266
+ }
267
+ function cookieMutation(name, value, maxAge, now, secure, httpOnly) {
268
+ return {
269
+ name,
270
+ value,
271
+ options: { path: '/', httpOnly, secure, sameSite: 'lax', maxAge, expires: new Date(now.getTime() + maxAge * 1000) },
272
+ };
273
+ }
274
+ function expireCookie(name, secure) {
275
+ return { name, value: '', options: { path: '/', httpOnly: true, secure, sameSite: 'lax', maxAge: 0, expires: new Date(0) } };
276
+ }
@@ -10,7 +10,7 @@ export async function getPage(route = '', config = {}) {
10
10
  if (runtimeInput)
11
11
  return runtimeInput.page;
12
12
  const provider = await resolveProviderWithFallback(config);
13
- const variantPage = await pageFromFunctionRef(provider);
13
+ const variantPage = await pageFromContentPath(provider);
14
14
  if (variantPage !== null)
15
15
  return variantPage;
16
16
  return await provider.getPage(route);
@@ -56,7 +56,7 @@ export async function getRenderInput(route = '/', config = {}) {
56
56
  // Cache shared resources (manifest, siteConfig) but not per-page content
57
57
  // Page-level caching is handled by CDN
58
58
  const [page, siteConfig] = await Promise.all([
59
- pageFromFunctionRef(provider).then((variantPage) => variantPage ?? provider.getPage(route)),
59
+ pageFromContentPath(provider).then((variantPage) => variantPage ?? provider.getPage(route)),
60
60
  getCachedSiteConfig(provider),
61
61
  ]);
62
62
  return {
@@ -77,19 +77,19 @@ export async function getPageRuntimeRenderInput() {
77
77
  }
78
78
  }
79
79
  // The personalization edge proxies framework-origin requests with
80
- // X-Baremetal-Function-Ref naming the exact page payload its decision
80
+ // X-Aci-Content-Ref naming the exact page payload its decision
81
81
  // selected (base or variant). When present, the SSR render must use that
82
82
  // payload — otherwise every visitor gets the base page and edge decisioning
83
83
  // is cosmetic. The S3 provider only serves refs the release manifest
84
84
  // declares.
85
- const BAREMETAL_FUNCTION_REF_HEADER = 'x-baremetal-function-ref';
86
- async function pageFromFunctionRef(provider) {
85
+ const ACI_CONTENT_PATH_HEADER = 'x-aci-content-path';
86
+ async function pageFromContentPath(provider) {
87
87
  if (!(provider instanceof S3ContentProvider))
88
88
  return null;
89
89
  let ref = '';
90
90
  try {
91
91
  const current = await headers();
92
- ref = (current.get(BAREMETAL_FUNCTION_REF_HEADER) || '').trim();
92
+ ref = (current.get(ACI_CONTENT_PATH_HEADER) || '').trim();
93
93
  }
94
94
  catch {
95
95
  return null;
@@ -0,0 +1,3 @@
1
+ export { CONTENT_PATH_CACHE_PARAM, CONTENT_PATH_HEADER, resolvePersonalizationRequest, type EdgeRouteManifest, type PersonalizationBlobStore, type PersonalizationCollectionStore, type PersonalizationHostConfig, type PersonalizationRouteStore, type PersonalizationResolution, } from './personalization-host.js';
2
+ export { createPersonalizationCollectRoute, type PersonalizationCollectRouteConfig, } from './collect-route.js';
3
+ export { createVercelPersonalizationFromEnv, createVercelCollectionFromEnv, VercelEdgeConfigRouteStore, createVercelS3PersonalizationStore, VercelS3PersonalizationStore, type VercelS3PersonalizationConfig, } from './vercel-s3-personalization.js';