@gradial/aci 0.1.41 → 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 (134) hide show
  1. package/CHANGELOG.md +15 -11
  2. package/README.md +4 -4
  3. package/dist/astro/index.d.ts +1 -1
  4. package/dist/astro/index.js +1 -1
  5. package/dist/astro/page.d.ts +6 -0
  6. package/dist/astro/page.js +3 -2
  7. package/dist/audience-data-models.d.ts +286 -0
  8. package/dist/audience-data-models.js +46 -0
  9. package/dist/cli/compile-registry.d.mts +6 -0
  10. package/dist/cli/compile-registry.mjs +774 -0
  11. package/dist/cli/materialize-template.d.mts +19 -0
  12. package/dist/cli/materialize-template.mjs +128 -0
  13. package/dist/cli/validate-content.d.mts +32 -0
  14. package/dist/cli/validate-content.mjs +464 -0
  15. package/dist/cli/verify-next-cdn-routes.d.mts +2 -0
  16. package/dist/cli/verify-next-cdn-routes.mjs +47 -0
  17. package/dist/collect/aci.js +1 -0
  18. package/dist/collect/aci.js.sha256 +1 -0
  19. package/dist/collect/assignments.d.ts +65 -0
  20. package/dist/collect/assignments.js +138 -0
  21. package/dist/collect/batcher.d.ts +23 -0
  22. package/dist/collect/batcher.js +28 -6
  23. package/dist/collect/consent/envelope.d.ts +9 -3
  24. package/dist/collect/consent/envelope.js +58 -40
  25. package/dist/collect/declarative.d.ts +1 -1
  26. package/dist/collect/declarative.js +7 -1
  27. package/dist/collect/index.js +39 -6
  28. package/dist/collect/linkclicks.js +4 -2
  29. package/dist/collect/pageviews.d.ts +15 -3
  30. package/dist/collect/pageviews.js +28 -13
  31. package/dist/collect/transport.d.ts +4 -0
  32. package/dist/collect/transport.js +37 -2
  33. package/dist/consent/portable-envelope.d.ts +9 -0
  34. package/dist/consent/portable-envelope.js +110 -0
  35. package/dist/consent-banner/index.d.ts +33 -0
  36. package/dist/consent-banner/index.js +100 -0
  37. package/dist/content/assets.d.ts +1 -0
  38. package/dist/content/assets.js +1 -0
  39. package/dist/content/personalization.d.ts +35 -0
  40. package/dist/content/personalization.js +57 -0
  41. package/dist/content/resolve-slots.js +1 -0
  42. package/dist/content/types.d.ts +9 -1
  43. package/dist/edge/personalization-core.d.ts +11 -0
  44. package/dist/edge/personalization-core.js +265 -0
  45. package/dist/index.d.ts +1 -0
  46. package/dist/index.js +1 -0
  47. package/dist/next/assignments-script.d.ts +14 -0
  48. package/dist/next/assignments-script.js +17 -0
  49. package/dist/next/collect-route.d.ts +19 -0
  50. package/dist/next/collect-route.js +422 -0
  51. package/dist/next/envelope-digest.d.ts +21 -0
  52. package/dist/next/envelope-digest.js +58 -0
  53. package/dist/next/index.d.ts +1 -0
  54. package/dist/next/index.js +1 -0
  55. package/dist/next/middleware.d.ts +31 -3
  56. package/dist/next/middleware.js +254 -30
  57. package/dist/next/page.js +2 -1
  58. package/dist/next/personalization-host.d.ts +127 -0
  59. package/dist/next/personalization-host.js +257 -0
  60. package/dist/next/server.d.ts +1 -0
  61. package/dist/next/server.js +9 -6
  62. package/dist/next/vercel-collect-sdk-route.d.ts +27 -0
  63. package/dist/next/vercel-collect-sdk-route.js +205 -0
  64. package/dist/next/vercel-personalization.d.ts +4 -0
  65. package/dist/next/vercel-personalization.js +4 -0
  66. package/dist/next/vercel-s3-personalization.d.ts +79 -0
  67. package/dist/next/vercel-s3-personalization.js +574 -0
  68. package/dist/providers/s3.d.ts +20 -5
  69. package/dist/providers/s3.js +93 -23
  70. package/dist/react/VideoPlayer.d.ts +6 -6
  71. package/dist/react/index.d.ts +6 -6
  72. package/dist/testing/conformance/checks.js +75 -4
  73. package/dist/types/block-ref.d.ts +2 -0
  74. package/dist/types/block-ref.js +1 -0
  75. package/dist/types/page.d.ts +1 -1
  76. package/package.json +44 -15
  77. package/src/astro/ConsentBanner.astro +62 -0
  78. package/src/cli/compile-registry.mjs +24 -10
  79. package/src/cli/validate-content.mjs +52 -13
  80. package/src/cli/verify-next-cdn-routes.mjs +5 -4
  81. package/src/consent-banner/styles.css +110 -0
  82. package/src/types/block-ref.test.ts +45 -0
  83. package/src/types/block-ref.ts +2 -0
  84. package/src/types/page.ts +1 -1
  85. package/templates/astro/starter-content/src/cms/contracts/components/index.ts +2 -0
  86. package/templates/astro/starter-content/src/cms/contracts/components/personalizationChoice.contract.ts +17 -0
  87. package/templates/astro/starter-content/src/cms/registry.ts +3 -0
  88. package/templates/astro/starter-content/src/components/sections/PersonalizationChoice.astro +41 -0
  89. package/templates/astro/starter-content/src/fixtures/atomicBlocks.ts +7 -6
  90. package/templates/astro/starter-content/src/fixtures/composition.ts +3 -2
  91. package/templates/astro/starter-content/src/fixtures/sections.ts +2 -1
  92. package/templates/astro/starter-content/src/render/RenderPage.astro +3 -2
  93. package/templates/astro/template/src/layouts/SiteLayout.astro +3 -1
  94. package/templates/astro/template/src/pages/[...slug].astro +1 -0
  95. package/templates/common/.agents/skills/authoring/SKILL.md +11 -3
  96. package/templates/common/.agents/skills/authoring/references/block-slots.md +3 -0
  97. package/templates/common/.content/audiences/preference-blue/_index.json +11 -0
  98. package/templates/common/.content/audiences/preference-red/_index.json +11 -0
  99. package/templates/common/.content/overlays/audience/preference-blue/pages/home/_index.json +13 -0
  100. package/templates/common/.content/overlays/audience/preference-red/pages/home/_index.json +13 -0
  101. package/templates/common/.content/pages/home/_index.json +124 -4
  102. package/templates/common/.content/segments/preference-blue/_index.json +20 -0
  103. package/templates/common/.content/segments/preference-red/_index.json +20 -0
  104. package/templates/common/contracts/components/index.ts +21 -1
  105. package/templates/common/contracts/components/personalizationChoice.contract.ts +17 -0
  106. package/templates/common/starter-content/.content/pages/about/_index.json +3 -0
  107. package/templates/common/starter-content/.content/pages/home/_index.json +9 -0
  108. package/templates/nextjs/starter-content/src/cms/contracts/components/index.ts +3 -1
  109. package/templates/nextjs/starter-content/src/cms/contracts/components/personalizationChoice.contract.ts +17 -0
  110. package/templates/nextjs/starter-content/src/cms/registry.ts +3 -0
  111. package/templates/nextjs/starter-content/src/components/sections/PersonalizationChoice.tsx +53 -0
  112. package/templates/nextjs/starter-content/src/middleware.ts +41 -0
  113. package/templates/nextjs/template/src/app/collect/route.ts +18 -0
  114. package/templates/nextjs/template/src/middleware.ts +36 -2
  115. package/templates/nextjs/template/src/middleware.ts.tmpl +31 -1
  116. package/templates/astro/template/.aci.yaml +0 -28
  117. package/templates/common/.content/.gradial-dam/blobs/sha256/7f/bf/7fbfc7fe230ff12688c496f660119e41d9725c4504d82e310c15665c73c09292 +0 -0
  118. package/templates/common/.content/.gradial-dam/blobs/sha256/a0/f5/a0f506689de9b15a7055f70103a3f02cd3ba105771ae2de6754df2ede1210d3a +0 -0
  119. package/templates/common/.content/.gradial-dam/blobs/sha256/dd/b7/ddb7e9642be08317bcd4b2bd26b58ba8d0d34dd7239e03e684a4f3634fa3845b +0 -0
  120. package/templates/common/.content/.gradial-dam/blobs/sha256/fb/32/fb3260e8286ea8231ea1c77b9c0be92d6edd9026565731eb14cfdcfd6b1b75b3 +0 -0
  121. package/templates/common/.content/config/robots.txt +0 -2
  122. package/templates/common/.content/config/site.json +0 -197
  123. package/templates/common/.content/dam/folders/seed/ast_site_about_hero/asset.json +0 -9
  124. package/templates/common/.content/dam/folders/seed/ast_site_about_hero/editorial.json +0 -14
  125. package/templates/common/.content/dam/folders/seed/ast_site_about_hero/governance.json +0 -23
  126. package/templates/common/.content/dam/folders/seed/ast_site_about_hero/versions/v0001.json +0 -37
  127. package/templates/common/.content/dam/folders/seed/ast_site_home_hero/asset.json +0 -9
  128. package/templates/common/.content/dam/folders/seed/ast_site_home_hero/editorial.json +0 -14
  129. package/templates/common/.content/dam/folders/seed/ast_site_home_hero/governance.json +0 -23
  130. package/templates/common/.content/dam/folders/seed/ast_site_home_hero/versions/v0001.json +0 -37
  131. package/templates/common/.content/fragments/navbar/_index.json +0 -25
  132. package/templates/common/.content/fragments/site-footer/_index.json +0 -62
  133. package/templates/common/.content/pages/about/_index.json +0 -51
  134. package/templates/nextjs/template/.aci.yaml +0 -26
