@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
|
@@ -2,33 +2,26 @@
|
|
|
2
2
|
// carries URL-encoded JSON {v:1, p, t, src} — cookie values cannot carry
|
|
3
3
|
// quotes. The collector and the edge URL-decode before parsing, with a
|
|
4
4
|
// legacy bare-token fallback during the deprecation window.
|
|
5
|
+
import { parsePortableConsentEnvelope, portableConsentAllowsPersonalization, trimPortableConsentWhitespace, } from "../../consent/portable-envelope.js";
|
|
5
6
|
export const PERSONALIZATION_PURPOSE = "personalization";
|
|
6
7
|
export const CONSENT_COOKIE = "aci_consent";
|
|
7
8
|
export const CONSENT_ENVELOPE_VERSION = 1;
|
|
8
9
|
/** Consent persistence horizon; the CMP remains the source of truth. */
|
|
9
10
|
export const CONSENT_MAX_AGE_S = 365 * 24 * 60 * 60;
|
|
10
|
-
/** Mirrors the server-side grandfathered tokens (audience collect_signals). */
|
|
11
|
-
const LEGACY_GRANT_TOKENS = new Set([
|
|
12
|
-
"true",
|
|
13
|
-
"all",
|
|
14
|
-
"granted",
|
|
15
|
-
"yes",
|
|
16
|
-
"personalization",
|
|
17
|
-
"personalized",
|
|
18
|
-
]);
|
|
19
11
|
/**
|
|
20
12
|
* writeConsent builds the {v:1, p, t, src} envelope, URL-encodes it and sets
|
|
21
|
-
* the aci_consent cookie (Path=/, SameSite=Lax, Secure on https).
|
|
13
|
+
* the aci_consent cookie (Path=/, SameSite=Lax, Secure on https). Inputs that
|
|
14
|
+
* cannot produce a portable envelope are ignored.
|
|
22
15
|
*/
|
|
23
16
|
export function writeConsent(purposes, src, deps) {
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
let cookie = `${CONSENT_COOKIE}=${
|
|
17
|
+
const prepared = prepareConsentEnvelope(purposes, src, deps.now);
|
|
18
|
+
if (prepared === null)
|
|
19
|
+
return false;
|
|
20
|
+
writePreparedConsent(prepared, deps);
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
function writePreparedConsent(prepared, deps) {
|
|
24
|
+
let cookie = `${CONSENT_COOKIE}=${prepared.encoded}; Path=/; Max-Age=${CONSENT_MAX_AGE_S}; SameSite=Lax`;
|
|
32
25
|
if (deps.document.location?.protocol === "https:") {
|
|
33
26
|
cookie += "; Secure";
|
|
34
27
|
}
|
|
@@ -41,15 +34,19 @@ export function writeConsent(purposes, src, deps) {
|
|
|
41
34
|
* written: withdrawal must reach the server.
|
|
42
35
|
*/
|
|
43
36
|
export function applyConsent(purposes, src, deps) {
|
|
44
|
-
|
|
37
|
+
const prepared = prepareConsentEnvelope(purposes, src, deps.now);
|
|
38
|
+
if (prepared === null)
|
|
39
|
+
return false;
|
|
40
|
+
if (!portableConsentAllowsPersonalization(prepared.snapshot)) {
|
|
45
41
|
deps.gapBuffer?.purge();
|
|
46
42
|
}
|
|
47
|
-
|
|
43
|
+
writePreparedConsent(prepared, deps);
|
|
44
|
+
return true;
|
|
48
45
|
}
|
|
49
46
|
/** readConsentCookie returns the raw (still URL-encoded) cookie value. */
|
|
50
47
|
export function readConsentCookie(doc) {
|
|
51
48
|
for (const part of doc.cookie.split(";")) {
|
|
52
|
-
const trimmed = part
|
|
49
|
+
const trimmed = trimPortableConsentWhitespace(part);
|
|
53
50
|
if (trimmed.startsWith(CONSENT_COOKIE + "=")) {
|
|
54
51
|
return trimmed.slice(CONSENT_COOKIE.length + 1);
|
|
55
52
|
}
|
|
@@ -64,23 +61,7 @@ export function readConsentCookie(doc) {
|
|
|
64
61
|
export function envelopeGrantsPersonalization(raw) {
|
|
65
62
|
if (raw === undefined || raw === "")
|
|
66
63
|
return false;
|
|
67
|
-
|
|
68
|
-
try {
|
|
69
|
-
decoded = decodeURIComponent(raw);
|
|
70
|
-
}
|
|
71
|
-
catch {
|
|
72
|
-
// Not URL-encoded; evaluate as-is.
|
|
73
|
-
}
|
|
74
|
-
if (LEGACY_GRANT_TOKENS.has(decoded.trim().toLowerCase()))
|
|
75
|
-
return true;
|
|
76
|
-
try {
|
|
77
|
-
const envelope = JSON.parse(decoded);
|
|
78
|
-
return (Array.isArray(envelope.p) &&
|
|
79
|
-
envelope.p.includes(PERSONALIZATION_PURPOSE));
|
|
80
|
-
}
|
|
81
|
-
catch {
|
|
82
|
-
return false;
|
|
83
|
-
}
|
|
64
|
+
return portableConsentAllowsPersonalization(parsePortableConsentEnvelope(raw, true));
|
|
84
65
|
}
|
|
85
66
|
/**
|
|
86
67
|
* sanitizePurposes validates a caller-supplied purposes value (the manual
|
|
@@ -94,7 +75,44 @@ export function sanitizePurposes(raw) {
|
|
|
94
75
|
for (const item of raw) {
|
|
95
76
|
if (typeof item !== "string")
|
|
96
77
|
return undefined;
|
|
97
|
-
purposes.push(item
|
|
78
|
+
purposes.push(trimPortableConsentWhitespace(item));
|
|
79
|
+
}
|
|
80
|
+
const encoded = JSON.stringify({
|
|
81
|
+
v: CONSENT_ENVELOPE_VERSION,
|
|
82
|
+
p: purposes,
|
|
83
|
+
});
|
|
84
|
+
return parsePortableConsentEnvelope(encoded, false)?.purposes;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* sanitizeConsentSource preserves the manual API's default while applying the
|
|
88
|
+
* same portable whitespace and byte bounds as every envelope reader.
|
|
89
|
+
*/
|
|
90
|
+
export function sanitizeConsentSource(raw) {
|
|
91
|
+
if (raw !== undefined && typeof raw !== "string")
|
|
92
|
+
return undefined;
|
|
93
|
+
const trimmed = typeof raw === "string" ? trimPortableConsentWhitespace(raw) : "";
|
|
94
|
+
const source = trimmed === "" ? "manual" : trimmed;
|
|
95
|
+
const encoded = JSON.stringify({
|
|
96
|
+
v: CONSENT_ENVELOPE_VERSION,
|
|
97
|
+
p: [],
|
|
98
|
+
src: source,
|
|
99
|
+
});
|
|
100
|
+
return parsePortableConsentEnvelope(encoded, false)?.source;
|
|
101
|
+
}
|
|
102
|
+
function prepareConsentEnvelope(purposes, src, now) {
|
|
103
|
+
try {
|
|
104
|
+
const serialized = JSON.stringify({
|
|
105
|
+
v: CONSENT_ENVELOPE_VERSION,
|
|
106
|
+
p: purposes,
|
|
107
|
+
t: new Date((now ?? Date.now)()).toISOString(),
|
|
108
|
+
src,
|
|
109
|
+
});
|
|
110
|
+
const snapshot = parsePortableConsentEnvelope(serialized, false);
|
|
111
|
+
return snapshot === null
|
|
112
|
+
? null
|
|
113
|
+
: { encoded: encodeURIComponent(serialized), snapshot };
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return null;
|
|
98
117
|
}
|
|
99
|
-
return purposes;
|
|
100
118
|
}
|
package/dist/collect/index.js
CHANGED
|
@@ -10,7 +10,7 @@ import { start as startC15t } from "./consent/adapters/c15t.js";
|
|
|
10
10
|
import { start as startCookiebot } from "./consent/adapters/cookiebot.js";
|
|
11
11
|
import { start as startOneTrust } from "./consent/adapters/onetrust.js";
|
|
12
12
|
import { start as startTrustArc } from "./consent/adapters/trustarc.js";
|
|
13
|
-
import { applyConsent, envelopeGrantsPersonalization, readConsentCookie, sanitizePurposes, } from "./consent/envelope.js";
|
|
13
|
+
import { applyConsent, envelopeGrantsPersonalization, readConsentCookie, sanitizeConsentSource, sanitizePurposes, } from "./consent/envelope.js";
|
|
14
14
|
import { startDeclarative } from "./declarative.js";
|
|
15
15
|
import { GapBuffer, hasMintMarker, maybeFlushGapBuffer, routeEvent, } from "./gapbuffer.js";
|
|
16
16
|
import { startLinkClicks } from "./linkclicks.js";
|
|
@@ -121,7 +121,14 @@ export function init(win) {
|
|
|
121
121
|
debug("consent", { reason: "malformed_purposes" });
|
|
122
122
|
return;
|
|
123
123
|
}
|
|
124
|
-
|
|
124
|
+
const source = sanitizeConsentSource(src);
|
|
125
|
+
if (source === undefined) {
|
|
126
|
+
debug("consent", { reason: "malformed_source" });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (!applyConsent(purposes, source, { document: doc, gapBuffer })) {
|
|
130
|
+
debug("consent", { reason: "oversized_envelope" });
|
|
131
|
+
}
|
|
125
132
|
},
|
|
126
133
|
}, debug);
|
|
127
134
|
const emitPageView = startPageViews(emit, w);
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
// document; the closest a[href] ancestor of the click target is recorded.
|
|
4
4
|
// target="_blank" links are still captured — the click event is useful RUM
|
|
5
5
|
// even when the browser opens a new tab, and dropping them would lose a
|
|
6
|
-
// measurable fraction of outbound intent.
|
|
6
|
+
// measurable fraction of outbound intent. Capture phase keeps application
|
|
7
|
+
// stopPropagation handlers from suppressing telemetry. Errors are swallowed
|
|
8
|
+
// silently.
|
|
7
9
|
export const LINK_CLICK_EVENT = "rum.link_click";
|
|
8
10
|
/**
|
|
9
11
|
* startLinkClicks installs one delegated click listener on document that
|
|
@@ -31,5 +33,5 @@ export function startLinkClicks(emit, env) {
|
|
|
31
33
|
catch (err) {
|
|
32
34
|
debug?.("capture", { reason: "link_click_failed", error: String(err) });
|
|
33
35
|
}
|
|
34
|
-
});
|
|
36
|
+
}, { capture: true });
|
|
35
37
|
}
|
|
@@ -14,6 +14,8 @@ export interface TransportEnv {
|
|
|
14
14
|
navigator?: BeaconNavigator | undefined;
|
|
15
15
|
fetch?: FetchLike | undefined;
|
|
16
16
|
debug?: DebugFn;
|
|
17
|
+
/** Injectable only so refresh-envelope identity is deterministic in tests. */
|
|
18
|
+
createEnvelopeId?: () => string;
|
|
17
19
|
}
|
|
18
20
|
/**
|
|
19
21
|
* send posts one serialized batch envelope to the relative collect path.
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// Transport (design/05): navigator.sendBeacon("/collect", Blob text/plain)
|
|
2
2
|
// with a keepalive-fetch fallback. Fire-and-forget — no retry loop in v1;
|
|
3
|
-
//
|
|
4
|
-
// stay exactly same-origin
|
|
3
|
+
// the sealed envelopeId and bytes are the identity any future retry layer
|
|
4
|
+
// must reuse. The path is RELATIVE so requests stay exactly same-origin
|
|
5
|
+
// (data-plane design/04).
|
|
6
|
+
import { ulid } from "./ulid.js";
|
|
5
7
|
export const COLLECT_PATH = "/collect";
|
|
6
8
|
/**
|
|
7
9
|
* send posts one serialized batch envelope to the relative collect path.
|
|
@@ -60,7 +62,8 @@ export function refreshClaims(env) {
|
|
|
60
62
|
if (!fetchFn)
|
|
61
63
|
return Promise.resolve(false);
|
|
62
64
|
try {
|
|
63
|
-
const
|
|
65
|
+
const envelopeId = (env?.createEnvelopeId ?? ulid)();
|
|
66
|
+
const body = new Blob([`{"envelopeId":"${envelopeId}","events":[]}`], { type: "text/plain" });
|
|
64
67
|
return fetchFn(COLLECT_PATH, {
|
|
65
68
|
method: "POST",
|
|
66
69
|
body,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface PortableConsentSnapshot {
|
|
2
|
+
version: number;
|
|
3
|
+
purposes: string[];
|
|
4
|
+
grantedAt: string;
|
|
5
|
+
source?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function parsePortableConsentEnvelope(raw: string, decodeCookieValue?: boolean): PortableConsentSnapshot | null;
|
|
8
|
+
export declare function portableConsentAllowsPersonalization(snapshot: PortableConsentSnapshot | null): boolean;
|
|
9
|
+
export declare function trimPortableConsentWhitespace(value: string): string;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
const encoder = new TextEncoder();
|
|
2
|
+
const MAX_CONSENT_ENVELOPE_BYTES = 2048;
|
|
3
|
+
const MAX_CONSENT_PURPOSES = 32;
|
|
4
|
+
const MAX_CONSENT_PURPOSE_BYTES = 128;
|
|
5
|
+
const MAX_CONSENT_SOURCE_BYTES = 512;
|
|
6
|
+
const MAX_CONSENT_POLICY_VERSION = 2147483647;
|
|
7
|
+
const RFC3339 = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|[+-]\d{2}:\d{2})$/;
|
|
8
|
+
const CONSENT_EDGE_WHITESPACE = /^[\t\n\v\f\r ]+|[\t\n\v\f\r ]+$/g;
|
|
9
|
+
const LEGACY_GRANTS = new Set([
|
|
10
|
+
'true',
|
|
11
|
+
'all',
|
|
12
|
+
'granted',
|
|
13
|
+
'yes',
|
|
14
|
+
'personalization',
|
|
15
|
+
'personalized',
|
|
16
|
+
]);
|
|
17
|
+
export function parsePortableConsentEnvelope(raw, decodeCookieValue = true) {
|
|
18
|
+
let decoded = trimPortableConsentWhitespace(raw);
|
|
19
|
+
if (!decoded)
|
|
20
|
+
return null;
|
|
21
|
+
if (decodeCookieValue) {
|
|
22
|
+
try {
|
|
23
|
+
decoded = decodeURIComponent(decoded);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// Evaluate the original cookie value.
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
decoded = trimPortableConsentWhitespace(decoded);
|
|
30
|
+
if (!decoded
|
|
31
|
+
|| encoder.encode(decoded).byteLength > MAX_CONSENT_ENVELOPE_BYTES) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
if (LEGACY_GRANTS.has(decoded.toLowerCase())) {
|
|
35
|
+
return {
|
|
36
|
+
version: 0,
|
|
37
|
+
purposes: ['personalization'],
|
|
38
|
+
grantedAt: '',
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const parsed = JSON.parse(decoded);
|
|
43
|
+
const grantedAt = typeof parsed.t === 'string' ? parsed.t : '';
|
|
44
|
+
if (!Number.isInteger(parsed.v)
|
|
45
|
+
|| parsed.v < 0
|
|
46
|
+
|| parsed.v > MAX_CONSENT_POLICY_VERSION
|
|
47
|
+
|| !Array.isArray(parsed.p)
|
|
48
|
+
|| parsed.p.length > MAX_CONSENT_PURPOSES
|
|
49
|
+
|| !parsed.p.every((item) => typeof item === 'string')
|
|
50
|
+
|| parsed.p.some((item) => encoder.encode(item).byteLength > MAX_CONSENT_PURPOSE_BYTES)
|
|
51
|
+
|| (parsed.t !== undefined
|
|
52
|
+
&& parsed.t !== null
|
|
53
|
+
&& (typeof parsed.t !== 'string'
|
|
54
|
+
|| parsed.t === ''
|
|
55
|
+
|| !isPortableRFC3339(parsed.t)))
|
|
56
|
+
|| (parsed.src !== undefined
|
|
57
|
+
&& parsed.src !== null
|
|
58
|
+
&& typeof parsed.src !== 'string')
|
|
59
|
+
|| (typeof parsed.src === 'string'
|
|
60
|
+
&& encoder.encode(parsed.src).byteLength > MAX_CONSENT_SOURCE_BYTES)) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
version: parsed.v,
|
|
65
|
+
purposes: parsed.p,
|
|
66
|
+
grantedAt,
|
|
67
|
+
source: typeof parsed.src === 'string' ? parsed.src : undefined,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
export function portableConsentAllowsPersonalization(snapshot) {
|
|
75
|
+
return snapshot?.purposes.some((purpose) => trimPortableConsentWhitespace(purpose).toLowerCase()
|
|
76
|
+
=== 'personalization') || false;
|
|
77
|
+
}
|
|
78
|
+
export function trimPortableConsentWhitespace(value) {
|
|
79
|
+
return value.replace(CONSENT_EDGE_WHITESPACE, '');
|
|
80
|
+
}
|
|
81
|
+
function isPortableRFC3339(value) {
|
|
82
|
+
const match = RFC3339.exec(value);
|
|
83
|
+
if (!match || Number.isNaN(Date.parse(value)))
|
|
84
|
+
return false;
|
|
85
|
+
const year = Number(match[1]);
|
|
86
|
+
const month = Number(match[2]);
|
|
87
|
+
const day = Number(match[3]);
|
|
88
|
+
const hour = Number(match[4]);
|
|
89
|
+
const minute = Number(match[5]);
|
|
90
|
+
const second = Number(match[6]);
|
|
91
|
+
if (month < 1
|
|
92
|
+
|| month > 12
|
|
93
|
+
|| day < 1
|
|
94
|
+
|| day > daysInMonth(year, month)
|
|
95
|
+
|| hour > 23
|
|
96
|
+
|| minute > 59
|
|
97
|
+
|| second > 59) {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
const zone = match[8];
|
|
101
|
+
return zone === 'Z'
|
|
102
|
+
|| (Number(zone.slice(1, 3)) <= 23
|
|
103
|
+
&& Number(zone.slice(4, 6)) <= 59);
|
|
104
|
+
}
|
|
105
|
+
function daysInMonth(year, month) {
|
|
106
|
+
if (month === 2) {
|
|
107
|
+
return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28;
|
|
108
|
+
}
|
|
109
|
+
return [4, 6, 9, 11].includes(month) ? 30 : 31;
|
|
110
|
+
}
|
|
@@ -45,7 +45,8 @@ export function mountConsentBanner(doc, opts = {}) {
|
|
|
45
45
|
};
|
|
46
46
|
const decide = (granted) => {
|
|
47
47
|
const purposes = granted ? [PERSONALIZATION_PURPOSE] : [];
|
|
48
|
-
applyConsent(purposes, config.source, { document: doc })
|
|
48
|
+
if (!applyConsent(purposes, config.source, { document: doc }))
|
|
49
|
+
return;
|
|
49
50
|
banner.classList.add(BANNER_HIDDEN);
|
|
50
51
|
setTimeout(cleanup, 300);
|
|
51
52
|
opts.onDecision?.(granted);
|
package/dist/content/assets.d.ts
CHANGED
package/dist/content/assets.js
CHANGED
|
@@ -19,6 +19,7 @@ export async function getReleaseAssetResponse(input) {
|
|
|
19
19
|
accessKeyId: config.accessKeyId,
|
|
20
20
|
secretAccessKey: config.secretAccessKey,
|
|
21
21
|
sessionToken: config.sessionToken,
|
|
22
|
+
roleArn: config.roleArn,
|
|
22
23
|
endpoint: config.endpoint,
|
|
23
24
|
});
|
|
24
25
|
const key = joinKey(provider.config.keyPrefix, 'assets', clean);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { type IdentitySigningKeys, type
|
|
1
|
+
import { type IdentitySigningKeys, type PersonalizationCollectionStore } from './personalization-host.js';
|
|
2
2
|
export interface PersonalizationCollectRouteConfig {
|
|
3
3
|
siteId: string;
|
|
4
|
-
store:
|
|
4
|
+
store: PersonalizationCollectionStore;
|
|
5
5
|
identityKeys: IdentitySigningKeys;
|
|
6
6
|
/**
|
|
7
7
|
* Claims verify keys (kid -> secret) for withdrawal EVIDENCE
|
|
@@ -1,10 +1,24 @@
|
|
|
1
1
|
import { MAX_BATCH_EVENTS } from '../collect/batcher.js';
|
|
2
|
-
import {
|
|
2
|
+
import { computeEnvelopeDigest } from './envelope-digest.js';
|
|
3
|
+
import { consentSignals, IdentityErasureFencedError, identityHashHex, parseCookieHeader, verifyClaims, verifyIdentityCookie, } from './personalization-host.js';
|
|
3
4
|
const MAX_COLLECT_BODY_BYTES = 64 * 1024;
|
|
4
5
|
const DEFAULT_RETENTION_SECONDS = 13 * 30 * 24 * 60 * 60;
|
|
5
|
-
const FORBIDDEN_FIELDS = new Set(['aciid', 'audienceids', 'segmentids', 'traits', 'identity']);
|
|
6
6
|
const ULID = /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/i;
|
|
7
7
|
const UTM_KEYS = ['source', 'medium', 'campaign', 'term', 'content', 'id'];
|
|
8
|
+
const ENVELOPE_FIELDS = new Set(['envelopeId', 'events']);
|
|
9
|
+
const EVENT_FIELDS = new Set([
|
|
10
|
+
'eventId',
|
|
11
|
+
'eventName',
|
|
12
|
+
'occurredAt',
|
|
13
|
+
'properties',
|
|
14
|
+
'url',
|
|
15
|
+
'referrer',
|
|
16
|
+
'title',
|
|
17
|
+
'path',
|
|
18
|
+
'utm',
|
|
19
|
+
'viewport',
|
|
20
|
+
'experiment',
|
|
21
|
+
]);
|
|
8
22
|
function identityHashShard(identityHash) {
|
|
9
23
|
const hash = identityHash.trim().toLowerCase();
|
|
10
24
|
if (hash.length < 2)
|
|
@@ -20,9 +34,22 @@ export function createPersonalizationCollectRoute(config) {
|
|
|
20
34
|
const bytes = new Uint8Array(await request.arrayBuffer());
|
|
21
35
|
if (bytes.byteLength > MAX_COLLECT_BODY_BYTES)
|
|
22
36
|
return new Response(null, { status: 413 });
|
|
37
|
+
let envelopeId;
|
|
23
38
|
let events;
|
|
24
39
|
try {
|
|
25
|
-
const envelope =
|
|
40
|
+
const envelope = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
|
|
41
|
+
if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)) {
|
|
42
|
+
throw new CollectValidationError('collect envelope must be an object');
|
|
43
|
+
}
|
|
44
|
+
for (const key of Object.keys(envelope)) {
|
|
45
|
+
if (!ENVELOPE_FIELDS.has(key)) {
|
|
46
|
+
throw new CollectValidationError(`field ${key} is not allowed on the collect envelope`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (typeof envelope.envelopeId !== 'string' || !ULID.test(envelope.envelopeId.trim())) {
|
|
50
|
+
throw new CollectValidationError('envelopeId must be a valid ULID');
|
|
51
|
+
}
|
|
52
|
+
envelopeId = envelope.envelopeId.trim().toUpperCase();
|
|
26
53
|
if (!Array.isArray(envelope.events))
|
|
27
54
|
throw new CollectValidationError('events must be an array');
|
|
28
55
|
// The TS runtime has no cross-instance quota checker (Go's REQ-14/
|
|
@@ -32,8 +59,18 @@ export function createPersonalizationCollectRoute(config) {
|
|
|
32
59
|
if (envelope.events.length > MAX_BATCH_EVENTS)
|
|
33
60
|
throw new CollectValidationError('too many events in one batch');
|
|
34
61
|
events = envelope.events;
|
|
35
|
-
|
|
62
|
+
const eventIds = new Set();
|
|
63
|
+
for (const event of events) {
|
|
36
64
|
validateEvent(event);
|
|
65
|
+
const eventId = event.eventId.trim().toUpperCase();
|
|
66
|
+
if (eventId === envelopeId) {
|
|
67
|
+
throw new CollectValidationError('envelopeId must be distinct from every eventId');
|
|
68
|
+
}
|
|
69
|
+
if (eventIds.has(eventId)) {
|
|
70
|
+
throw new CollectValidationError('eventId must be unique within an envelope');
|
|
71
|
+
}
|
|
72
|
+
eventIds.add(eventId);
|
|
73
|
+
}
|
|
37
74
|
}
|
|
38
75
|
catch (error) {
|
|
39
76
|
return validationResponse(error);
|
|
@@ -75,13 +112,21 @@ export function createPersonalizationCollectRoute(config) {
|
|
|
75
112
|
await putSuppression('withdrawal');
|
|
76
113
|
}
|
|
77
114
|
const identityHash = await identityHashHex('aci_id', rawIdentity);
|
|
78
|
-
|
|
115
|
+
const collectedAt = now.toISOString();
|
|
116
|
+
const expiresAt = new Date(now.getTime() +
|
|
117
|
+
(config.retentionSeconds ?? DEFAULT_RETENTION_SECONDS) * 1000).toISOString();
|
|
118
|
+
const consent = signals.snapshot
|
|
119
|
+
? {
|
|
120
|
+
v: signals.snapshot.version,
|
|
121
|
+
p: signals.snapshot.purposes,
|
|
122
|
+
...(signals.snapshot.grantedAt ? { t: signals.snapshot.grantedAt } : {}),
|
|
123
|
+
...(signals.snapshot.source ? { src: signals.snapshot.source } : {}),
|
|
124
|
+
}
|
|
125
|
+
: { v: 0, p: [] };
|
|
126
|
+
const records = events.map((event) => {
|
|
79
127
|
const normalized = normalizeEvent(event, now);
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const key = `audience/first-party/${identityHashShard(identityHash)}/${config.siteId}/aci_id/${identityHash}/${normalized.eventId}.json`;
|
|
83
|
-
await config.store.appendEvent(key, {
|
|
84
|
-
schemaVersion: 1,
|
|
128
|
+
return {
|
|
129
|
+
schemaVersion: 2,
|
|
85
130
|
eventId: normalized.eventId,
|
|
86
131
|
eventName: normalized.eventName,
|
|
87
132
|
eventCategory: normalized.eventCategory,
|
|
@@ -91,14 +136,7 @@ export function createPersonalizationCollectRoute(config) {
|
|
|
91
136
|
// an empty-t record permanently poisons that identity's
|
|
92
137
|
// classification and DSAR reads. Omit t unless a real timestamp
|
|
93
138
|
// was observed (legacy bare tokens have none).
|
|
94
|
-
consent
|
|
95
|
-
? {
|
|
96
|
-
v: signals.snapshot.version,
|
|
97
|
-
p: signals.snapshot.purposes,
|
|
98
|
-
...(signals.snapshot.grantedAt ? { t: signals.snapshot.grantedAt } : {}),
|
|
99
|
-
...(signals.snapshot.source ? { src: signals.snapshot.source } : {}),
|
|
100
|
-
}
|
|
101
|
-
: { v: 0, p: [] },
|
|
139
|
+
consent,
|
|
102
140
|
...(normalized.properties ? { properties: normalized.properties } : {}),
|
|
103
141
|
page: normalized.page,
|
|
104
142
|
...(normalized.utm ? { utm: normalized.utm } : {}),
|
|
@@ -107,7 +145,40 @@ export function createPersonalizationCollectRoute(config) {
|
|
|
107
145
|
occurredAt: normalized.occurredAt,
|
|
108
146
|
collectedAt,
|
|
109
147
|
expiresAt,
|
|
148
|
+
};
|
|
149
|
+
});
|
|
150
|
+
if (records.length > 0) {
|
|
151
|
+
const key = `audience/first-party/${identityHashShard(identityHash)}/${config.siteId}/aci_id/${identityHash}/${envelopeId}.json`;
|
|
152
|
+
const digest = await computeEnvelopeDigest({
|
|
153
|
+
siteId: config.siteId,
|
|
154
|
+
acceptedBody: bytes,
|
|
155
|
+
identity: { namespace: 'aci_id', hash: identityHash },
|
|
156
|
+
consent: {
|
|
157
|
+
version: signals.snapshot?.version ?? 0,
|
|
158
|
+
purposes: signals.snapshot?.purposes ?? [],
|
|
159
|
+
grantedAt: signals.snapshot?.grantedAt,
|
|
160
|
+
source: signals.snapshot?.source,
|
|
161
|
+
},
|
|
110
162
|
});
|
|
163
|
+
try {
|
|
164
|
+
await config.store.appendEnvelope({
|
|
165
|
+
key,
|
|
166
|
+
envelopeId,
|
|
167
|
+
siteId: config.siteId,
|
|
168
|
+
identityNamespace: 'aci_id',
|
|
169
|
+
identityHash,
|
|
170
|
+
records,
|
|
171
|
+
digest,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
if (error instanceof IdentityErasureFencedError) {
|
|
176
|
+
if (cookies.has('aci_claims'))
|
|
177
|
+
appendCookie(responseHeaders, expiredClaimsCookie(secure));
|
|
178
|
+
return accepted(responseHeaders);
|
|
179
|
+
}
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
111
182
|
}
|
|
112
183
|
if (personalization) {
|
|
113
184
|
const claims = await config.store.readClaims(config.siteId, 'aci_id', identityHash);
|
|
@@ -131,7 +202,7 @@ function validateEvent(value) {
|
|
|
131
202
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
132
203
|
throw new CollectValidationError('event must be an object');
|
|
133
204
|
for (const key of Object.keys(value)) {
|
|
134
|
-
if (
|
|
205
|
+
if (!EVENT_FIELDS.has(key))
|
|
135
206
|
throw new CollectValidationError(`field ${key} is not allowed on the public collect route`);
|
|
136
207
|
}
|
|
137
208
|
if (typeof value.eventId !== 'string' || !ULID.test(value.eventId.trim()))
|
|
@@ -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
|
};
|