@gradial/aci 0.1.41 → 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.
- package/CHANGELOG.md +9 -13
- package/README.md +4 -4
- package/dist/astro/index.d.ts +1 -1
- package/dist/astro/index.js +1 -1
- package/dist/astro/page.d.ts +6 -0
- package/dist/astro/page.js +3 -2
- package/dist/cli/compile-registry.d.mts +2 -0
- package/dist/cli/compile-registry.mjs +759 -0
- package/dist/cli/materialize-template.d.mts +19 -0
- package/dist/cli/materialize-template.mjs +128 -0
- package/dist/cli/validate-content.d.mts +2 -0
- package/dist/cli/validate-content.mjs +426 -0
- package/dist/cli/verify-next-cdn-routes.d.mts +2 -0
- package/dist/cli/verify-next-cdn-routes.mjs +47 -0
- package/dist/collect/assignments.d.ts +65 -0
- package/dist/collect/assignments.js +138 -0
- package/dist/collect/batcher.d.ts +21 -0
- package/dist/collect/batcher.js +10 -0
- package/dist/collect/declarative.d.ts +1 -1
- package/dist/collect/declarative.js +7 -1
- package/dist/collect/index.js +30 -4
- package/dist/collect/pageviews.d.ts +15 -3
- package/dist/collect/pageviews.js +28 -13
- package/dist/collect/transport.d.ts +2 -0
- package/dist/collect/transport.js +32 -0
- package/dist/consent-banner/index.d.ts +33 -0
- package/dist/consent-banner/index.js +99 -0
- package/dist/content/personalization.d.ts +35 -0
- package/dist/content/personalization.js +57 -0
- package/dist/content/types.d.ts +7 -1
- package/dist/edge/personalization-core.d.ts +11 -0
- package/dist/edge/personalization-core.js +265 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/next/assignments-script.d.ts +14 -0
- package/dist/next/assignments-script.js +17 -0
- package/dist/next/collect-route.d.ts +19 -0
- package/dist/next/collect-route.js +351 -0
- package/dist/next/index.d.ts +1 -0
- package/dist/next/index.js +1 -0
- package/dist/next/middleware.d.ts +15 -1
- package/dist/next/middleware.js +71 -27
- package/dist/next/page.js +2 -1
- package/dist/next/personalization-host.d.ts +118 -0
- package/dist/next/personalization-host.js +276 -0
- package/dist/next/server.js +6 -6
- package/dist/next/vercel-personalization.d.ts +3 -0
- package/dist/next/vercel-personalization.js +3 -0
- package/dist/next/vercel-s3-personalization.d.ts +58 -0
- package/dist/next/vercel-s3-personalization.js +220 -0
- package/dist/providers/s3.d.ts +1 -1
- package/dist/providers/s3.js +1 -1
- package/dist/react/VideoPlayer.d.ts +6 -6
- package/dist/react/index.d.ts +6 -6
- package/dist/types/page.d.ts +1 -1
- package/package.json +19 -1
- package/src/astro/ConsentBanner.astro +62 -0
- package/src/cli/verify-next-cdn-routes.mjs +5 -4
- package/src/consent-banner/styles.css +110 -0
- package/src/types/page.ts +1 -1
- package/templates/astro/starter-content/src/cms/contracts/components/index.ts +2 -0
- package/templates/astro/starter-content/src/cms/contracts/components/personalizationChoice.contract.ts +17 -0
- package/templates/astro/starter-content/src/cms/registry.ts +3 -0
- package/templates/astro/starter-content/src/components/sections/PersonalizationChoice.astro +41 -0
- package/templates/astro/starter-content/src/render/RenderPage.astro +3 -2
- package/templates/astro/template/src/layouts/SiteLayout.astro +3 -1
- package/templates/astro/template/src/pages/[...slug].astro +1 -0
- package/templates/common/.content/audiences/preference-blue/_index.json +11 -0
- package/templates/common/.content/audiences/preference-red/_index.json +11 -0
- package/templates/common/.content/overlays/audience/preference-blue/pages/home/_index.json +13 -0
- package/templates/common/.content/overlays/audience/preference-red/pages/home/_index.json +13 -0
- package/templates/common/.content/pages/home/_index.json +114 -4
- package/templates/common/.content/segments/preference-blue/_index.json +20 -0
- package/templates/common/.content/segments/preference-red/_index.json +20 -0
- package/templates/common/contracts/components/index.ts +21 -1
- package/templates/common/contracts/components/personalizationChoice.contract.ts +17 -0
- package/templates/nextjs/starter-content/src/cms/contracts/components/index.ts +3 -1
- package/templates/nextjs/starter-content/src/cms/contracts/components/personalizationChoice.contract.ts +17 -0
- package/templates/nextjs/starter-content/src/cms/registry.ts +3 -0
- package/templates/nextjs/starter-content/src/components/sections/PersonalizationChoice.tsx +53 -0
- package/templates/nextjs/starter-content/src/middleware.ts +11 -0
- package/templates/nextjs/template/src/app/collect/route.ts +18 -0
- package/templates/nextjs/template/src/middleware.ts +6 -2
- package/templates/astro/template/.aci.yaml +0 -28
- package/templates/common/.content/.gradial-dam/blobs/sha256/7f/bf/7fbfc7fe230ff12688c496f660119e41d9725c4504d82e310c15665c73c09292 +0 -0
- package/templates/common/.content/.gradial-dam/blobs/sha256/a0/f5/a0f506689de9b15a7055f70103a3f02cd3ba105771ae2de6754df2ede1210d3a +0 -0
- package/templates/common/.content/.gradial-dam/blobs/sha256/dd/b7/ddb7e9642be08317bcd4b2bd26b58ba8d0d34dd7239e03e684a4f3634fa3845b +0 -0
- package/templates/common/.content/.gradial-dam/blobs/sha256/fb/32/fb3260e8286ea8231ea1c77b9c0be92d6edd9026565731eb14cfdcfd6b1b75b3 +0 -0
- package/templates/common/.content/config/robots.txt +0 -2
- package/templates/common/.content/config/site.json +0 -197
- package/templates/common/.content/dam/folders/seed/ast_site_about_hero/asset.json +0 -9
- package/templates/common/.content/dam/folders/seed/ast_site_about_hero/editorial.json +0 -14
- package/templates/common/.content/dam/folders/seed/ast_site_about_hero/governance.json +0 -23
- package/templates/common/.content/dam/folders/seed/ast_site_about_hero/versions/v0001.json +0 -37
- package/templates/common/.content/dam/folders/seed/ast_site_home_hero/asset.json +0 -9
- package/templates/common/.content/dam/folders/seed/ast_site_home_hero/editorial.json +0 -14
- package/templates/common/.content/dam/folders/seed/ast_site_home_hero/governance.json +0 -23
- package/templates/common/.content/dam/folders/seed/ast_site_home_hero/versions/v0001.json +0 -37
- package/templates/common/.content/fragments/navbar/_index.json +0 -25
- package/templates/common/.content/fragments/site-footer/_index.json +0 -62
- package/templates/common/.content/pages/about/_index.json +0 -51
- package/templates/nextjs/template/.aci.yaml +0 -26
|
@@ -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
|
+
}
|
package/dist/next/server.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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-
|
|
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
|
|
86
|
-
async function
|
|
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(
|
|
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';
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { CONTENT_PATH_CACHE_PARAM, CONTENT_PATH_HEADER, resolvePersonalizationRequest, } from './personalization-host.js';
|
|
2
|
+
export { createPersonalizationCollectRoute, } from './collect-route.js';
|
|
3
|
+
export { createVercelPersonalizationFromEnv, createVercelCollectionFromEnv, VercelEdgeConfigRouteStore, createVercelS3PersonalizationStore, VercelS3PersonalizationStore, } from './vercel-s3-personalization.js';
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { EdgeRouteManifest, PersonalizationCollectionStore, PersonalizationHostConfig, PersonalizationRouteStore } from './personalization-host.js';
|
|
2
|
+
export interface VercelS3PersonalizationConfig {
|
|
3
|
+
region: string;
|
|
4
|
+
roleArn?: string;
|
|
5
|
+
hostingBucket: string;
|
|
6
|
+
eventsBucket: string;
|
|
7
|
+
hostingKeyPrefix?: string;
|
|
8
|
+
claimsKeyPrefix?: string;
|
|
9
|
+
endpoint?: string;
|
|
10
|
+
forcePathStyle?: boolean;
|
|
11
|
+
}
|
|
12
|
+
export interface S3CommandClient {
|
|
13
|
+
send(command: {
|
|
14
|
+
input: unknown;
|
|
15
|
+
}): Promise<any>;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Node-only Vercel collection adapter for audience lake objects and signed
|
|
19
|
+
* claims. The EdgeRouter never receives this adapter or AWS credentials.
|
|
20
|
+
* Production construction exchanges the Vercel workload OIDC token for
|
|
21
|
+
* scoped AWS credentials; no long-lived AWS keys are read.
|
|
22
|
+
*/
|
|
23
|
+
export declare class VercelS3PersonalizationStore implements PersonalizationCollectionStore {
|
|
24
|
+
private readonly config;
|
|
25
|
+
private readonly client;
|
|
26
|
+
constructor(config: VercelS3PersonalizationConfig, client?: S3CommandClient);
|
|
27
|
+
readClaims(siteId: string, namespace: string, identityHash: string): Promise<string | null>;
|
|
28
|
+
appendEvent(key: string, record: unknown): Promise<void>;
|
|
29
|
+
putSuppression(key: string, record: unknown): Promise<void>;
|
|
30
|
+
private readObject;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Edge Config is the provider projection for immutable release routing state.
|
|
34
|
+
* This tiny store is safe in Next middleware: it only fetches the route
|
|
35
|
+
* projection and never reads S3, claims, or event data.
|
|
36
|
+
*/
|
|
37
|
+
export declare class VercelEdgeConfigRouteStore implements PersonalizationRouteStore {
|
|
38
|
+
private readonly edgeConfig;
|
|
39
|
+
private readonly siteId;
|
|
40
|
+
constructor(edgeConfig: string, siteId: string);
|
|
41
|
+
readEdgeManifest(releaseId: string): Promise<EdgeRouteManifest | null>;
|
|
42
|
+
}
|
|
43
|
+
export declare function createVercelS3PersonalizationStore(config: VercelS3PersonalizationConfig): VercelS3PersonalizationStore;
|
|
44
|
+
/**
|
|
45
|
+
* Builds the EdgeRouter configuration used by Next middleware. It deliberately
|
|
46
|
+
* has no AWS/S3 configuration: its only remote dependency is the provider's
|
|
47
|
+
* Edge Config projection.
|
|
48
|
+
*/
|
|
49
|
+
export declare function createVercelPersonalizationFromEnv(env?: Record<string, string | undefined>): PersonalizationHostConfig | undefined;
|
|
50
|
+
/** Builds the Node.js `/collect` configuration. This is the only Vercel
|
|
51
|
+
* application runtime that receives data-plane storage configuration. */
|
|
52
|
+
export declare function createVercelCollectionFromEnv(env?: Record<string, string | undefined>): {
|
|
53
|
+
siteId: string;
|
|
54
|
+
store: PersonalizationCollectionStore;
|
|
55
|
+
identityKeys: PersonalizationHostConfig['identityKeys'];
|
|
56
|
+
claimsKeys: Record<string, string>;
|
|
57
|
+
secureCookies: boolean;
|
|
58
|
+
} | undefined;
|