@gradial/aci 0.1.42 → 0.1.44
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/README.md +4 -4
- package/dist/audience-data-models.d.ts +286 -0
- package/dist/audience-data-models.js +46 -0
- package/dist/collect/aci.js +1 -0
- package/dist/collect/aci.js.sha256 +1 -0
- package/dist/collect/batcher.d.ts +2 -0
- package/dist/collect/batcher.js +18 -6
- package/dist/collect/consent/envelope.d.ts +9 -3
- package/dist/collect/consent/envelope.js +58 -40
- package/dist/collect/index.js +9 -2
- package/dist/collect/linkclicks.js +4 -2
- package/dist/collect/transport.d.ts +2 -0
- package/dist/collect/transport.js +6 -3
- package/dist/consent/portable-envelope.d.ts +9 -0
- package/dist/consent/portable-envelope.js +110 -0
- package/dist/consent-banner/index.js +2 -1
- package/dist/content/assets.d.ts +1 -0
- package/dist/content/assets.js +1 -0
- package/dist/next/collect-route.d.ts +2 -2
- package/dist/next/collect-route.js +90 -19
- package/dist/next/envelope-digest.d.ts +21 -0
- package/dist/next/envelope-digest.js +58 -0
- package/dist/next/middleware.d.ts +16 -2
- package/dist/next/middleware.js +190 -10
- package/dist/next/personalization-host.d.ts +16 -7
- package/dist/next/personalization-host.js +14 -33
- package/dist/next/server.d.ts +1 -0
- package/dist/next/server.js +3 -0
- package/dist/next/vercel-collect-sdk-route.d.ts +27 -0
- package/dist/next/vercel-collect-sdk-route.js +205 -0
- package/dist/next/vercel-personalization.d.ts +3 -2
- package/dist/next/vercel-personalization.js +3 -2
- package/dist/next/vercel-s3-personalization.d.ts +25 -4
- package/dist/next/vercel-s3-personalization.js +368 -14
- package/dist/providers/s3.d.ts +19 -4
- package/dist/providers/s3.js +92 -22
- package/dist/testing/conformance/checks.js +75 -4
- package/package.json +27 -16
- package/src/cli/validate-content.mjs +0 -0
- package/templates/nextjs/starter-content/src/middleware.ts +31 -1
- package/templates/nextjs/template/src/app/collect/route.ts +2 -2
- package/templates/nextjs/template/src/middleware.ts +31 -1
- package/templates/nextjs/template/src/middleware.ts.tmpl +31 -1
package/dist/next/middleware.js
CHANGED
|
@@ -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
|
|
47
|
+
return passThroughResponse(request, frameworkDispatch);
|
|
22
48
|
}
|
|
23
49
|
if (CMS_ASSET_PATHS.has(url.pathname)) {
|
|
24
|
-
return
|
|
50
|
+
return passThroughResponse(request, frameworkDispatch);
|
|
25
51
|
}
|
|
26
52
|
if (isPassthroughRoute(url.pathname)) {
|
|
27
|
-
return
|
|
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,
|
|
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 (
|
|
152
|
-
rewriteURL.searchParams.set(CONTENT_PATH_CACHE_PARAM,
|
|
190
|
+
if (options?.contentPath) {
|
|
191
|
+
rewriteURL.searchParams.set(CONTENT_PATH_CACHE_PARAM, options.contentPath);
|
|
153
192
|
}
|
|
154
|
-
const response =
|
|
155
|
-
? NextResponse.rewrite(rewriteURL, { request: { headers:
|
|
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
|
-
|
|
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
|
|
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
|
|
89
|
-
const
|
|
90
|
-
const
|
|
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])
|
package/dist/next/server.d.ts
CHANGED
package/dist/next/server.js
CHANGED
|
@@ -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;
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3';
|
|
3
|
+
import { awsCredentialsProvider } from '@vercel/oidc-aws-credentials-provider';
|
|
4
|
+
import { getUncachedEdgeConfigValue } from './edge-config.js';
|
|
5
|
+
const SHA256_HEX = /^[0-9a-f]{64}$/;
|
|
6
|
+
/**
|
|
7
|
+
* Creates the Node-only stable collect-SDK route used by ACI's injected
|
|
8
|
+
* Vercel Function. The provider pointer selects the immutable release on every
|
|
9
|
+
* uncached invocation, so an old code deployment remains correct while a
|
|
10
|
+
* canonical activation converges through Edge Config and deployment promotion.
|
|
11
|
+
*/
|
|
12
|
+
export function createVercelCollectSDKRoute(input, dependencies = {}) {
|
|
13
|
+
const config = validateConfig(input);
|
|
14
|
+
const readEdgeConfig = dependencies.readEdgeConfig || getUncachedEdgeConfigValue;
|
|
15
|
+
const client = dependencies.s3Client || new S3Client({
|
|
16
|
+
region: config.region,
|
|
17
|
+
credentials: awsCredentialsProvider({
|
|
18
|
+
roleArn: config.roleArn,
|
|
19
|
+
clientConfig: { region: config.region },
|
|
20
|
+
}),
|
|
21
|
+
});
|
|
22
|
+
const onError = dependencies.onError || ((error) => {
|
|
23
|
+
console.error('[ACI collect SDK] route failed', error);
|
|
24
|
+
});
|
|
25
|
+
return async (request) => {
|
|
26
|
+
const method = request.method.toUpperCase();
|
|
27
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
28
|
+
return new Response('method not allowed', {
|
|
29
|
+
status: 405,
|
|
30
|
+
headers: {
|
|
31
|
+
Allow: 'GET, HEAD',
|
|
32
|
+
'Cache-Control': 'private, no-store',
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
const active = parseActiveReleasePointer(await readEdgeConfig(config.edgeConfig, activeReleaseKey(config.siteId)));
|
|
38
|
+
const projection = parseProjection(await readEdgeConfig(config.edgeConfig, edgeRoutesKey(config.siteId, active.releaseId)));
|
|
39
|
+
if (projection.siteId !== config.siteId || projection.releaseId !== active.releaseId) {
|
|
40
|
+
throw new Error(`collect SDK projection identity mismatch: got ${projection.siteId}/${projection.releaseId}, `
|
|
41
|
+
+ `want ${config.siteId}/${active.releaseId}`);
|
|
42
|
+
}
|
|
43
|
+
if (!Object.prototype.hasOwnProperty.call(projection, 'collectSdk')) {
|
|
44
|
+
return new Response(method === 'HEAD' ? null : 'collect SDK is not enabled for this release', {
|
|
45
|
+
status: 404,
|
|
46
|
+
headers: { 'Cache-Control': 'no-store' },
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
const entry = validateCollectSDKEntry(projection.collectSdk);
|
|
50
|
+
const body = await readSDKObject(client, config.hostingBucket, joinKey(config.hostingKeyPrefix, 'releases', active.releaseId, entry.path));
|
|
51
|
+
const actualHash = createHash('sha256').update(body).digest('hex');
|
|
52
|
+
if (actualHash !== entry.hash) {
|
|
53
|
+
throw new Error(`collect SDK digest mismatch: got ${actualHash}, want ${entry.hash}`);
|
|
54
|
+
}
|
|
55
|
+
const etag = `"${entry.hash}"`;
|
|
56
|
+
const headers = new Headers({
|
|
57
|
+
'Content-Type': 'text/javascript; charset=utf-8',
|
|
58
|
+
'Cache-Control': 'public, max-age=300, must-revalidate',
|
|
59
|
+
ETag: etag,
|
|
60
|
+
});
|
|
61
|
+
if (ifNoneMatchSatisfied(request.headers.get('if-none-match'), etag)) {
|
|
62
|
+
return new Response(null, { status: 304, headers });
|
|
63
|
+
}
|
|
64
|
+
headers.set('Content-Length', String(body.byteLength));
|
|
65
|
+
const responseBody = body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength);
|
|
66
|
+
return new Response(method === 'HEAD' ? null : responseBody, { status: 200, headers });
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
onError(error);
|
|
70
|
+
return new Response(method === 'HEAD' ? null : 'collect SDK is temporarily unavailable', {
|
|
71
|
+
status: 503,
|
|
72
|
+
headers: { 'Cache-Control': 'private, no-store' },
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
export function createVercelCollectSDKRouteFromEnv(env = process.env, dependencies = {}) {
|
|
78
|
+
try {
|
|
79
|
+
return createVercelCollectSDKRoute({
|
|
80
|
+
siteId: requiredEnv(env, 'ACI_SITE_ID'),
|
|
81
|
+
edgeConfig: requiredEnv(env, 'EDGE_CONFIG'),
|
|
82
|
+
region: env.AWS_REGION || env.AWS_DEFAULT_REGION || '',
|
|
83
|
+
roleArn: requiredEnv(env, 'AWS_ROLE_ARN'),
|
|
84
|
+
hostingBucket: requiredEnv(env, 'HOSTING_BUCKET'),
|
|
85
|
+
hostingKeyPrefix: env.HOSTING_KEY_PREFIX || env.GRADIAL_S3_KEY_PREFIX,
|
|
86
|
+
}, dependencies);
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
(dependencies.onError || ((value) => {
|
|
90
|
+
console.error('[ACI collect SDK] configuration failed', value);
|
|
91
|
+
}))(error);
|
|
92
|
+
return async (request) => new Response(request.method.toUpperCase() === 'HEAD' ? null : 'collect SDK is temporarily unavailable', { status: 503, headers: { 'Cache-Control': 'private, no-store' } });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function validateConfig(input) {
|
|
96
|
+
const siteId = requiredPathSegment(input.siteId, 'ACI site ID');
|
|
97
|
+
const edgeConfig = input.edgeConfig.trim();
|
|
98
|
+
const region = input.region.trim();
|
|
99
|
+
const roleArn = input.roleArn.trim();
|
|
100
|
+
const hostingBucket = input.hostingBucket.trim();
|
|
101
|
+
if (!edgeConfig)
|
|
102
|
+
throw new Error('Edge Config connection is required');
|
|
103
|
+
if (!region)
|
|
104
|
+
throw new Error('AWS region is required');
|
|
105
|
+
if (!roleArn)
|
|
106
|
+
throw new Error('AWS role ARN is required');
|
|
107
|
+
if (!hostingBucket)
|
|
108
|
+
throw new Error('hosting bucket is required');
|
|
109
|
+
return {
|
|
110
|
+
siteId,
|
|
111
|
+
edgeConfig,
|
|
112
|
+
region,
|
|
113
|
+
roleArn,
|
|
114
|
+
hostingBucket,
|
|
115
|
+
hostingKeyPrefix: cleanKey(input.hostingKeyPrefix || ''),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
function parseActiveReleasePointer(value) {
|
|
119
|
+
const parsed = parseRecord(value, 'active release pointer');
|
|
120
|
+
return { releaseId: requiredPathSegment(parsed.releaseId || '', 'active release ID') };
|
|
121
|
+
}
|
|
122
|
+
function parseProjection(value) {
|
|
123
|
+
const parsed = parseRecord(value, 'collect SDK route projection');
|
|
124
|
+
const projection = {
|
|
125
|
+
releaseId: requiredPathSegment(parsed.releaseId || '', 'projection release ID'),
|
|
126
|
+
siteId: requiredPathSegment(parsed.siteId || '', 'projection site ID'),
|
|
127
|
+
};
|
|
128
|
+
if (Object.prototype.hasOwnProperty.call(parsed, 'collectSdk')) {
|
|
129
|
+
projection.collectSdk = parsed.collectSdk;
|
|
130
|
+
}
|
|
131
|
+
return projection;
|
|
132
|
+
}
|
|
133
|
+
function parseRecord(value, label) {
|
|
134
|
+
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
|
|
135
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
136
|
+
throw new Error(`${label} is unavailable or malformed`);
|
|
137
|
+
}
|
|
138
|
+
return parsed;
|
|
139
|
+
}
|
|
140
|
+
function validateCollectSDKEntry(value) {
|
|
141
|
+
const entry = parseRecord(value, 'collect SDK metadata');
|
|
142
|
+
if (typeof entry.hash !== 'string' || typeof entry.path !== 'string') {
|
|
143
|
+
throw new Error('collect SDK metadata must contain string hash and path fields');
|
|
144
|
+
}
|
|
145
|
+
const hash = entry.hash.trim().toLowerCase();
|
|
146
|
+
const path = cleanKey(entry.path);
|
|
147
|
+
if (!SHA256_HEX.test(hash))
|
|
148
|
+
throw new Error('collect SDK hash must be lowercase SHA-256');
|
|
149
|
+
const expectedPath = `_aci/aci.${hash}.js`;
|
|
150
|
+
if (path !== expectedPath || path !== entry.path) {
|
|
151
|
+
throw new Error(`collect SDK path ${JSON.stringify(entry.path)} must equal ${JSON.stringify(expectedPath)}`);
|
|
152
|
+
}
|
|
153
|
+
return { hash, path };
|
|
154
|
+
}
|
|
155
|
+
async function readSDKObject(client, bucket, key) {
|
|
156
|
+
const output = await client.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
|
|
157
|
+
if (output.Body?.transformToByteArray)
|
|
158
|
+
return await output.Body.transformToByteArray();
|
|
159
|
+
if (output.Body?.transformToString) {
|
|
160
|
+
return new TextEncoder().encode(await output.Body.transformToString());
|
|
161
|
+
}
|
|
162
|
+
throw new Error(`collect SDK object ${key} returned an unsupported body`);
|
|
163
|
+
}
|
|
164
|
+
function ifNoneMatchSatisfied(value, etag) {
|
|
165
|
+
const header = (value || '').trim();
|
|
166
|
+
if (!header)
|
|
167
|
+
return false;
|
|
168
|
+
if (header === '*')
|
|
169
|
+
return true;
|
|
170
|
+
return header.split(',').some((candidate) => {
|
|
171
|
+
const normalized = candidate.trim().replace(/^W\//, '');
|
|
172
|
+
return normalized === etag;
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
function activeReleaseKey(siteId) {
|
|
176
|
+
return `activeRelease_${edgeKeySegment(siteId)}`;
|
|
177
|
+
}
|
|
178
|
+
function edgeRoutesKey(siteId, releaseId) {
|
|
179
|
+
return `edgeRoutes_${edgeKeySegment(siteId)}_${edgeKeySegment(releaseId)}`;
|
|
180
|
+
}
|
|
181
|
+
function edgeKeySegment(value) {
|
|
182
|
+
return value.replace(/[^A-Za-z0-9_-]/g, '_');
|
|
183
|
+
}
|
|
184
|
+
function requiredPathSegment(value, label) {
|
|
185
|
+
const clean = value.trim();
|
|
186
|
+
if (!clean || clean === '.' || clean === '..' || clean.includes('/') || clean.includes('\\')) {
|
|
187
|
+
throw new Error(`${label} is required and must be one path segment`);
|
|
188
|
+
}
|
|
189
|
+
return clean;
|
|
190
|
+
}
|
|
191
|
+
function cleanKey(value) {
|
|
192
|
+
const segments = value.replaceAll('\\', '/').split('/').filter((segment) => segment && segment !== '.');
|
|
193
|
+
if (segments.some((segment) => segment === '..'))
|
|
194
|
+
throw new Error('S3 key traversal is not allowed');
|
|
195
|
+
return segments.join('/');
|
|
196
|
+
}
|
|
197
|
+
function joinKey(...parts) {
|
|
198
|
+
return parts.map((part) => cleanKey(part || '')).filter(Boolean).join('/');
|
|
199
|
+
}
|
|
200
|
+
function requiredEnv(env, key) {
|
|
201
|
+
const value = env[key]?.trim() || '';
|
|
202
|
+
if (!value)
|
|
203
|
+
throw new Error(`${key} is required for the Vercel collect SDK route`);
|
|
204
|
+
return value;
|
|
205
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
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
1
|
export { createPersonalizationCollectRoute, type PersonalizationCollectRouteConfig, } from './collect-route.js';
|
|
3
|
-
export {
|
|
2
|
+
export { computeEnvelopeDigest, type EnvelopeDigestInput, } from './envelope-digest.js';
|
|
3
|
+
export { CONTENT_PATH_CACHE_PARAM, CONTENT_PATH_HEADER, IdentityErasureFencedError, resolvePersonalizationRequest, type EdgeRouteManifest, type PersonalizationBlobStore, type PersonalizationCollectionStore, type PersonalizationEnvelopeWrite, type PersonalizationHostConfig, type PersonalizationRouteStore, type PersonalizationResolution, } from './personalization-host.js';
|
|
4
|
+
export { createVercelPersonalizationFromEnv, createVercelCollectionFromEnv, VercelEdgeConfigRouteStore, createVercelS3PersonalizationStore, EnvelopeIDCollisionError, VercelS3PersonalizationStore, type VercelS3PersonalizationConfig, } from './vercel-s3-personalization.js';
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
-
export { CONTENT_PATH_CACHE_PARAM, CONTENT_PATH_HEADER, resolvePersonalizationRequest, } from './personalization-host.js';
|
|
2
1
|
export { createPersonalizationCollectRoute, } from './collect-route.js';
|
|
3
|
-
export {
|
|
2
|
+
export { computeEnvelopeDigest, } from './envelope-digest.js';
|
|
3
|
+
export { CONTENT_PATH_CACHE_PARAM, CONTENT_PATH_HEADER, IdentityErasureFencedError, resolvePersonalizationRequest, } from './personalization-host.js';
|
|
4
|
+
export { createVercelPersonalizationFromEnv, createVercelCollectionFromEnv, VercelEdgeConfigRouteStore, createVercelS3PersonalizationStore, EnvelopeIDCollisionError, VercelS3PersonalizationStore, } from './vercel-s3-personalization.js';
|