@@ -3,18 +3,19 @@
3
3
  // init(); every failure is swallowed after a debug record so the page is
4
4
  // never affected (QR-2/3).
5
5
  import { installCommandQueue } from "./api.js";
6
+ import { createAssignments, withAssignmentContext } from "./assignments.js";
6
7
  import { Batcher } from "./batcher.js";
7
8
  import { createDebug, readTagConfig } from "./config.js";
8
9
  import { start as startC15t } from "./consent/adapters/c15t.js";
9
10
  import { start as startCookiebot } from "./consent/adapters/cookiebot.js";
10
11
  import { start as startOneTrust } from "./consent/adapters/onetrust.js";
11
12
  import { start as startTrustArc } from "./consent/adapters/trustarc.js";
12
- import { applyConsent, envelopeGrantsPersonalization, readConsentCookie, sanitizePurposes, } from "./consent/envelope.js";
13
+ import { applyConsent, envelopeGrantsPersonalization, readConsentCookie, sanitizeConsentSource, sanitizePurposes, } from "./consent/envelope.js";
13
14
  import { startDeclarative } from "./declarative.js";
14
15
  import { GapBuffer, hasMintMarker, maybeFlushGapBuffer, routeEvent, } from "./gapbuffer.js";
15
16
  import { startLinkClicks } from "./linkclicks.js";
