@tokenoftrust/storefront-runner 4.9.2 → 4.10.0

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 (57) hide show
  1. package/agency-kit/tools/lib/analytics-declaration.mjs +299 -0
  2. package/apps/storefront/migrations-apps/0021_contact_vault.sql +162 -0
  3. package/apps/storefront/src/lib/access/auth/contactReaderAuthority.ts +64 -0
  4. package/apps/storefront/src/lib/access/shared/cspStaticHashes.ts +1 -1
  5. package/apps/storefront/src/lib/access/shared/viewerGrant.ts +30 -0
  6. package/apps/storefront/src/lib/compliance/shared/visitorRegionCookie.ts +1 -1
  7. package/apps/storefront/src/lib/customers/contactVault/contactAudit.ts +80 -0
  8. package/apps/storefront/src/lib/customers/contactVault/envelope.ts +166 -0
  9. package/apps/storefront/src/lib/customers/contactVault/retention.ts +44 -0
  10. package/apps/storefront/src/lib/engagement/contact/contactMessages.ts +242 -65
  11. package/apps/storefront/src/lib/engagement/contact/contract.ts +2 -0
  12. package/apps/storefront/src/lib/fulfillment/commerceProjection.ts +0 -1
  13. package/apps/storefront/src/lib/fulfillment/fulfillmentService.ts +3 -78
  14. package/apps/storefront/src/lib/integrations/integration/tenantAnalyticsDeclarations.ts +46 -206
  15. package/apps/storefront/src/lib/integrations/webhooks/deliveryEngine.ts +4 -0
  16. package/apps/storefront/src/lib/measurement/admin/analyticsRegionPolicy.ts +151 -0
  17. package/apps/storefront/src/lib/measurement/admin/analyticsRegionsClient.ts +84 -0
  18. package/apps/storefront/src/lib/orders/admin/orderActions.ts +229 -19
  19. package/apps/storefront/src/lib/orders/data/commerceCallerConfig.ts +37 -0
  20. package/apps/storefront/src/lib/orders/data/orderForwardReceiver.ts +10 -3
  21. package/apps/storefront/src/lib/orders/data/orderMapper.ts +9 -0
  22. package/apps/storefront/src/lib/orders/data/sourceAuthorityContract.ts +2 -1
  23. package/apps/storefront/src/lib/orders/vault/contactIntake.ts +61 -0
  24. package/apps/storefront/src/lib/orders/vault/contactRoutes.ts +99 -0
  25. package/apps/storefront/src/lib/orders/vault/keyringEnv.ts +7 -0
  26. package/apps/storefront/src/lib/orders/vault/orderContact.ts +96 -0
  27. package/apps/storefront/src/lib/orders/vault/orderContactVault.ts +369 -0
  28. package/apps/storefront/src/lib/publication/d1/schema-apps.ts +22 -11
  29. package/apps/storefront/src/lib/publication/shared/routedApiParam.ts +3 -0
  30. package/apps/storefront/src/pages/api/admin/fulfillment/orders/[id]/address.ts +26 -23
  31. package/apps/storefront/src/pages/api/contact.ts +16 -4
  32. package/apps/storefront/src/scheduled/contactVaultSweep.ts +80 -0
  33. package/apps/storefront/test/pipeline/publish-kinds.ts +5 -1
  34. package/apps/storefront/test/pipeline-faults/matrix-lib.ts +16 -2
  35. package/package.json +1 -1
  36. package/packages/public-runtime/src/integration/consent-broker.ts +117 -17
  37. package/packages/public-runtime/src/integration/events.ts +15 -19
  38. package/packages/public-runtime/src/integration/index.ts +1 -0
  39. package/packages/public-runtime/src/integration/lifecycle-boot.ts +41 -7
  40. package/packages/public-runtime/src/integration/managed-analytics-value-safety.ts +84 -1
  41. package/packages/public-runtime/src/integration/managed-analytics.ts +39 -26
  42. package/packages/public-runtime/src/integration/native-amplitude.ts +11 -10
  43. package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.d.mts +1 -0
  44. package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.mjs +20 -0
  45. package/packages/public-runtime/src/integration/native-ga4-sandbox-entry.ts +24 -13
  46. package/packages/public-runtime/src/integration/native-ga4-sandbox.ts +8 -11
  47. package/packages/public-runtime/src/integration/native-ga4.ts +21 -8
  48. package/packages/public-runtime/src/integration/plan.ts +6 -4
  49. package/packages/public-runtime/src/integration/platform-adapter-runtime.ts +9 -2
  50. package/packages/public-runtime/src/integration/render-phases.ts +10 -18
  51. package/packages/public-runtime/src/integration/resolve.ts +3 -3
  52. package/packages/public-runtime/src/integration/tenant-analytics.ts +250 -0
  53. package/packages/public-runtime/src/integration/visitor-region.mjs +1 -1
  54. package/packages/public-runtime/src/tenant.ts +3 -3
  55. package/packages/public-runtime/tests/fixtures/integration/campaign-token-corpus.ts +75 -0
  56. package/scripts/dev/ga4-gs2-canary.mjs +36 -10
  57. package/scripts/tenant/validate.mjs +9 -0
