@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
|
@@ -45,6 +45,8 @@ interface ListenerTarget {
|
|
|
45
45
|
export interface BatcherOptions {
|
|
46
46
|
/** Transport for one serialized batch envelope. */
|
|
47
47
|
send: (body: string) => void;
|
|
48
|
+
/** Injectable only so tests can prove one ID is minted per sealed batch. */
|
|
49
|
+
createEnvelopeId?: () => string;
|
|
48
50
|
debug?: DebugFn;
|
|
49
51
|
/** Runs at the start of every flush tick (gap-buffer marker re-check). */
|
|
50
52
|
onTick?: () => void;
|
package/dist/collect/batcher.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// Batcher (design/05): queue events, split so no request exceeds the 60 KiB
|
|
2
|
-
// planning threshold (64 KiB wire ceiling),
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// planning threshold (64 KiB wire ceiling), mint one envelope ULID per sealed
|
|
3
|
+
// batch, and flush on start, size, interval, visibilitychange and pagehide.
|
|
4
|
+
// Byte accounting is incremental — a running per-event size computed at
|
|
5
|
+
// enqueue — so a flush stays O(n).
|
|
6
|
+
import { ulid } from "./ulid.js";
|
|
5
7
|
/** Planning threshold: full serialized envelope, UTF-8 bytes. */
|
|
6
8
|
export const MAX_BATCH_BYTES = 60 * 1024;
|
|
7
9
|
/**
|
|
@@ -14,9 +16,14 @@ export const MAX_BATCH_BYTES = 60 * 1024;
|
|
|
14
16
|
export const MAX_BATCH_EVENTS = 100;
|
|
15
17
|
/** Flush interval (build-time constant, design/04 config table). */
|
|
16
18
|
export const FLUSH_INTERVAL_MS = 5000;
|
|
17
|
-
const ENVELOPE_PREFIX = '{"
|
|
19
|
+
const ENVELOPE_PREFIX = '{"envelopeId":"';
|
|
20
|
+
const EVENTS_PREFIX = '","events":[';
|
|
18
21
|
const ENVELOPE_SUFFIX = "]}";
|
|
19
|
-
const
|
|
22
|
+
const ENVELOPE_ID_BYTES = 26;
|
|
23
|
+
const ENVELOPE_OVERHEAD = ENVELOPE_PREFIX.length +
|
|
24
|
+
ENVELOPE_ID_BYTES +
|
|
25
|
+
EVENTS_PREFIX.length +
|
|
26
|
+
ENVELOPE_SUFFIX.length;
|
|
20
27
|
const encoder = new TextEncoder();
|
|
21
28
|
const utf8ByteLength = (s) => encoder.encode(s).length;
|
|
22
29
|
export class Batcher {
|
|
@@ -131,7 +138,12 @@ export class Batcher {
|
|
|
131
138
|
}
|
|
132
139
|
if (parts.length === 0)
|
|
133
140
|
continue;
|
|
134
|
-
this.options.
|
|
141
|
+
const envelopeId = (this.options.createEnvelopeId ?? ulid)();
|
|
142
|
+
this.options.send(ENVELOPE_PREFIX +
|
|
143
|
+
envelopeId +
|
|
144
|
+
EVENTS_PREFIX +
|
|
145
|
+
parts.join(",") +
|
|
146
|
+
ENVELOPE_SUFFIX);
|
|
135
147
|
}
|
|
136
148
|
}
|
|
137
149
|
}
|
|
@@ -21,16 +21,17 @@ export interface ApplyConsentDeps extends ConsentWriteDeps {
|
|
|
21
21
|
}
|
|
22
22
|
/**
|
|
23
23
|
* writeConsent builds the {v:1, p, t, src} envelope, URL-encodes it and sets
|
|
24
|
-
* the aci_consent cookie (Path=/, SameSite=Lax, Secure on https).
|
|
24
|
+
* the aci_consent cookie (Path=/, SameSite=Lax, Secure on https). Inputs that
|
|
25
|
+
* cannot produce a portable envelope are ignored.
|
|
25
26
|
*/
|
|
26
|
-
export declare function writeConsent(purposes: string[], src: string, deps: ConsentWriteDeps):
|
|
27
|
+
export declare function writeConsent(purposes: string[], src: string, deps: ConsentWriteDeps): boolean;
|
|
27
28
|
/**
|
|
28
29
|
* applyConsent is the single write path for every consent decision — CMP
|
|
29
30
|
* adapters and the manual JS API alike. Any decision where personalization
|
|
30
31
|
* is absent purges the gap buffer (REQ-8), and the envelope is still
|
|
31
32
|
* written: withdrawal must reach the server.
|
|
32
33
|
*/
|
|
33
|
-
export declare function applyConsent(purposes: string[], src: string, deps: ApplyConsentDeps):
|
|
34
|
+
export declare function applyConsent(purposes: string[], src: string, deps: ApplyConsentDeps): boolean;
|
|
34
35
|
/** readConsentCookie returns the raw (still URL-encoded) cookie value. */
|
|
35
36
|
export declare function readConsentCookie(doc: {
|
|
36
37
|
cookie: string;
|
|
@@ -47,3 +48,8 @@ export declare function envelopeGrantsPersonalization(raw: string | undefined):
|
|
|
47
48
|
* and the command is ignored.
|
|
48
49
|
*/
|
|
49
50
|
export declare function sanitizePurposes(raw: unknown): string[] | undefined;
|
|
51
|
+
/**
|
|
52
|
+
* sanitizeConsentSource preserves the manual API's default while applying the
|
|
53
|
+
* same portable whitespace and byte bounds as every envelope reader.
|
|
54
|
+
*/
|
|
55
|
+
export declare function sanitizeConsentSource(raw: unknown): string | undefined;
|
|
@@ -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);
|
|
@@ -12,6 +12,7 @@ export async function resolveSlots(layout, pageRegions, getFragment) {
|
|
|
12
12
|
for (const ref of defaults) {
|
|
13
13
|
const fragment = await getFragment(ref.fragmentId);
|
|
14
14
|
blocks.push({
|
|
15
|
+
$type: 'component',
|
|
15
16
|
id: fragment.id,
|
|
16
17
|
component: fragment.component,
|
|
17
18
|
props: fragment.props || {},
|
package/dist/content/types.d.ts
CHANGED
|
@@ -42,11 +42,13 @@ export interface KernelPageSeo {
|
|
|
42
42
|
canonical?: string;
|
|
43
43
|
}
|
|
44
44
|
export interface KernelPageBlock {
|
|
45
|
+
$type: 'component';
|
|
45
46
|
id: string;
|
|
46
47
|
component: string;
|
|
47
48
|
props: Record<string, unknown>;
|
|
48
49
|
}
|
|
49
50
|
export interface KernelAtomicBlock {
|
|
51
|
+
$type: 'component';
|
|
50
52
|
id: string;
|
|
51
53
|
component: string;
|
|
52
54
|
props: Record<string, unknown>;
|
|
@@ -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()))
|