16
- import { startPageViews } from "./pageviews.js";
17
- import { send } from "./transport.js";
17
+ import { installNavigationHook, startPageViews, } from "./pageviews.js";
18
+ import { refreshClaims, send } from "./transport.js";
18
19
  import { ulid } from "./ulid.js";
19
20
  import { startVariationViews } from "./variationviews.js";
20
21
  import { startWebVitals } from "./webvitals.js";
@@ -94,12 +95,24 @@ export function init(win) {
94
95
  documentTarget: doc,
95
96
  windowTarget: w,
96
97
  });
98
+ // While an assignment is active, every emitted event carries the first
99
+ // active assignment's experiment context (REQ-3) unless it already has
100
+ // one — exposures and explicit conversions carry their own. The handle
101
+ // is created (without emitting) BEFORE emit so every dependency of emit
102
+ // exists before emit can run; exposures flow at start() below.
103
+ const assignments = createAssignments({ document: doc, window: w, debug });
97
104
  const emit = (event) => {
98
105
  routeEvent({
99
- ...event,
106
+ ...withAssignmentContext(event, assignments),
100
107
  eventId: ulid(undefined, (bytes) => w.crypto.getRandomValues(bytes)),
101
108
  }, { grants, marker, gapBuffer, batcher });
102
109
  };
