@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.
Files changed (44) hide show
  1. package/CHANGELOG.md +16 -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/collect/aci.js +1 -0
  6. package/dist/collect/aci.js.sha256 +1 -0
  7. package/dist/collect/batcher.d.ts +2 -0
  8. package/dist/collect/batcher.js +18 -6
  9. package/dist/collect/consent/envelope.d.ts +9 -3
  10. package/dist/collect/consent/envelope.js +58 -40
  11. package/dist/collect/index.js +9 -2
  12. package/dist/collect/linkclicks.js +4 -2
  13. package/dist/collect/transport.d.ts +2 -0
  14. package/dist/collect/transport.js +6 -3
  15. package/dist/consent/portable-envelope.d.ts +9 -0
  16. package/dist/consent/portable-envelope.js +110 -0
  17. package/dist/consent-banner/index.js +2 -1
  18. package/dist/content/assets.d.ts +1 -0
  19. package/dist/content/assets.js +1 -0
  20. package/dist/next/collect-route.d.ts +2 -2
  21. package/dist/next/collect-route.js +90 -19
  22. package/dist/next/envelope-digest.d.ts +21 -0
  23. package/dist/next/envelope-digest.js +58 -0
  24. package/dist/next/middleware.d.ts +16 -2
  25. package/dist/next/middleware.js +190 -10
  26. package/dist/next/personalization-host.d.ts +16 -7
  27. package/dist/next/personalization-host.js +14 -33
  28. package/dist/next/server.d.ts +1 -0
  29. package/dist/next/server.js +3 -0
  30. package/dist/next/vercel-collect-sdk-route.d.ts +27 -0
  31. package/dist/next/vercel-collect-sdk-route.js +205 -0
  32. package/dist/next/vercel-personalization.d.ts +3 -2
  33. package/dist/next/vercel-personalization.js +3 -2
  34. package/dist/next/vercel-s3-personalization.d.ts +25 -4
  35. package/dist/next/vercel-s3-personalization.js +368 -14
  36. package/dist/providers/s3.d.ts +19 -4
  37. package/dist/providers/s3.js +92 -22
  38. package/dist/testing/conformance/checks.js +75 -4
  39. package/package.json +27 -16
  40. package/src/cli/validate-content.mjs +0 -0
  41. package/templates/nextjs/starter-content/src/middleware.ts +31 -1
  42. package/templates/nextjs/template/src/app/collect/route.ts +2 -2
  43. package/templates/nextjs/template/src/middleware.ts +31 -1
  44. 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 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);
@@ -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()))
@@ -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
  };