@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.
- package/CHANGELOG.md +8 -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/cli/compile-registry.d.mts +5 -1
- package/dist/cli/compile-registry.mjs +25 -10
- package/dist/cli/validate-content.d.mts +31 -1
- package/dist/cli/validate-content.mjs +50 -12
- 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/content/resolve-slots.js +1 -0
- package/dist/content/types.d.ts +2 -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/dist/types/block-ref.d.ts +2 -0
- package/dist/types/block-ref.js +1 -0
- package/package.json +27 -16
- package/src/cli/compile-registry.mjs +24 -10
- package/src/cli/validate-content.mjs +52 -13
- package/src/types/block-ref.test.ts +45 -0
- package/src/types/block-ref.ts +2 -0
- package/templates/astro/starter-content/src/fixtures/atomicBlocks.ts +7 -6
- package/templates/astro/starter-content/src/fixtures/composition.ts +3 -2
- package/templates/astro/starter-content/src/fixtures/sections.ts +2 -1
- package/templates/common/.agents/skills/authoring/SKILL.md +11 -3
- package/templates/common/.agents/skills/authoring/references/block-slots.md +3 -0
- package/templates/common/.content/pages/home/_index.json +10 -0
- package/templates/common/starter-content/.content/pages/about/_index.json +3 -0
- package/templates/common/starter-content/.content/pages/home/_index.json +9 -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
|
@@ -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
|
};
|
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;
|