@@ -0,0 +1,80 @@
1
+ /**
2
+ * The hourly contact vault sweep — the Worker's `scheduled` handler body. One `now` per firing,
3
+ * taken from its scheduled time: purge expired order contacts and contact messages, shorten terminal
4
+ * orders' retention, and seal any contact message still held as legacy plaintext. Expiry predicates
5
+ * only; it never touches an order, audit or other row.
6
+ *
7
+ * It logs ONE structured line of counts on success and ONE fixed error code on failure — never an
8
+ * error message, id or row, since either could carry data. It composes orders and engagement, which
9
+ * no module under src/lib may do, so it lives beside the Worker entry.
10
+ */
11
+ import { fromD1, type Queryable } from "@/lib/catalog/d1/catalog.js";
12
+ import type { VaultKeyring } from "@/lib/customers/contactVault/envelope.js";
13
+ import { purgeExpiredContactMessages, sealLegacyContactMessages } from "@/lib/engagement/contact/contactMessages.js";
14
+ import { loadVaultKeyring } from "@/lib/orders/vault/keyringEnv.js";
15
+ import { purgeOrderContacts, type PurgeCounts } from "@/lib/orders/vault/orderContactVault.js";
16
+ import { readD1 } from "@/lib/publication/shared/env";
17
+
18
+ export const SWEEP_LEGACY_SEAL_LIMIT = 500;
19
+ export const CONTACT_VAULT_SWEEP_EVENT = "contact-vault-sweep";
20
+ export const SWEEP_DB_UNAVAILABLE = "contact-vault-sweep-db-unavailable";
21
+ export const SWEEP_FAILED = "contact-vault-sweep-failed";
22
+
23
+ export interface SweepCounts {
24
+ orders: PurgeCounts;
25
+ messagesPurged: number;
26
+ legacySealed: number;
27
+ legacyRemaining: number;
28
+ }
29
+
30
+ export async function runContactVaultSweep(db: Queryable, keyring: VaultKeyring | null, now: Date): Promise<SweepCounts> {
31
+ if (Number.isNaN(now.getTime())) throw new Error("contact vault sweep: unreadable clock");
32
+ const orders = await purgeOrderContacts(db, now);
33
+ const messagesPurged = await purgeExpiredContactMessages(db, now);
34
+ const legacy = keyring
35
+ ? await sealLegacyContactMessages(db, keyring, now, SWEEP_LEGACY_SEAL_LIMIT)
36
+ : { sealed: 0, remaining: -1 };
37
+ return { orders, messagesPurged, legacySealed: legacy.sealed, legacyRemaining: legacy.remaining };
38
+ }
39
+
40
+ export interface ScheduledSweepDeps {
41
+ openDb(): Promise<Queryable | null>;
42
+ loadKeyring(): Promise<VaultKeyring | null>;
43
+ sweep(db: Queryable, keyring: VaultKeyring | null, now: Date): Promise<SweepCounts>;
44
+ log(line: string): void;
45
+ logError(line: string): void;
46
+ }
47
+
48
+ const workerDeps: ScheduledSweepDeps = {
49
+ openDb: async () => {
50
+ const d1 = await readD1("STOREFRONT_APPS_DB");
51
+ return d1 ? fromD1(d1) : null;
52
+ },
53
+ loadKeyring: loadVaultKeyring,
54
+ sweep: runContactVaultSweep,
55
+ log: (line) => console.log(line),
56
+ logError: (line) => console.error(line),
57
+ };
58
+
59
+ /** Never throws: every failure is reported as a fixed code. */
60
+ export async function runScheduledContactVaultSweep(scheduledTime: number, deps: ScheduledSweepDeps = workerDeps): Promise<void> {
61
+ const fail = (error: string) => deps.logError(JSON.stringify({ event: CONTACT_VAULT_SWEEP_EVENT, error }));
62
+ try {
63
+ const db = await deps.openDb();
64
+ if (!db) return fail(SWEEP_DB_UNAVAILABLE);
65
+ const keyring = await deps.loadKeyring();
66
+ const counts = await deps.sweep(db, keyring, new Date(scheduledTime));
67
+ deps.log(JSON.stringify({
68
+ event: CONTACT_VAULT_SWEEP_EVENT,
69
+ keyringAvailable: keyring !== null,
70
+ ordersPurged: counts.orders.purged,
71
+ tombstonesRemoved: counts.orders.tombstonesRemoved,
72
+ ordersShortened: counts.orders.shortened,
73
+ messagesPurged: counts.messagesPurged,
74
+ legacySealed: counts.legacySealed,
75
+ legacyRemaining: counts.legacyRemaining,
76
+ }));
77
+ } catch {
78
+ fail(SWEEP_FAILED);
79
+ }
80
+ }
@@ -40,13 +40,17 @@ type PointerRecord = Awaited<ReturnType<PointerWriter["read"]>>;
40
40
  * Production's ratios, in seconds: a CI window (45 min) outlasts the lease an evicted object waits
41
41
  * out before it resumes (10 min, here `leaseMs`), so a job resumed after an eviction is still inside
42
42
  * its window — as in production.
43
+ *
44
+ * A hotfix's stage window ends the job `stuck` when it expires, and the fault matrix answers the
45
+ * prebuild from a poll loop that a loaded host starves for several seconds at a time (a 30 ms sleep
46
+ * measured at 4–8 s on an oversubscribed machine). The window is sized well past that stall.
43
47
  */
44
48
  export const PUBLISH_HARNESS_TIMING: PublishTiming = {
45
49
  awaitWindowMs: 4_000,
46
50
  runGraceMs: 500,
47
51
  proofDelayMs: 30,
48
52
  proofProbes: { synchronous: 3, asynchronous: 5 },
49
- stageWindowMs: 4_000,
53
+ stageWindowMs: 15_000,
50
54
  stageChecks: 3,
51
55
  };