110
+ // Started BEFORE the command-queue drain and page views: pre-load
111
+ // aci('track', ...) calls and the initial rum.page_view must be stamped
112
+ // with the active assignment context, or queued conversions can never
113
+ // match a goal (review-quorum finding). Consent commands drained after
114
+ // this stay safe — events wait in the gap buffer until grants resolve.
115
+ assignments.start(emit);
103
116
  installCommandQueue(w, cfg, {
104
117
  track: (name, properties) => emit({ eventName: name, properties }),
105
118
  consent: ({ p, src }) => {
@@ -108,14 +121,34 @@ export function init(win) {
108
121
  debug("consent", { reason: "malformed_purposes" });
109
122
  return;
110
123
  }
111
- 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
+ }
112
132
  },
113
133
  }, debug);
114
- startPageViews(emit, w);
134
+ const emitPageView = startPageViews(emit, w);
135
+ // The single history patch (one pushState/replaceState wrap, one dedupe
136
+ // state, one popstate listener). Order is load-bearing: the assignments
137
+ // re-arm runs BEFORE the SPA page view so rum.page_view is stamped with
138
+ // the new page's experiment context.
139
+ installNavigationHook(w, [
140
+ () => assignments.rearm(),
141
+ emitPageView,
142
+ ]);
115
143
  startDeclarative(emit, {
116
144
  document: doc,
117
145
  intersectionObserver: w.IntersectionObserver,
118
146
  debug,
147
+ }, () => {
148
+ void refreshClaims(transportEnv).then((refreshed) => {
149
+ if (refreshed)
150
+ w.location.reload();
151
+ });
119
152
  });
120
153
  startLinkClicks(emit, { document: doc, debug });
