@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.
Files changed (63) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +4 -4
  3. package/dist/audience-data-models.d.ts +286 -0
  4. package/dist/audience-data-models.js +46 -0
  5. package/dist/cli/compile-registry.d.mts +5 -1
  6. package/dist/cli/compile-registry.mjs +25 -10
  7. package/dist/cli/validate-content.d.mts +31 -1
  8. package/dist/cli/validate-content.mjs +50 -12
  9. package/dist/collect/aci.js +1 -0
  10. package/dist/collect/aci.js.sha256 +1 -0
  11. package/dist/collect/batcher.d.ts +2 -0
  12. package/dist/collect/batcher.js +18 -6
  13. package/dist/collect/consent/envelope.d.ts +9 -3
  14. package/dist/collect/consent/envelope.js +58 -40
  15. package/dist/collect/index.js +9 -2
  16. package/dist/collect/linkclicks.js +4 -2
  17. package/dist/collect/transport.d.ts +2 -0
  18. package/dist/collect/transport.js +6 -3
  19. package/dist/consent/portable-envelope.d.ts +9 -0
  20. package/dist/consent/portable-envelope.js +110 -0
  21. package/dist/consent-banner/index.js +2 -1
  22. package/dist/content/assets.d.ts +1 -0
  23. package/dist/content/assets.js +1 -0
  24. package/dist/content/resolve-slots.js +1 -0
  25. package/dist/content/types.d.ts +2 -0
  26. package/dist/next/collect-route.d.ts +2 -2
  27. package/dist/next/collect-route.js +90 -19
  28. package/dist/next/envelope-digest.d.ts +21 -0
  29. package/dist/next/envelope-digest.js +58 -0
  30. package/dist/next/middleware.d.ts +16 -2
  31. package/dist/next/middleware.js +190 -10
  32. package/dist/next/personalization-host.d.ts +16 -7
  33. package/dist/next/personalization-host.js +14 -33
  34. package/dist/next/server.d.ts +1 -0
  35. package/dist/next/server.js +3 -0
  36. package/dist/next/vercel-collect-sdk-route.d.ts +27 -0
  37. package/dist/next/vercel-collect-sdk-route.js +205 -0
  38. package/dist/next/vercel-personalization.d.ts +3 -2
  39. package/dist/next/vercel-personalization.js +3 -2
  40. package/dist/next/vercel-s3-personalization.d.ts +25 -4
  41. package/dist/next/vercel-s3-personalization.js +368 -14
  42. package/dist/providers/s3.d.ts +19 -4
  43. package/dist/providers/s3.js +92 -22
  44. package/dist/testing/conformance/checks.js +75 -4
  45. package/dist/types/block-ref.d.ts +2 -0
  46. package/dist/types/block-ref.js +1 -0
  47. package/package.json +27 -16
  48. package/src/cli/compile-registry.mjs +24 -10
  49. package/src/cli/validate-content.mjs +52 -13
  50. package/src/types/block-ref.test.ts +45 -0
  51. package/src/types/block-ref.ts +2 -0
  52. package/templates/astro/starter-content/src/fixtures/atomicBlocks.ts +7 -6
  53. package/templates/astro/starter-content/src/fixtures/composition.ts +3 -2
  54. package/templates/astro/starter-content/src/fixtures/sections.ts +2 -1
  55. package/templates/common/.agents/skills/authoring/SKILL.md +11 -3
  56. package/templates/common/.agents/skills/authoring/references/block-slots.md +3 -0
  57. package/templates/common/.content/pages/home/_index.json +10 -0
  58. package/templates/common/starter-content/.content/pages/about/_index.json +3 -0
  59. package/templates/common/starter-content/.content/pages/home/_index.json +9 -0
  60. package/templates/nextjs/starter-content/src/middleware.ts +31 -1
  61. package/templates/nextjs/template/src/app/collect/route.ts +2 -2
  62. package/templates/nextjs/template/src/middleware.ts +31 -1
  63. 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;
@@ -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), flush on start, size, interval,
3
- // visibilitychange and pagehide. Byte accounting is incremental — a running
4
- // per-event size computed at enqueue — so a flush stays O(n).
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 = '{"events":[';
19
+ const ENVELOPE_PREFIX = '{"envelopeId":"';
20
+ const EVENTS_PREFIX = '","events":[';
18
21
  const ENVELOPE_SUFFIX = "]}";
19
- const ENVELOPE_OVERHEAD = ENVELOPE_PREFIX.length + ENVELOPE_SUFFIX.length;
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.send(ENVELOPE_PREFIX + parts.join(",") + ENVELOPE_SUFFIX);
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): void;
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): void;
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 envelope = {
25
- v: CONSENT_ENVELOPE_VERSION,
26
- p: purposes,
27
- t: new Date((deps.now ?? Date.now)()).toISOString(),
28
- src,
29
- };
30
- const value = encodeURIComponent(JSON.stringify(envelope));
31
- let cookie = `${CONSENT_COOKIE}=${value}; Path=/; Max-Age=${CONSENT_MAX_AGE_S}; SameSite=Lax`;
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
- if (!purposes.includes(PERSONALIZATION_PURPOSE)) {
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
- writeConsent(purposes, src, deps);
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.trim();
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
- let decoded = raw;
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.trim());
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
  }
@@ -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
- applyConsent(purposes, typeof src === "string" && src.trim() !== "" ? src.trim() : "manual", { document: doc, gapBuffer });
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. Errors are swallowed silently.
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
- // eventIds make a future retry layer safe. The path is RELATIVE so requests
4
- // stay exactly same-origin (data-plane design/04).
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 body = new Blob(['{"events":[]}'], { type: "text/plain" });
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);
@@ -5,6 +5,7 @@ export interface ReleaseAssetConfig {
5
5
  accessKeyId?: string;
6
6
  secretAccessKey?: string;
7
7
  sessionToken?: string;
8
+ roleArn?: string;
8
9
  endpoint?: string;
9
10
  }
10
11
  export declare class ReleaseAssetNotFoundError extends Error {
@@ -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 || {},
@@ -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 PersonalizationBlobStore } from './personalization-host.js';
1
+ import { type IdentitySigningKeys, type PersonalizationCollectionStore } from './personalization-host.js';
2
2
  export interface PersonalizationCollectRouteConfig {
3
3
  siteId: string;
4
- store: PersonalizationBlobStore;
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 { consentSignals, identityHashHex, parseCookieHeader, verifyClaims, verifyIdentityCookie, } from './personalization-host.js';
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 = bytes.byteLength === 0 ? { events: [] } : JSON.parse(new TextDecoder().decode(bytes));
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
- for (const event of events)
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
- for (const event of events) {
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
- const collectedAt = now.toISOString();
81
- const expiresAt = new Date(now.getTime() + (config.retentionSeconds ?? DEFAULT_RETENTION_SECONDS) * 1000).toISOString();
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: signals.snapshot
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 (FORBIDDEN_FIELDS.has(key.toLowerCase()))
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()))