52
56
 
@@ -150,7 +150,7 @@ async function cloudFrontServesEarlier(t: Tenant, provider: Provider): Promise<v
150
150
  async function prerequisite(t: Tenant, kind: string, input: unknown): Promise<void> {
151
151
  const jobId = await t.submit(kind, input);
152
152
  const { job } = await drive(t, jobId);
153
- if (job.state !== "succeeded") throw new Error(`setup ${kind} ended ${job.state}: ${JSON.stringify(job.error)}`);
153
+ if (job.state !== "succeeded") throw new Error(`setup ${kind} ended ${job.state}: ${JSON.stringify(job.error)} outcome: ${outcomeOf(job)}`);
154
154
  }
155
155
 
156
156
  async function goLive(t: Tenant, provider: Provider, sha: string, runId: string): Promise<void> {
@@ -578,6 +578,17 @@ export function overlapViolations(all: TransitionView[]): string[] {
578
578
  return [];
579
579
  }
580
580
 
581
+ /**
582
+ * What the job's own outcome says about why it ended (a `JobEnded` error's reason only points at
583
+ * it): its reason code and message, or null when the job recorded none.
584
+ */
585
+ export function outcomeOf(job: JobView): string | null {
586
+ const outcome = job.produces?.outcome as { reason?: unknown; message?: unknown } | undefined;
587
+ if (!outcome || typeof outcome !== "object") return null;
588
+ const parts = [outcome.reason, outcome.message].filter((p): p is string => typeof p === "string" && p.length > 0);
589
+ return parts.length ? parts.join(": ") : JSON.stringify(outcome);
590
+ }
591
+
581
592
  function diffKeys(a: Record<string, unknown>, b: Record<string, unknown>, keys: string[]): string[] {
582
593
  return keys.filter((k) => JSON.stringify(a[k]) !== JSON.stringify(b[k]));
583
594
  }
@@ -615,7 +626,10 @@ export function convergence(cell: CellRun, base: CellRun, opts: ConvergenceOptio
615
626
  const v: string[] = [];
616
627
  const { job } = cell;
617
628
  if (job.state !== base.job.state || job.error?.code !== base.job.error?.code) {
618
- v.push(`verdict: ${job.state}${job.error ? ` (${job.error.code}: ${job.error.reason})` : ""}, unfaulted ${base.job.state}`);
629
+ const outcome = outcomeOf(job);
630
+ v.push(
631
+ `verdict: ${job.state}${job.error ? ` (${job.error.code}: ${job.error.reason})` : ""}${outcome ? ` outcome: ${outcome}` : ""}, unfaulted ${base.job.state}`,
632
+ );
619
633
  }
620
634
  if (opts.expectKill && cell.kills.length === 0) v.push("fired: the armed fault never fired");
621
635
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "4.9.2",
3
+ "version": "4.10.0",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
6
6
  "packageManager": "pnpm@11.9.0",
@@ -30,6 +30,7 @@ import { CONSENT_PURPOSES, type ConsentPurpose } from "../integration-capabiliti
30
30
  import { CONSENT_SNAPSHOT_SCHEMA_VERSION } from "./version.js";
31
31
  import type { EventConsentSnapshot } from "./events.js";
32
32
  import type { EuUkVisitorPolicy } from "./plan.js";
33
+ import { schedulePostPaint, type PostPaintWindow } from "./post-paint.js";
33
34
 
34
35
  /** The purpose that is always on and cannot be gated (charter floor). */
35
36
  const ESSENTIAL: ConsentPurpose = "essential";
@@ -479,8 +480,10 @@ export class ConsentBroker {
479
480
  * The tenant configuration supplies the analytics default and a stored viewer choice
480
481
  * overrides it. Global Privacy Control always denies analytics. Visitors the edge
481
482
  * classes as EU/EEA/UK/CH, or cannot class, get the tenant's EU/UK visitor policy:
482
- * `count-only` (default) keeps page views but stores nothing on the device, `none`
483
- * denies analytics, and `full` applies the default behavior. The same synchronous
483
+ * `ask` (default) counts page views without storing anything and prompts the visitor,
484
+ * and an Allow lifts the restriction for this context; `count-only` keeps page views
485
+ * but stores nothing on the device; `none` denies analytics; and `full` applies the
486
+ * default behavior. The same synchronous
484
487
  * early boot runs in both document mounts before any integration or measurement loader
485
488
  * can execute.
486
489
  */
@@ -515,6 +518,16 @@ export interface ViewerAnalyticsConsentState {
515
518
  * visitor-continuity storage reads this and falls back to a per-page memory id.
516
519
  */
517
520
  visitorStorage: "persistent" | "none";
521
+ /**
522
+ * Whether the EU/UK visitor policy currently restricts this visitor: no device
523
+ * storage, no marketing or personalization, and no tenant-local code.
524
+ */
525
+ regionRestricted: boolean;
526
+ /**
527
+ * `pending` when the `ask` policy applies and the visitor has not decided; the
528
+ * control then opens itself after paint. `none` otherwise.
529
+ */
530
+ prompt: "pending" | "none";
518
531
  tenantPublicId: string;
519
532
  environment: string;
520
533
  siteSurface: string;
@@ -616,18 +629,31 @@ export function applyEarlyViewerAnalyticsConsent(
616
629
 
617
630
  // The edge sets a class-only `tot_rgn` cookie on every HTML response
618
631
  // (visitor-region.mjs). An absent or unreadable class is unknown, and unknown gets
619
- // the EU/UK policy until the region is known.
632
+ // the EU/UK policy until the region is known. Several `tot_rgn` cookies that disagree
633
+ // (one planted with another Domain or Path beside the edge's) are unknown too, so a
634
+ // planted class can never widen the edge's.
620
635
  let regionClass = "u";
621
636
  try {
622
- const regionMatch = /(?:^|;\s*)tot_rgn=(eu|x|u)(?:;|$)/.exec(String(win.document?.cookie ?? ""));
623
- if (regionMatch) regionClass = regionMatch[1] as string;
637
+ const regionClasses: string[] = [];
638
+ for (const part of String(win.document?.cookie ?? "").split(";")) {
639
+ const separator = part.indexOf("=");
640
+ if (separator !== -1 && part.slice(0, separator).trim() === "tot_rgn") regionClasses.push(part.slice(separator + 1).trim());
641
+ }
642
+ const firstClass = regionClasses[0];
643
+ if (
644
+ (firstClass === "eu" || firstClass === "x" || firstClass === "u") &&
645
+ regionClasses.every((candidate) => candidate === firstClass)
646
+ ) {
647
+ regionClass = firstClass;
648
+ }
624
649
  } catch {
625
650
  // An unreadable cookie jar leaves the region unknown.
626
651
  }
652
+ // An absent or unrecognized policy is `ask`: it never widens collection.
627
653
  const euUkVisitors: EuUkVisitorPolicy =
628
- plan?.euUkVisitors === "full" || plan?.euUkVisitors === "none" ? plan.euUkVisitors : "count-only";
629
- const regionRestricted = regionClass !== "x" && euUkVisitors !== "full";
630
- const regionDeniesAnalytics = regionRestricted && euUkVisitors === "none";
654
+ plan?.euUkVisitors === "count-only" || plan?.euUkVisitors === "full" || plan?.euUkVisitors === "none"
655
+ ? plan.euUkVisitors
656
+ : "ask";
631
657
 
632
658
  let stored: { decision: ViewerAnalyticsConsentDecision; expiresAt: number } | null = null;
633
659
  // A stored record that is present but unreadable may have been a denial, so it denies
@@ -679,8 +705,19 @@ export function applyEarlyViewerAnalyticsConsent(
679
705
  }
680
706
  }
681
707
 
708
+ // Under `ask`, a visitor's own Allow for this exact context lifts the restriction.
709
+ const regionSensitive = regionClass !== "x";
710
+ const askUpgraded =
711
+ euUkVisitors === "ask" && stored?.decision === "granted" && !globalPrivacyControl && tenantApprovedAnalytics;
712
+ const regionRestricted = regionSensitive && euUkVisitors !== "full" && !askUpgraded;
713
+ const regionDeniesAnalytics = regionRestricted && euUkVisitors === "none";
714
+ // Until the visitor allows, `ask` counts page views exactly as `count-only` does; a
715
+ // Decline, a Close or no answer leaves it there.
716
+ const askRestricted = regionRestricted && euUkVisitors === "ask";
717
+
682
718
  const decision: ViewerAnalyticsConsentDecision =
683
- globalPrivacyControl || !tenantApprovedAnalytics || regionDeniesAnalytics || (!stored && storedInvalid)
719
+ globalPrivacyControl || !tenantApprovedAnalytics || regionDeniesAnalytics || askRestricted ||
720
+ (!stored && storedInvalid)
684
721
  ? "denied"
685
722
  : stored?.decision ?? (tenantDefaultAnalytics ? "granted" : "denied");
686
723
  const source: ViewerAnalyticsConsentState["source"] = globalPrivacyControl
@@ -691,9 +728,12 @@ export function applyEarlyViewerAnalyticsConsent(
691
728
  ? "stored"
692
729
  : storedInvalid
693
730
  ? "stored-invalid"
694
- : tenantDefaultAnalytics
695
- ? "tenant-default"
696
- : "default-deny";
731
+ : askRestricted
732
+ ? "eu-uk-policy"
733
+ : tenantDefaultAnalytics
734
+ ? "tenant-default"
735
+ : "default-deny";
736
+ const prompt = askRestricted && !stored && !globalPrivacyControl && tenantApprovedAnalytics ? "pending" : "none";
697
737
 
698
738
  // Under the EU/UK policy, marketing and personalization never run: neither can work
699
739
  // without storing an identifier on the device.
@@ -701,7 +741,9 @@ export function applyEarlyViewerAnalyticsConsent(
701
741
  (purpose) =>
702
742
  purpose === "essential" ||
703
743
  (purpose === "analytics"
704
- ? decision === "granted" && tenantApprovedAnalytics
744
+ ? askRestricted
745
+ ? tenantDefaultAnalytics && !globalPrivacyControl
746
+ : decision === "granted" && tenantApprovedAnalytics
705
747
  : !regionRestricted && rawEligible.includes(purpose)),
706
748
  );
707
749
  const approved = canonicalPurposes.filter((purpose) => rawApproved.includes(purpose));
@@ -735,6 +777,8 @@ export function applyEarlyViewerAnalyticsConsent(
735
777
  region: regionClass === "eu" ? "eu-uk" : regionClass === "x" ? "other" : "unknown",
736
778
  euUkVisitors,
737
779
  visitorStorage,
780
+ regionRestricted,
781
+ prompt,
738
782
  tenantPublicId,
739
783
  environment,
740
784
  siteSurface,
@@ -820,22 +864,33 @@ export function setViewerAnalyticsConsent(
820
864
 
821
865
  function renderControlState(root: HTMLElement, state: ViewerAnalyticsConsentState): void {
822
866
  const status = root.querySelector<HTMLElement>("[data-tot-analytics-consent-status]");
867
+ const detail = root.querySelector<HTMLElement>("[data-tot-analytics-consent-detail]");
823
868
  const grant = root.querySelector<HTMLButtonElement>('[data-tot-analytics-consent-decision="granted"]');
824
869
  const deny = root.querySelector<HTMLButtonElement>('[data-tot-analytics-consent-decision="denied"]');
870
+ const countingOnly = state.regionRestricted && state.euUkVisitors === "ask" && state.canGrant;
825
871
  if (status) {
826
872
  status.textContent = state.source === "global-privacy-control"
827
873
  ? "Analytics off — Global Privacy Control"
828
874
  : state.decision === "granted"
829
875
  ? "Analytics on"
830
- : "Analytics off";
876
+ : countingOnly
877
+ ? "Page counts only"
878
+ : "Analytics off";
879
+ }
880
+ if (detail) {
881
+ detail.textContent = countingOnly
882
+ ? "Page views are counted without storing anything on this device. Allow analytics to remember this device between visits."
883
+ : "Choose whether analytics may run on this site.";
831
884
  }
832
885
  if (grant) {
833
886
  grant.disabled = !state.canGrant || state.decision === "granted";
834
887
  grant.setAttribute("aria-pressed", String(state.decision === "granted"));
835
888
  }
836
889
  if (deny) {
837
- deny.disabled = state.decision === "denied";
838
- deny.setAttribute("aria-pressed", String(state.decision === "denied"));
890
+ // A visitor counted under `ask` has not declined until they say so.
891
+ const declined = state.decision === "denied" && (!countingOnly || state.source === "stored");
892
+ deny.disabled = declined;
893
+ deny.setAttribute("aria-pressed", String(declined));
839
894
  }
840
895
  }
841
896
 
@@ -844,7 +899,7 @@ function firstEnabledButton(root: HTMLElement): HTMLButtonElement | null {
844
899
  }
845
900
 
846
901
  /**
847
- * Bind each persistent "Analytics choices" disclosure to the control its
902
+ * Bind each tenant Privacy Policy "Analytics choices" disclosure to the control its
848
903
  * `aria-controls` names: a click opens the control and moves focus into it; a second
849
904
  * click, the control's Close button, or Escape inside it closes it again, and the
850
905
  * latter two return focus to the toggle.
@@ -882,6 +937,38 @@ function bindViewerAnalyticsChoicesToggles(win: ViewerConsentWindow): void {
882
937
  });
883
938
  }
884
939
 
940
+ /** Show or hide a control and keep every disclosure that names it in step. */
941
+ function setControlVisible(win: ViewerConsentWindow, root: HTMLElement, visible: boolean): void {
942
+ root.hidden = !visible;
943
+ if (!root.id) return;
944
+ win.document?.querySelectorAll<HTMLElement>("[data-tot-analytics-choices-toggle]").forEach((toggle) => {
945
+ if (toggle.getAttribute("aria-controls") === root.id) toggle.setAttribute("aria-expanded", String(visible));
946
+ });
947
+ }
948
+
949
+ /**
950
+ * Open each control for a visitor the `ask` policy has not heard from. It runs after
951
+ * paint and idle so it never delays the page, and the control is a fixed overlay so it
952
+ * shifts no layout. Focus stays where the visitor left it.
953
+ */
954
+ function scheduleViewerAnalyticsPrompt(
955
+ win: ViewerConsentWindow,
956
+ roots: NodeListOf<HTMLElement> | undefined,
957
+ state: ViewerAnalyticsConsentState,
958
+ ): void {
959
+ if (state.prompt !== "pending" || !roots || roots.length === 0) return;
960
+ if (typeof win["setTimeout"] !== "function" || typeof win.addEventListener !== "function") return;
961
+ schedulePostPaint(win as unknown as PostPaintWindow, () => {
962
+ const latest = win[VIEWER_ANALYTICS_CONSENT_STATE_GLOBAL] as ViewerAnalyticsConsentState | undefined;
963
+ if (latest?.prompt !== "pending") return;
964
+ roots.forEach((root) => {
965
+ renderControlState(root, latest);
966
+ root.dataset["totAnalyticsConsentPrompted"] = "true";
967
+ setControlVisible(win, root, true);
968
+ });
969
+ });
970
+ }
971
+
885
972
  /** Bind the renderer-owned control once; safe when a document contains no analytics plan. */
886
973
  export function bootViewerAnalyticsConsentControl(
887
974
  win: ViewerConsentWindow,
@@ -897,15 +984,28 @@ export function bootViewerAnalyticsConsentControl(
897
984
  root.addEventListener("click", (event) => {
898
985
  const target = event.target;
899
986
  if (!(target instanceof Element)) return;
987
+ // Close records nothing, so an unanswered prompt returns on the next page.
988
+ if (target.closest("[data-tot-analytics-consent-close]")) {
989
+ delete root.dataset["totAnalyticsConsentPrompted"];
990
+ setControlVisible(win, root, false);
991
+ return;
992
+ }
900
993
  const button = target.closest<HTMLButtonElement>("[data-tot-analytics-consent-decision]");
901
994
  const decision = button?.dataset["totAnalyticsConsentDecision"];
902
995
  if (decision !== "granted" && decision !== "denied") return;
903
996
  renderControlState(root, setViewerAnalyticsConsent(win, decision, now()));
997
+ // An answered prompt goes away; the disclosure reopens the control on demand.
998
+ if (root.dataset["totAnalyticsConsentPrompted"] === "true") {
999
+ delete root.dataset["totAnalyticsConsentPrompted"];
1000
+ setControlVisible(win, root, false);
1001
+ return;
1002
+ }
904
1003
  // The pressed button is now disabled; keep keyboard focus inside the control.
905
1004
  if (button?.disabled) firstEnabledButton(root)?.focus();
906
1005
  });
907
1006
  });
908
1007
  bindViewerAnalyticsChoicesToggles(win);
1008
+ scheduleViewerAnalyticsPrompt(win, roots, current);
909
1009
 
910
1010
  const storageBoundGlobal = "__TOT_VIEWER_ANALYTICS_CONSENT_STORAGE_BOUND__";
911
1011
  if (win.addEventListener && win[storageBoundGlobal] !== true) {
@@ -57,8 +57,9 @@ import { INTEGRATION_ENVIRONMENTS, INTEGRATION_SITE_SURFACES } from "./environme
57
57
  import type { IntegrationEnvironment, IntegrationSiteSurface } from "./environment.js";
58
58
  import { findForbiddenFields } from "./secrets.js";
59
59
  import {
60
+ MANAGED_ANALYTICS_CAMPAIGN_FIELDS,
60
61
  resemblesManagedAnalyticsPii,
61
- resemblesManagedAnalyticsProtectedValue,
62
+ screenManagedAnalyticsCampaign,
62
63
  } from "./managed-analytics-value-safety.js";
63
64
 
64
65
  /**
@@ -349,8 +350,6 @@ function validateLineItems(parent: Record<string, unknown>, v: Violations, path:
349
350
  });
350
351
  }
351
352
 
352
- const UTM_FIELDS = ["source", "medium", "campaign", "term", "content"] as const;
353
- const SAFE_UTM_VALUE = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$/;
354
353
  function validatePageAttribution(parent: Record<string, unknown>, v: Violations): void {
355
354
  const attribution = childObject(parent, "attribution", v, "data");
356
355
  if (!attribution) return;
@@ -389,27 +388,24 @@ function validatePageAttribution(parent: Record<string, unknown>, v: Violations)
389
388
  if ("utm" in attribution) {
390
389
  const utm = childObject(attribution, "utm", v, "data.attribution");
391
390
  if (utm) {
392
- rejectUnknownKeys(utm, UTM_FIELDS, v, "data.attribution.utm");
391
+ rejectUnknownKeys(utm, MANAGED_ANALYTICS_CAMPAIGN_FIELDS, v, "data.attribution.utm");
393
392
  if (Object.keys(utm).length === 0) {
394
393
  v.add("data.attribution.utm", "empty", "UTM must contain at least one allowlisted field.");
395
394
  }
396
- for (const key of UTM_FIELDS) {
395
+ const strings: Record<string, string> = {};
396
+ for (const key of MANAGED_ANALYTICS_CAMPAIGN_FIELDS) {
397
397
  if (!(key in utm)) continue;
398
398
  const value = reqString(utm, key, v, "data.attribution.utm");
399
- if (value !== undefined && !SAFE_UTM_VALUE.test(value)) {
400
- v.add(
401
- `data.attribution.utm.${key}`,
402
- "format",
403
- `UTM ${key} must be a 1-128 character opaque campaign token.`,
404
- );
405
- }
406
- if (value !== undefined && resemblesManagedAnalyticsProtectedValue(value)) {
407
- v.add(
408
- `data.attribution.utm.${key}`,
409
- "protected_value",
410
- `UTM ${key} resembles PII or protected material and must not be emitted.`,
411
- );
412
- }
399
+ if (value !== undefined) strings[key] = value;
400
+ }
401
+ for (const { field, reason } of screenManagedAnalyticsCampaign(strings).rejected) {
402
+ v.add(
403
+ `data.attribution.utm.${field}`,
404
+ reason,
405
+ reason === "format"
406
+ ? `UTM ${field} must be a 1-128 character opaque campaign token.`
407
+ : `UTM ${field} resembles PII or protected material and must not be emitted.`,
408
+ );
413
409
  }
414
410
  }
415
411
  }
@@ -56,6 +56,7 @@ export * from "./manifest.js";
56
56
  export * from "./plan.js";
57
57
  export * from "./resolve.js";
58
58
  export * from "./platform-adapters.js";
59
+ export * from "./tenant-analytics.js";
59
60
  export * from "./native-amplitude.js";
60
61
  export * from "./native-ga4.js";
61
62
  export * from "./native-ga4-sandbox.js";
@@ -59,8 +59,8 @@ import {
59
59
  import { schedulePostPaint, type PostPaintWindow } from "./post-paint.js";
60
60
  import { scheduleGa4Sandbox, type Ga4SandboxScheduleWindow } from "./ga4-sandbox-schedule.js";
61
61
  import { ManagedAnalyticsEventBrokerBridge } from "./managed-analytics-bridge.js";
62
- import { minimizePageAttribution, type ManagedPageViewInput } from "./managed-analytics.js";
63
- import { managedAnalyticsOpaqueRevision } from "./managed-analytics-value-safety.js";
62
+ import { minimizePageAttribution, readGoogleAdsClickIds, type ManagedPageViewInput } from "./managed-analytics.js";
63
+ import { managedAnalyticsOpaqueRevision, type GoogleAdsClickIds } from "./managed-analytics-value-safety.js";
64
64
  import {
65
65
  CONTACT_SALES_ACTION_REGISTRY,
66
66
  createContactSalesActionSource,
@@ -349,13 +349,13 @@ function defaultConsentGate(
349
349
  }
350
350
 
351
351
  /**
352
- * Whether the tenant's EU/UK visitor policy governs this document: the edge classed the
353
- * visitor as EU/EEA/UK/CH or could not class them, and the tenant has not taken `full`
354
- * responsibility. A missing state global applies it (fail closed).
352
+ * Whether the tenant's EU/UK visitor policy governs this document, as the early consent
353
+ * boot decided it (`regionRestricted`). Only an explicit `false` lifts it: a missing
354
+ * state global or a missing or non-boolean flag applies it (fail closed).
355
355
  */
356
356
  function euUkPolicyApplies(win: LifecycleWindow): boolean {
357
- const state = win[VIEWER_ANALYTICS_CONSENT_STATE_GLOBAL] as ViewerAnalyticsConsentState | undefined;
358
- return !state || (state.region !== "other" && state.euUkVisitors !== "full");
357
+ const state = win[VIEWER_ANALYTICS_CONSENT_STATE_GLOBAL] as Partial<ViewerAnalyticsConsentState> | undefined;
358
+ return state?.regionRestricted !== false;
359
359
  }
360
360
 
361
361
  /**
@@ -367,6 +367,39 @@ function persistentVisitorStorage(win: LifecycleWindow): boolean {
367
367
  return state?.visitorStorage === "persistent";
368
368
  }
369
369
 
370
+ /**
371
+ * Whether landing click identifiers may reach GA4: the visitor is known to be outside
372
+ * the EU/UK and analytics may keep them on this device. GPC, a saved denial, `count-only`,
373
+ * an unknown region, and an EU/UK visitor under any tenant policy all forward nothing.
374
+ * A missing state global allows nothing (fail closed).
375
+ */
376
+ function landingClickIdsAllowed(win: LifecycleWindow): boolean {
377
+ const state = win[VIEWER_ANALYTICS_CONSENT_STATE_GLOBAL] as ViewerAnalyticsConsentState | undefined;
378
+ return state?.visitorStorage === "persistent" && state.region === "other";
379
+ }
380
+
381
+ /** Windows whose landing click identifiers already went out; a back-forward restore never resends them. */
382
+ const landingClickIdsTaken = new WeakSet<object>();
383
+
384
+ /**
385
+ * The GA4 host capability over this document's landing URL: identifiers are read once,
386
+ * when the lifecycle is built, and released once, to the page view of the landing path,
387
+ * only while {@link landingClickIdsAllowed} holds at that moment.
388
+ */
389
+ function landingClickIdSource(
390
+ win: LifecycleWindow,
391
+ tenantPublicId: string,
392
+ ): ((path: string) => GoogleAdsClickIds | undefined) | undefined {
393
+ const clickIds = readGoogleAdsClickIds(win.location.href);
394
+ if (!clickIds) return undefined;
395
+ const landingPath = canonicalDocumentPath(win.location.pathname, tenantPublicId);
396
+ return (path) => {
397
+ if (path !== landingPath || landingClickIdsTaken.has(win) || !landingClickIdsAllowed(win)) return undefined;
398
+ landingClickIdsTaken.add(win);
399
+ return clickIds;
400
+ };
401
+ }
402
+
370
403
  /**
371
404
  * Under the EU/UK policy only a reviewed platform adapter may run: its
372
405
  * visitor storage goes through this host's continuity seam, which then keeps nothing on
@@ -500,6 +533,7 @@ function createDocumentLifecycle(
500
533
  ? win["localStorage"] as VisitorContinuityStorage | undefined
501
534
  : undefined),
502
535
  }),
536
+ takeLandingClickIds: landingClickIdSource(win, descriptor.tenantPublicId),
503
537
  nativeGa4Transport: () => {
504
538
  if (options.nativeGa4Transport) return options.nativeGa4Transport;
505
539
  if (typeof document === "undefined" || typeof MessageChannel === "undefined") {
@@ -1,12 +1,95 @@
1
1
  /** Shared PII-value canary for managed browser events and their diagnostics. */
2
- import { MANAGED_ANALYTICS_PII_VALUE_PATTERN } from "./managed-analytics-api-v1-safety.mjs";
2
+ import {
3
+ MANAGED_ANALYTICS_PII_VALUE_PATTERN,
4
+ MANAGED_ANALYTICS_SECRET_VALUE_PATTERN,
5
+ } from "./managed-analytics-api-v1-safety.mjs";
3
6
  import { fnv1a128Hex } from "./digest.js";
4
7
 
5
8
  const MANAGED_ANALYTICS_PII_VALUE = new RegExp(MANAGED_ANALYTICS_PII_VALUE_PATTERN);
9
+ const MANAGED_ANALYTICS_SECRET_VALUE = new RegExp(MANAGED_ANALYTICS_SECRET_VALUE_PATTERN);
6
10
  const MANAGED_ANALYTICS_PROTECTED_VALUE =
7
11
  /(?:@|\d{7,}|(?:^|[./_~-])(?:bearer|password|secret|token|authorization|preview)(?:$|[./_~-]))/i;
8
12
  const OPAQUE_HEX_ALPHABET = "abcdefghijklmnop";
9
13
 
14
+ /** The five standard UTM fields, without their `utm_` prefix. */
15
+ export const MANAGED_ANALYTICS_CAMPAIGN_FIELDS = ["source", "medium", "campaign", "term", "content"] as const;
16
+ export type ManagedAnalyticsCampaignField = (typeof MANAGED_ANALYTICS_CAMPAIGN_FIELDS)[number];
17
+ export type ManagedAnalyticsCampaign = Partial<Record<ManagedAnalyticsCampaignField, string>>;
18
+
19
+ /** A dropped campaign field. It names the field and the reason, never the value. */
20
+ export interface ManagedAnalyticsCampaignRejection {
21
+ field: ManagedAnalyticsCampaignField;
22
+ reason: "format" | "protected_value";
23
+ }
24
+
25
+ export interface ManagedAnalyticsCampaignScreen {
26
+ kept: ManagedAnalyticsCampaign;
27
+ rejected: ManagedAnalyticsCampaignRejection[];
28
+ }
29
+
30
+ const CAMPAIGN_TOKEN = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$/;
31
+ /** Credential words as a whole token segment. `token` and `preview` are ordinary campaign words. */
32
+ const CAMPAIGN_CREDENTIAL_WORD = /(?:^|[._~-])(?:bearer|password|secret|authorization|credential)(?:$|[._~-])/i;
33
+ /** A labelled token may carry a 7-8 digit stamp (`_20261027`); a longer run could be a dialable number. */
34
+ const CAMPAIGN_LONG_DIGIT_RUN = /\d{9,}/;
35
+ /**
36
+ * A bare number is never a campaign token. A Google Ads ValueTrack `{campaignid}` has the
37
+ * same shape as an international phone number written without `+`, and neither `utm_source`
38
+ * nor length separates them, so it is rejected; GA4 still attributes Ads traffic by click id.
39
+ */
40
+ const CAMPAIGN_BARE_DIGITS = /^\d{7,}$/;
41
+
42
+ function campaignTokenRejection(value: unknown): ManagedAnalyticsCampaignRejection["reason"] | undefined {
43
+ if (typeof value !== "string" || !CAMPAIGN_TOKEN.test(value)) return "format";
44
+ if (
45
+ resemblesManagedAnalyticsPii(value) || MANAGED_ANALYTICS_SECRET_VALUE.test(value) ||
46
+ CAMPAIGN_CREDENTIAL_WORD.test(value)
47
+ ) return "protected_value";
48
+ return CAMPAIGN_BARE_DIGITS.test(value) || CAMPAIGN_LONG_DIGIT_RUN.test(value) ? "protected_value" : undefined;
49
+ }
50
+
51
+ /**
52
+ * The one campaign-token policy for every layer that carries UTM values: minimization,
53
+ * event validation, the GA4 parent and sandbox, and Amplitude. Each present field is kept
54
+ * or rejected on its own, so one unsafe value never costs the page view its other fields.
55
+ */
56
+ export function screenManagedAnalyticsCampaign(utm: Readonly<Record<string, unknown>>): ManagedAnalyticsCampaignScreen {
57
+ const kept: ManagedAnalyticsCampaign = {};
58
+ const rejected: ManagedAnalyticsCampaignRejection[] = [];
59
+ for (const field of MANAGED_ANALYTICS_CAMPAIGN_FIELDS) {
60
+ if (!Object.hasOwn(utm, field)) continue;
61
+ const value = utm[field];
62
+ const reason = campaignTokenRejection(value);
63
+ if (reason) rejected.push({ field, reason });
64
+ else kept[field] = value as string;
65
+ }
66
+ return { kept, rejected };
67
+ }
68
+
69
+ /** The Google Ads click identifiers a landing URL may carry to GA4. */
70
+ export const GOOGLE_ADS_CLICK_ID_KEYS = ["gclid", "gbraid", "wbraid"] as const;
71
+ export type GoogleAdsClickIdKey = (typeof GOOGLE_ADS_CLICK_ID_KEYS)[number];
72
+ export type GoogleAdsClickIds = Partial<Record<GoogleAdsClickIdKey, string>>;
73
+
74
+ /** Auto-tagging writes URL-safe base64; the length bound only caps hostile input. */
75
+ const GOOGLE_ADS_CLICK_ID = /^[A-Za-z0-9_-]{1,512}$/;
76
+
77
+ /** A click identifier safe to place on a GA4 `page_location`: URL-safe, bounded, and not PII- or secret-shaped. */
78
+ export function isGoogleAdsClickIdValue(value: unknown): value is string {
79
+ return typeof value === "string" && GOOGLE_ADS_CLICK_ID.test(value) &&
80
+ !resemblesManagedAnalyticsPii(value) && !MANAGED_ANALYTICS_SECRET_VALUE.test(value);
81
+ }
82
+
83
+ /** A non-empty record of known click-identifier keys, every value valid. */
84
+ export function isGoogleAdsClickIds(value: unknown): value is GoogleAdsClickIds {
85
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
86
+ const proto = Object.getPrototypeOf(value);
87
+ if (proto !== Object.prototype && proto !== null) return false;
88
+ const entries = Object.entries(value);
89
+ return entries.length > 0 && entries.every(([key, id]) =>
90
+ (GOOGLE_ADS_CLICK_ID_KEYS as readonly string[]).includes(key) && isGoogleAdsClickIdValue(id));
91
+ }
92
+
10
93
  /** True when a public string resembles an email, SSN, payment card, or phone number. */
11
94
  export function resemblesManagedAnalyticsPii(value: unknown): boolean {
12
95
  return typeof value === "string" && MANAGED_ANALYTICS_PII_VALUE.test(value);