121
154
  startWebVitals(emit, {
@@ -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
  }
@@ -15,8 +15,20 @@ export interface PageViewWindow {
15
15
  addEventListener(type: string, fn: () => void): void;
16
16
  }
17
17
  /**
18
- * startPageViews emits the initial page view and hooks
19
- * pushState/replaceState/popstate for SPA route changes.
18
+ * installNavigationHook wires pushState/replaceState/popstate and invokes
19
+ * the subscribers, in order, once per SPA route change; hash-only changes
20
+ * and same-URL repeats never fire. Installed exactly ONCE by init — one
21
+ * history patch, one dedupe state — and shared by the assignments collector
22
+ * and page views so "page view" means exactly the same thing to both
23
+ * (REQ-2). Subscriber order is the contract: init lists the assignments
24
+ * re-arm BEFORE the page-view emitter so rum.page_view is stamped with the
25
+ * new page's experiment context.
20
26
  */
21
- export declare function startPageViews(emit: (event: PendingEvent) => void, win: PageViewWindow): void;
27
+ export declare function installNavigationHook(win: PageViewWindow, subscribers: ReadonlyArray<() => void>): void;
28
+ /**
29
+ * startPageViews emits the initial page view and returns the emitter for
30
+ * SPA route-change views — init subscribes it to the single navigation hook
31
+ * (after the assignments re-arm; see installNavigationHook).
32
+ */
33
+ export declare function startPageViews(emit: (event: PendingEvent) => void, win: PageViewWindow): () => void;
22
34
  export {};
@@ -9,34 +9,49 @@ function stripHash(href) {
9
9
  return i === -1 ? href : href.slice(0, i);
10
10
  }
11
11
  /**
12
- * startPageViews emits the initial page view and hooks
13
- * pushState/replaceState/popstate for SPA route changes.
12
+ * installNavigationHook wires pushState/replaceState/popstate and invokes
13
+ * the subscribers, in order, once per SPA route change; hash-only changes
14
+ * and same-URL repeats never fire. Installed exactly ONCE by init — one
15
+ * history patch, one dedupe state — and shared by the assignments collector
16
+ * and page views so "page view" means exactly the same thing to both
17
+ * (REQ-2). Subscriber order is the contract: init lists the assignments
18
+ * re-arm BEFORE the page-view emitter so rum.page_view is stamped with the
19
+ * new page's experiment context.
14
20
  */
15
- export function startPageViews(emit, win) {
16
- const view = () => emit({
17
- eventName: PAGE_VIEW_EVENT,
18
- url: win.location.href,
19
- referrer: win.document.referrer,
20
- });
21
+ export function installNavigationHook(win, subscribers) {
21
22
  let last = stripHash(win.location.href);
22
- view();
23
- const onNavigate = () => {
23
+ const handler = () => {
24
24
  const current = stripHash(win.location.href);
25
25
  if (current === last)
26
26
  return; // hash-only change or same-URL repeat
27
27
  last = current;
28
- view();
28
+ for (const onNavigate of subscribers)
29
+ onNavigate();
29
30
  };
30
31
  const history = win.history;
31
32
  const wrap = (method) => {
32
33
  const original = history[method].bind(history);
33
34
  history[method] = (...args) => {
34
35
  const result = original(...args);
35
- onNavigate();
36
+ handler();
36
37
  return result;
37
38
  };
38
39
  };
39
40
  wrap("pushState");
40
41
  wrap("replaceState");
41
- win.addEventListener("popstate", onNavigate);
42
+ win.addEventListener("popstate", handler);
43
+ }
44
+ /**
45
+ * startPageViews emits the initial page view and returns the emitter for
46
+ * SPA route-change views — init subscribes it to the single navigation hook
47
+ * (after the assignments re-arm; see installNavigationHook).
48
+ */
49
+ export function startPageViews(emit, win) {
50
+ const view = () => emit({
51
+ eventName: PAGE_VIEW_EVENT,
52
+ url: win.location.href,
53
+ referrer: win.document.referrer,
54
+ });
55
+ view();
56
+ return view;
42
57
  }
@@ -7,12 +7,15 @@ export type FetchLike = (input: string, init?: {
7
7
  method?: string;
8
8
  body?: Blob;
9
9
  keepalive?: boolean;
10
+ credentials?: "same-origin";
10
11
  }) => Promise<unknown>;
11
12
  export interface TransportEnv {
12
13
  /** Present key (even if undefined) overrides the global for tests. */
13
14
  navigator?: BeaconNavigator | undefined;
14
15
  fetch?: FetchLike | undefined;
15
16
  debug?: DebugFn;
17
+ /** Injectable only so refresh-envelope identity is deterministic in tests. */
18
+ createEnvelopeId?: () => string;
16
19
  }
17
20
  /**
18
21
  * send posts one serialized batch envelope to the relative collect path.
@@ -20,3 +23,4 @@ export interface TransportEnv {
20
23
  * never affected.
21
24
  */
22
25
  export declare function send(body: string, env?: TransportEnv): void;
26
+ export declare function refreshClaims(env?: TransportEnv): Promise<boolean>;
@@ -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.
@@ -50,3 +52,36 @@ export function send(body, env) {
50
52
  debug?.("flush", { reason: "fetch_failed", error: String(err) });
51
53
  }
52
54
  }
55
+ // refreshClaims deliberately uses fetch rather than sendBeacon. The host's
56
+ // response may refresh the visitor's audience state, so this explicit action
57
+ // must make a normal same-origin request with an empty event envelope.
58
+ export function refreshClaims(env) {
59
+ const fetchFn = env !== undefined && "fetch" in env
60
+ ? env.fetch
61
+ : globalThis.fetch;
62
+ if (!fetchFn)
63
+ return Promise.resolve(false);
64
+ try {
65
+ const envelopeId = (env?.createEnvelopeId ?? ulid)();
66
+ const body = new Blob([`{"envelopeId":"${envelopeId}","events":[]}`], { type: "text/plain" });
67
+ return fetchFn(COLLECT_PATH, {
68
+ method: "POST",
69
+ body,
70
+ keepalive: true,
71
+ credentials: "same-origin",
72
+ }).then((response) => {
73
+ if (typeof response === "object" && response !== null && "ok" in response && response.ok === false) {
74
+ env?.debug?.("flush", { reason: "refresh_failed", status: "not_ok" });
75
+ return false;
76
+ }
77
+ return true;
78
+ }).catch((err) => {
79
+ env?.debug?.("flush", { reason: "refresh_failed", error: String(err) });
80
+ return false;
81
+ });
82
+ }
83
+ catch (err) {
84
+ env?.debug?.("flush", { reason: "refresh_failed", error: String(err) });
85
+ return Promise.resolve(false);
86
+ }
87
+ }
@@ -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
+ }
@@ -0,0 +1,33 @@
1
+ export interface ConsentBannerOptions {
2
+ /** Heading text. Default: "Privacy" */
3
+ heading?: string;
4
+ /** Body text explaining what the user is consenting to. */
5
+ message?: string;
6
+ /** Label for the accept button. Default: "Accept" */
7
+ acceptLabel?: string;
8
+ /** Label for the reject button. Default: "Reject" */
9
+ rejectLabel?: string;
10
+ /** Label for a link to the privacy policy. Optional. */
11
+ privacyPolicyLabel?: string;
12
+ /** URL to the privacy policy. Optional. */
13
+ privacyPolicyHref?: string;
14
+ /** Source identifier written to the cookie envelope. Default: "banner" */
15
+ source?: string;
16
+ /** Callback fired after a consent decision is written. */
17
+ onDecision?: (granted: boolean) => void;
18
+ }
19
+ /**
20
+ * hasConsentCookie returns true when the aci_consent cookie exists (the user
21
+ * has already made a decision, regardless of whether they granted or denied).
22
+ */
23
+ export declare function hasConsentCookie(doc: {
24
+ cookie: string;
25
+ }): boolean;
26
+ /**
27
+ * mountConsentBanner checks for an existing consent cookie. If none exists,
28
+ * it injects a banner into the document body and wires up accept/reject
29
+ * buttons. If a cookie already exists, it does nothing (idempotent).
30
+ *
31
+ * Returns a cleanup function that removes the banner and event listeners.
32
+ */
33
+ export declare function mountConsentBanner(doc: Document, opts?: ConsentBannerOptions): () => void;
@@ -0,0 +1,100 @@
1
+ // First-party consent banner (design/04, preset="manual"): a self-contained
2
+ // client-side component for sites that don't have an external CMP (c15t,
3
+ // OneTrust, etc.). Checks for the aci_consent cookie, shows a banner if
4
+ // absent, and calls applyConsent() on user decision.
5
+ //
6
+ // The banner is framework-agnostic — it renders plain DOM and is driven by
7
+ // CSS classes so each site can style it. The Astro wrapper component
8
+ // (packages/sdk/src/astro/ConsentBanner.astro) imports and invokes this,
9
+ // then applies site-specific styles.
10
+ import { applyConsent, readConsentCookie, PERSONALIZATION_PURPOSE, } from "../collect/consent/envelope.js";
11
+ const DEFAULTS = {
12
+ heading: "Privacy",
13
+ message: "We use cookies to personalize content and improve your experience. You can accept or reject personalization.",
14
+ acceptLabel: "Accept",
15
+ rejectLabel: "Reject",
16
+ source: "banner",
17
+ };
18
+ const BANNER_ID = "aci-consent-banner";
19
+ const BANNER_ATTR = "data-aci-consent-banner";
20
+ const BANNER_HIDDEN = "aci-consent-banner--hidden";
21
+ /**
22
+ * hasConsentCookie returns true when the aci_consent cookie exists (the user
23
+ * has already made a decision, regardless of whether they granted or denied).
24
+ */
25
+ export function hasConsentCookie(doc) {
26
+ return readConsentCookie(doc) !== undefined;
27
+ }
28
+ /**
29
+ * mountConsentBanner checks for an existing consent cookie. If none exists,
30
+ * it injects a banner into the document body and wires up accept/reject
31
+ * buttons. If a cookie already exists, it does nothing (idempotent).
32
+ *
33
+ * Returns a cleanup function that removes the banner and event listeners.
34
+ */
35
+ export function mountConsentBanner(doc, opts = {}) {
36
+ if (hasConsentCookie(doc))
37
+ return () => { };
38
+ if (doc.getElementById(BANNER_ID))
39
+ return () => { };
40
+ const config = { ...DEFAULTS, ...opts };
41
+ const banner = createBannerElement(config, doc);
42
+ doc.body.appendChild(banner);
43
+ const cleanup = () => {
44
+ banner.remove();
45
+ };
46
+ const decide = (granted) => {
47
+ const purposes = granted ? [PERSONALIZATION_PURPOSE] : [];
48
+ if (!applyConsent(purposes, config.source, { document: doc }))
49
+ return;
50
+ banner.classList.add(BANNER_HIDDEN);
51
+ setTimeout(cleanup, 300);
52
+ opts.onDecision?.(granted);
53
+ };
54
+ banner.querySelector("[data-aci-consent-accept]")?.addEventListener("click", () => decide(true));
55
+ banner.querySelector("[data-aci-consent-reject]")?.addEventListener("click", () => decide(false));
56
+ return cleanup;
57
+ }
58
+ function createBannerElement(config, doc) {
59
+ const banner = doc.createElement("div");
60
+ banner.id = BANNER_ID;
61
+ banner.setAttribute(BANNER_ATTR, "");
62
+ banner.className = BANNER_ID;
63
+ const content = doc.createElement("div");
64
+ content.className = `${BANNER_ID}__content`;
65
+ const text = doc.createElement("div");
66
+ text.className = `${BANNER_ID}__text`;
67
+ const heading = doc.createElement("p");
68
+ heading.className = `${BANNER_ID}__heading`;
69
+ heading.textContent = config.heading;
70
+ text.appendChild(heading);
71
+ const message = doc.createElement("p");
72
+ message.className = `${BANNER_ID}__message`;
73
+ message.textContent = config.message;
74
+ text.appendChild(message);
75
+ if (config.privacyPolicyHref && config.privacyPolicyLabel) {
76
+ const link = doc.createElement("a");
77
+ link.className = `${BANNER_ID}__privacy-link`;
78
+ link.href = config.privacyPolicyHref;
79
+ link.textContent = config.privacyPolicyLabel;
80
+ text.appendChild(link);
81
+ }
82
+ content.appendChild(text);
83
+ const actions = doc.createElement("div");
84
+ actions.className = `${BANNER_ID}__actions`;
85
+ const rejectBtn = doc.createElement("button");
86
+ rejectBtn.type = "button";
87
+ rejectBtn.className = `${BANNER_ID}__btn ${BANNER_ID}__btn--reject`;
88
+ rejectBtn.setAttribute("data-aci-consent-reject", "");
89
+ rejectBtn.textContent = config.rejectLabel;
90
+ actions.appendChild(rejectBtn);
91
+ const acceptBtn = doc.createElement("button");
92
+ acceptBtn.type = "button";
93
+ acceptBtn.className = `${BANNER_ID}__btn ${BANNER_ID}__btn--accept`;
94
+ acceptBtn.setAttribute("data-aci-consent-accept", "");
95
+ acceptBtn.textContent = config.acceptLabel;
96
+ actions.appendChild(acceptBtn);
97
+ content.appendChild(actions);
98
+ banner.appendChild(content);
99
+ return banner;
100
+ }
@@ -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);
@@ -55,3 +55,38 @@ export declare function collectTagAttributes(siteConfig: PersonalizationSiteConf
55
55
  * when personalization is disabled.
56
56
  */
57
57
  export declare function collectScriptTagHTML(siteConfig: PersonalizationSiteConfig | null | undefined): string;
58
+ /** Version of the data-aci-assignments tag body. */
59
+ export declare const ASSIGNMENTS_TAG_VERSION = 1;
60
+ /**
61
+ * One experiment assignment carried by a variant payload's `experiments`
62
+ * block (wire field names match the SDK event contract).
63
+ */
64
+ export interface ExperimentAssignmentContext {
65
+ experimentId: string;
66
+ armId: string;
67
+ allocationVersion: string;
68
+ }
69
+ /**
70
+ * The minimal page-payload surface assignments tag emission reads: the
71
+ * top-level `experiments` block stamped at build time onto experiment
72
+ * variant payloads. `KernelPage` satisfies this structurally.
73
+ */
74
+ export interface AssignmentsPayloadSource {
75
+ experiments?: ExperimentAssignmentContext[] | null;
76
+ }
77
+ /**
78
+ * assignmentsScriptJSON derives the HTML-safe JSON body of the
79
+ * data-aci-assignments tag ({"v":1,"experiments":[…]}) from the page
80
+ * payload, or "" when the payload carries no experiment assignments.
81
+ * `<` / `>` / `&` are \u-escaped so the JSON can never break out of the
82
+ * script element (standard JSON-in-script escaping; application/json tags
83
+ * are data, never executed).
84
+ */
85
+ export declare function assignmentsScriptJSON(page: AssignmentsPayloadSource | null | undefined): string;
86
+ /**
87
+ * assignmentsScriptTagHTML renders the contract tag
88
+ * (`<script type="application/json" data-aci-assignments>…</script>`,
89
+ * design/04) as an HTML snippet for emitters without a React element seam
90
+ * (the Astro slug page). Returns "" when the payload carries no assignments.
91
+ */
92
+ export declare function assignmentsScriptTagHTML(page: AssignmentsPayloadSource | null | undefined): string;
@@ -52,3 +52,60 @@ function escapeAttribute(value) {
52
52
  .replace(/</g, "&lt;")
53
53
  .replace(/>/g, "&gt;");
54
54
  }
55
+ // ---------------------------------------------------------------------------
56
+ // Assignments tag (personalization-reporting design/04, D-PR-6): experiment
57
+ // variant payloads carry a build-time `experiments` block; the starters
58
+ // render it as ONE page-level JSON script tag the collect SDK reads for
59
+ // exposure emission and context stamping. Base payloads carry no block and
60
+ // emit no tag. Like the collect tag, both framework emitters derive the tag
61
+ // through this module so there is exactly one derivation code path.
62
+ // ---------------------------------------------------------------------------
63
+ /** Version of the data-aci-assignments tag body. */
64
+ export const ASSIGNMENTS_TAG_VERSION = 1;
65
+ function isAssignment(value) {
66
+ if (typeof value !== "object" || value === null)
67
+ return false;
68
+ const record = value;
69
+ return (typeof record.experimentId === "string" &&
70
+ record.experimentId !== "" &&
71
+ typeof record.armId === "string" &&
72
+ record.armId !== "" &&
73
+ typeof record.allocationVersion === "string");
74
+ }
75
+ /**
76
+ * assignmentsScriptJSON derives the HTML-safe JSON body of the
77
+ * data-aci-assignments tag ({"v":1,"experiments":[…]}) from the page
78
+ * payload, or "" when the payload carries no experiment assignments.
79
+ * `<` / `>` / `&` are \u-escaped so the JSON can never break out of the
80
+ * script element (standard JSON-in-script escaping; application/json tags
81
+ * are data, never executed).
82
+ */
83
+ export function assignmentsScriptJSON(page) {
84
+ const experiments = (page?.experiments ?? []).filter(isAssignment);
85
+ if (experiments.length === 0)
86
+ return "";
87
+ const body = JSON.stringify({
88
+ v: ASSIGNMENTS_TAG_VERSION,
89
+ experiments: experiments.map(({ experimentId, armId, allocationVersion }) => ({
90
+ experimentId,
91
+ armId,
92
+ allocationVersion,
93
+ })),
94
+ });
95
+ return body
96
+ .replace(/</g, "\\u003c")
97
+ .replace(/>/g, "\\u003e")
98
+ .replace(/&/g, "\\u0026");
99
+ }
100
+ /**
101
+ * assignmentsScriptTagHTML renders the contract tag
102
+ * (`<script type="application/json" data-aci-assignments>…</script>`,
103
+ * design/04) as an HTML snippet for emitters without a React element seam
104
+ * (the Astro slug page). Returns "" when the payload carries no assignments.
105
+ */
106
+ export function assignmentsScriptTagHTML(page) {
107
+ const json = assignmentsScriptJSON(page);
108
+ if (!json)
109
+ return "";
110
+ return `<script type="application/json" data-aci-assignments>${json}</script>`;
111
+ }
@@ -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 || {},