@tokenoftrust/storefront-runner 2.0.3 → 2.0.5

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 (178) hide show
  1. package/apps/storefront/.astro/content.d.ts +159 -0
  2. package/apps/storefront/.astro/integrations/_astrojs_cloudflare/cloudflare.d.ts +1 -0
  3. package/apps/storefront/.astro/types.d.ts +3 -0
  4. package/apps/storefront/migrations-apps/0011_dt_tracking_tenant_databases.sql +23 -0
  5. package/apps/storefront/node_modules/.bin/acorn +43 -0
  6. package/apps/storefront/node_modules/.bin/astro +43 -0
  7. package/apps/storefront/node_modules/.bin/astro-check +43 -0
  8. package/apps/storefront/node_modules/.bin/browserslist +43 -0
  9. package/apps/storefront/node_modules/.bin/cf-wrangler +43 -0
  10. package/apps/storefront/node_modules/.bin/depcruise +43 -0
  11. package/apps/storefront/node_modules/.bin/depcruise-baseline +43 -0
  12. package/apps/storefront/node_modules/.bin/depcruise-fmt +43 -0
  13. package/apps/storefront/node_modules/.bin/depcruise-wrap-stream-in-html +43 -0
  14. package/apps/storefront/node_modules/.bin/dependency-cruise +43 -0
  15. package/apps/storefront/node_modules/.bin/dependency-cruiser +43 -0
  16. package/apps/storefront/node_modules/.bin/drizzle-kit +43 -0
  17. package/apps/storefront/node_modules/.bin/esbuild +43 -0
  18. package/apps/storefront/node_modules/.bin/jiti +43 -0
  19. package/apps/storefront/node_modules/.bin/playwright +43 -0
  20. package/apps/storefront/node_modules/.bin/playwright-core +43 -0
  21. package/apps/storefront/node_modules/.bin/prettier +43 -0
  22. package/apps/storefront/node_modules/.bin/rollup +43 -0
  23. package/apps/storefront/node_modules/.bin/tsc +43 -0
  24. package/apps/storefront/node_modules/.bin/tsserver +43 -0
  25. package/apps/storefront/node_modules/.bin/tsx +43 -0
  26. package/apps/storefront/node_modules/.bin/vite +43 -0
  27. package/apps/storefront/node_modules/.bin/vitest +43 -0
  28. package/apps/storefront/node_modules/.bin/workerd +43 -0
  29. package/apps/storefront/node_modules/.bin/wrangler +43 -0
  30. package/apps/storefront/node_modules/.bin/wrangler2 +43 -0
  31. package/apps/storefront/node_modules/.bin/yaml +43 -0
  32. package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_client__js.js +58 -0
  33. package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_client__js.js.map +1 -0
  34. package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_n_@preact_signals.js +741 -0
  35. package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_n_@preact_signals.js.map +1 -0
  36. package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_server__js.js +2549 -0
  37. package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_server__js.js.map +1 -0
  38. package/apps/storefront/node_modules/.vite/deps/_metadata.json +150 -0
  39. package/apps/storefront/node_modules/.vite/deps/astro-BIHuWFxm.js +434 -0
  40. package/apps/storefront/node_modules/.vite/deps/astro-BIHuWFxm.js.map +1 -0
  41. package/apps/storefront/node_modules/.vite/deps/astro_n_aria-query.js +6390 -0
  42. package/apps/storefront/node_modules/.vite/deps/astro_n_aria-query.js.map +1 -0
  43. package/apps/storefront/node_modules/.vite/deps/astro_n_axobject-query.js +2705 -0
  44. package/apps/storefront/node_modules/.vite/deps/astro_n_axobject-query.js.map +1 -0
  45. package/apps/storefront/node_modules/.vite/deps/astro_n_html-escaper.js +65 -0
  46. package/apps/storefront/node_modules/.vite/deps/astro_n_html-escaper.js.map +1 -0
  47. package/apps/storefront/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js +455 -0
  48. package/apps/storefront/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js.map +1 -0
  49. package/apps/storefront/node_modules/.vite/deps/audit-DKDpcdEI.js +1535 -0
  50. package/apps/storefront/node_modules/.vite/deps/audit-DKDpcdEI.js.map +1 -0
  51. package/apps/storefront/node_modules/.vite/deps/highlight-B9UoX1wa.js +48 -0
  52. package/apps/storefront/node_modules/.vite/deps/highlight-B9UoX1wa.js.map +1 -0
  53. package/apps/storefront/node_modules/.vite/deps/icons-BWi2wI9n.js +42 -0
  54. package/apps/storefront/node_modules/.vite/deps/icons-BWi2wI9n.js.map +1 -0
  55. package/apps/storefront/node_modules/.vite/deps/package.json +3 -0
  56. package/apps/storefront/node_modules/.vite/deps/preact.js +310 -0
  57. package/apps/storefront/node_modules/.vite/deps/preact.js.map +1 -0
  58. package/apps/storefront/node_modules/.vite/deps/preact_compat.js +420 -0
  59. package/apps/storefront/node_modules/.vite/deps/preact_compat.js.map +1 -0
  60. package/apps/storefront/node_modules/.vite/deps/preact_compat_jsx-runtime.js +4 -0
  61. package/apps/storefront/node_modules/.vite/deps/preact_debug.js +236 -0
  62. package/apps/storefront/node_modules/.vite/deps/preact_debug.js.map +1 -0
  63. package/apps/storefront/node_modules/.vite/deps/preact_devtools.js +12 -0
  64. package/apps/storefront/node_modules/.vite/deps/preact_devtools.js.map +1 -0
  65. package/apps/storefront/node_modules/.vite/deps/preact_hooks.js +192 -0
  66. package/apps/storefront/node_modules/.vite/deps/preact_hooks.js.map +1 -0
  67. package/apps/storefront/node_modules/.vite/deps/preact_jsx-dev-runtime.js +3 -0
  68. package/apps/storefront/node_modules/.vite/deps/preact_jsx-runtime.js +92 -0
  69. package/apps/storefront/node_modules/.vite/deps/preact_jsx-runtime.js.map +1 -0
  70. package/apps/storefront/node_modules/.vite/deps/preact_test-utils.js +59 -0
  71. package/apps/storefront/node_modules/.vite/deps/preact_test-utils.js.map +1 -0
  72. package/apps/storefront/node_modules/.vite/deps/rolldown-runtime-BPOCksWG.js +24 -0
  73. package/apps/storefront/node_modules/.vite/deps/settings-BVDV0Roj.js +189 -0
  74. package/apps/storefront/node_modules/.vite/deps/settings-BVDV0Roj.js.map +1 -0
  75. package/apps/storefront/node_modules/.vite/deps/static-html-DveYqwMo.js +13 -0
  76. package/apps/storefront/node_modules/.vite/deps/static-html-DveYqwMo.js.map +1 -0
  77. package/apps/storefront/node_modules/.vite/deps/toolbar-B3VIHCXg.js +458 -0
  78. package/apps/storefront/node_modules/.vite/deps/toolbar-B3VIHCXg.js.map +1 -0
  79. package/apps/storefront/node_modules/.vite/deps/ui-library-BK6ricUd.js +1016 -0
  80. package/apps/storefront/node_modules/.vite/deps/ui-library-BK6ricUd.js.map +1 -0
  81. package/apps/storefront/node_modules/.vite/deps/window-BvxDenKN.js +32 -0
  82. package/apps/storefront/node_modules/.vite/deps/window-BvxDenKN.js.map +1 -0
  83. package/apps/storefront/node_modules/.vite/deps/xray-YFiRUU0Z.js +113 -0
  84. package/apps/storefront/node_modules/.vite/deps/xray-YFiRUU0Z.js.map +1 -0
  85. package/apps/storefront/node_modules/.vite/deps_ssr/_metadata.json +8 -0
  86. package/apps/storefront/node_modules/.vite/deps_ssr/package.json +3 -0
  87. package/apps/storefront/src/components/chrome/MegaMenu.astro +11 -1
  88. package/apps/storefront/src/components/chrome/NavDropdown.astro +49 -7
  89. package/apps/storefront/src/components/chrome/SiteFooter.astro +29 -4
  90. package/apps/storefront/src/components/chrome/SiteHeader.astro +39 -6
  91. package/apps/storefront/src/components/chrome/UtilityNav.astro +13 -1
  92. package/apps/storefront/src/components/islands/MobileNav.tsx +42 -2
  93. package/apps/storefront/src/layouts/Layout.astro +65 -7
  94. package/apps/storefront/src/lib/apps/registryAccess.ts +45 -0
  95. package/apps/storefront/src/lib/auth/adminEntry.ts +4 -4
  96. package/apps/storefront/src/lib/chrome/ctaIdentity.ts +21 -0
  97. package/apps/storefront/src/lib/chrome/examples/tokenoftrust.chrome.example.json +22 -22
  98. package/apps/storefront/src/lib/chrome/trackingPlacements.ts +36 -0
  99. package/apps/storefront/src/lib/d1/schema-apps.ts +57 -0
  100. package/apps/storefront/src/lib/rawChrome.ts +73 -14
  101. package/apps/storefront/src/lib/rawMarketingHtml.ts +30 -0
  102. package/apps/storefront/src/lib/shared/capability.ts +68 -0
  103. package/apps/storefront/src/lib/shared/developerInvites.ts +1250 -0
  104. package/apps/storefront/src/lib/shared/invitePreflight.ts +123 -0
  105. package/apps/storefront/src/lib/storyblok/provider.ts +21 -5
  106. package/apps/storefront/src/lib/tracking/collectorAllowlist.ts +40 -0
  107. package/apps/storefront/src/lib/tracking/collectorIngest.ts +188 -0
  108. package/apps/storefront/src/lib/tracking/d1Control.ts +180 -0
  109. package/apps/storefront/src/lib/tracking/interactionCollector.ts +202 -0
  110. package/apps/storefront/src/lib/tracking/interactionHealth.ts +252 -0
  111. package/apps/storefront/src/lib/tracking/interactionSchema.ts +107 -0
  112. package/apps/storefront/src/lib/tracking/interactionStore.ts +0 -0
  113. package/apps/storefront/src/lib/tracking/pruneRollup.ts +260 -0
  114. package/apps/storefront/src/lib/tracking/publishSafetyNet.ts +162 -0
  115. package/apps/storefront/src/lib/tracking/tenantD1Registry.ts +214 -0
  116. package/apps/storefront/src/lib/tracking/tenantReadAccess.ts +138 -0
  117. package/apps/storefront/src/lib/tracking-writeback/rewriteTrackingIds.ts +175 -0
  118. package/apps/storefront/src/lib/tracking-writeback/writebackCandidate.ts +110 -0
  119. package/apps/storefront/src/lib/webhooks/deliveryEngine.ts +38 -4
  120. package/apps/storefront/src/lib/webhooks/emit.ts +1 -1
  121. package/apps/storefront/src/lib/webhooks/getDispatcher.ts +1 -1
  122. package/apps/storefront/src/pages/api/admin/interaction-insights.ts +101 -0
  123. package/apps/storefront/src/pages/api/auth/magic-exchange.ts +1 -1
  124. package/apps/storefront/src/pages/api/tracking/interaction.ts +123 -0
  125. package/apps/storefront/src/pages/index.astro +1 -1
  126. package/apps/storefront/src/pages/products/[handle].astro +1 -1
  127. package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +33 -14
  128. package/apps/storefront/src/workers/pruneRollupWorker.ts +120 -0
  129. package/package.json +1 -1
  130. package/packages/public-runtime/node_modules/.bin/tsc +43 -0
  131. package/packages/public-runtime/node_modules/.bin/tsserver +43 -0
  132. package/packages/public-runtime/node_modules/.bin/vitest +43 -0
  133. package/packages/public-runtime/src/chrome.ts +18 -7
  134. package/packages/public-runtime/src/index.ts +10 -0
  135. package/packages/public-runtime/src/integration/index.ts +1 -0
  136. package/packages/public-runtime/src/integration/interaction-tracking-gate.ts +142 -0
  137. package/packages/public-runtime/src/interaction-tracking.ts +364 -0
  138. package/packages/public-runtime/src/tracking-id-derivation.ts +1008 -0
  139. package/pnpm-workspace.yaml +3 -0
  140. package/scripts/tenant/validate.mjs +306 -1
  141. package/scripts/tot-dev.mjs +59 -3
  142. package/tenants/home/public/js/storefront.js +6 -87
  143. package/apps/storefront/src/lib/apps/adminService.ts +0 -116
  144. package/apps/storefront/src/lib/apps/adminSession.ts +0 -214
  145. package/apps/storefront/src/lib/apps/apiAuth.ts +0 -91
  146. package/apps/storefront/src/lib/apps/apiRoute.ts +0 -35
  147. package/apps/storefront/src/lib/apps/catalogMapper.ts +0 -39
  148. package/apps/storefront/src/lib/apps/consentConfigStore.ts +0 -228
  149. package/apps/storefront/src/lib/apps/gtmMigrationInstall.ts +0 -447
  150. package/apps/storefront/src/lib/apps/gtmMigrationReport.ts +0 -367
  151. package/apps/storefront/src/lib/apps/gtmMigrationTab.ts +0 -315
  152. package/apps/storefront/src/lib/apps/healthAggregate.ts +0 -66
  153. package/apps/storefront/src/lib/apps/integrationLifecycle.ts +0 -614
  154. package/apps/storefront/src/lib/apps/integrationLifecycleController.ts +0 -125
  155. package/apps/storefront/src/lib/apps/integrationReadModel.ts +0 -563
  156. package/apps/storefront/src/lib/apps/integrationsTab.ts +0 -547
  157. package/apps/storefront/src/lib/apps/integrationsView.ts +0 -106
  158. package/apps/storefront/src/lib/apps/managedAnalyticsService.ts +0 -599
  159. package/apps/storefront/src/lib/apps/tenantIntegrationMaterialization.ts +0 -170
  160. package/apps/storefront/src/lib/apps/tokenIssuer.ts +0 -121
  161. package/apps/storefront/src/lib/apps/tokenVerifier.ts +0 -148
  162. package/apps/storefront/src/lib/apps/v84CleanCloneReplayContext.ts +0 -16
  163. package/apps/storefront/src/lib/apps/v84CloneLifecycleCacheEvidence.ts +0 -105
  164. package/apps/storefront/src/lib/apps/v84CloneLifecycleEvidence.ts +0 -177
  165. package/apps/storefront/src/lib/apps/v84CloneLifecycleMaterialization.ts +0 -251
  166. package/apps/storefront/src/lib/apps/v84CloneLifecycleSemantics.ts +0 -241
  167. package/apps/storefront/src/lib/apps/v84CloneMigrationEvidence.ts +0 -211
  168. package/apps/storefront/src/lib/apps/v84CloneNativeInstallEvidence.ts +0 -409
  169. package/apps/storefront/src/lib/apps/v84NonportableCodeCoaching.ts +0 -368
  170. package/apps/storefront/src/lib/apps/v84NonportableRouteContract.ts +0 -188
  171. package/apps/storefront/src/lib/apps/v84NonportableRouteDogfood.ts +0 -291
  172. package/apps/storefront/src/lib/apps/ws1ManagedAnalyticsClient.ts +0 -95
  173. package/apps/storefront/src/lib/content-edit/applyDiff.ts +0 -91
  174. package/apps/storefront/src/lib/content-edit/client.ts +0 -286
  175. package/apps/storefront/src/lib/content-edit/index.ts +0 -14
  176. package/apps/storefront/src/lib/content-edit/route.ts +0 -25
  177. package/apps/storefront/src/lib/content-edit/types.ts +0 -19
  178. package/apps/storefront/src/pages/api/content-edit/ai-edit.ts +0 -164
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Consent + environment gate in front of interaction-tracking delivery
3
+ * (workstream `tot-storefront-declarative-tracking`, unit
4
+ * `dt-consent-environment-gate`).
5
+ *
6
+ * `interaction-tracking.ts`'s listener deliberately knows nothing about consent,
7
+ * environment, or tier — it hands every extracted {@link TotInteraction} straight
8
+ * to whatever `deliver` hook it was installed with. This module is that gate,
9
+ * wrapped IN FRONT of a caller-supplied `deliver`, never inside the listener:
10
+ *
11
+ * - CONSENT: the `analytics` purpose must be eligible under the current
12
+ * snapshot the neutral consent broker maintains
13
+ * ({@link isPurposeEligible}, `consent-broker.ts`). A missing or tampered
14
+ * `window.__TOT_INTEGRATION_CONSENT__` global fails closed to the
15
+ * default-deny snapshot, same as every other consumer of that global
16
+ * (`lifecycle-boot.ts`, the Cloudflare Web Analytics loader in
17
+ * `Layout.astro`).
18
+ * - ENVIRONMENT: the renderer-published four-stage environment
19
+ * (`resolveBrowserEnvironment`, `environment.ts`) must resolve AND must not
20
+ * be `candidate`. A per-change candidate render is a preview — it is never
21
+ * live, and its clicks are not real interaction data, so they are never
22
+ * delivered. An unresolved/invalid `window.__TOT_INTEGRATION_ENV__` global
23
+ * fails closed exactly like an unresolved consent snapshot: no delivery.
24
+ *
25
+ * Both checks are read fresh on EVERY interaction, never cached at wrap time —
26
+ * so a later consent grant (a CMP update, a native banner decision) or a stage
27
+ * change is honored on the very next click with no rebinding, the same posture
28
+ * `managed-analytics-bridge.ts` documents for its own consent handling
29
+ * ("Consent changes are intentionally an in-place no-op for bridge lifecycle").
30
+ *
31
+ * This module does not deliver, batch, transport, or allowlist actions — that is
32
+ * `dt-collector-v1`'s job. It answers exactly one question: should this
33
+ * interaction reach the deliver hook at all?
34
+ */
35
+ import type { InteractionDeliver, TotInteraction } from "../interaction-tracking.js";
36
+ import {
37
+ defaultConsentSnapshot,
38
+ isPurposeEligible,
39
+ parseConsentSnapshot,
40
+ type ConsentSnapshot,
41
+ } from "./consent-broker.js";
42
+ import { INTEGRATION_CONSENT_GLOBAL } from "./render-phases.js";
43
+ import {
44
+ validateBrowserEnvironment,
45
+ BROWSER_ENVIRONMENT_GLOBAL,
46
+ type BrowserEnvironment,
47
+ } from "./environment.js";
48
+
49
+ /** The consent purpose interaction tracking declares. A closed-vocabulary member. */
50
+ export const INTERACTION_TRACKING_CONSENT_PURPOSE = "analytics";
51
+
52
+ /**
53
+ * The narrow slice of `window` this gate reads — structural, so a test can pass a
54
+ * plain object instead of a real `window` and the gate stays edge-safe typed.
55
+ */
56
+ export interface InteractionTrackingGateWindow {
57
+ [key: string]: unknown;
58
+ }
59
+
60
+ export interface InteractionTrackingGateOptions {
61
+ /**
62
+ * The window to read the consent/environment globals off. Defaults to the
63
+ * global `window` when present; off the browser (no global `window`, and none
64
+ * supplied) both reads resolve to their fail-closed defaults, so delivery is
65
+ * always denied rather than throwing.
66
+ */
67
+ win?: InteractionTrackingGateWindow;
68
+ }
69
+
70
+ function resolveWindow(win?: InteractionTrackingGateWindow): InteractionTrackingGateWindow | null {
71
+ if (win) return win;
72
+ return typeof window !== "undefined" ? (window as unknown as InteractionTrackingGateWindow) : null;
73
+ }
74
+
75
+ /** Read the current consent snapshot off the renderer-inlined global. Fail-closed default-deny. */
76
+ function readConsentSnapshot(win: InteractionTrackingGateWindow | null): ConsentSnapshot {
77
+ return parseConsentSnapshot(win?.[INTEGRATION_CONSENT_GLOBAL]) ?? defaultConsentSnapshot();
78
+ }
79
+
80
+ /**
81
+ * Read the renderer-published environment. `null` when the global is
82
+ * absent/invalid — the same fail-closed posture `lifecycle-boot.ts` uses, except
83
+ * this gate has no descriptor-derived fallback to fall back to: an unresolved
84
+ * environment simply denies delivery.
85
+ */
86
+ function readBrowserEnvironment(win: InteractionTrackingGateWindow | null): BrowserEnvironment | null {
87
+ const result = validateBrowserEnvironment(win?.[BROWSER_ENVIRONMENT_GLOBAL]);
88
+ return result.ok ? result.value : null;
89
+ }
90
+
91
+ /**
92
+ * Whether an interaction may be delivered RIGHT NOW: consent AND environment
93
+ * must both permit it.
94
+ *
95
+ * - Consent: {@link INTERACTION_TRACKING_CONSENT_PURPOSE} must be eligible
96
+ * under the live snapshot.
97
+ * - Environment: must resolve, and must not be `"candidate"`.
98
+ *
99
+ * Exported standalone (in addition to {@link gateInteractionDelivery}) so a
100
+ * caller that needs the yes/no without wrapping a hook — e.g. a health/
101
+ * diagnostics assertion — can ask the same question the gate itself asks.
102
+ */
103
+ export function isInteractionDeliveryPermitted(
104
+ options: InteractionTrackingGateOptions = {},
105
+ ): boolean {
106
+ const win = resolveWindow(options.win);
107
+ const consent = readConsentSnapshot(win);
108
+ if (!isPurposeEligible(consent, INTERACTION_TRACKING_CONSENT_PURPOSE)) return false;
109
+ const environment = readBrowserEnvironment(win);
110
+ if (!environment) return false;
111
+ return environment.environment !== "candidate";
112
+ }
113
+
114
+ /**
115
+ * Wrap a delivery hook with the consent+environment gate. The returned function
116
+ * has the exact {@link InteractionDeliver} shape `installInteractionTracking`
117
+ * and `onInteraction` expect, so it drops in wherever an unwrapped `deliver`
118
+ * would go:
119
+ *
120
+ * ```ts
121
+ * import { onInteraction, gateInteractionDelivery } from "@tot/public-runtime";
122
+ *
123
+ * onInteraction(gateInteractionDelivery((interaction) => {
124
+ * // dt-collector-v1's transport — only ever called when consent AND
125
+ * // environment both currently permit it.
126
+ * }));
127
+ * ```
128
+ *
129
+ * A gated-out interaction is silently dropped: `deliver` is simply never
130
+ * called for it, there is no error, no queuing, and no retry. `options.win`
131
+ * is read at CALL TIME (never captured once at wrap time), so gating tracks
132
+ * live consent/environment changes across the wrapped hook's whole lifetime.
133
+ */
134
+ export function gateInteractionDelivery(
135
+ deliver: InteractionDeliver,
136
+ options: InteractionTrackingGateOptions = {},
137
+ ): InteractionDeliver {
138
+ return (interaction: TotInteraction): void => {
139
+ if (!isInteractionDeliveryPermitted(options)) return;
140
+ deliver(interaction);
141
+ };
142
+ }
@@ -0,0 +1,364 @@
1
+ /**
2
+ * Declarative interaction tracking — the attribute contract, its emit side, and
3
+ * the ONE delegated click listener that reads it.
4
+ *
5
+ * The contract is three attributes with three DIFFERENT stability properties,
6
+ * plus an explicit opt-out. Nothing here is inferred: no DOM-position
7
+ * heuristics, no id-prefix stripping, no derived defaults. A required field
8
+ * that is absent is a validation failure (chrome.ts) or a stamp-time throw
9
+ * ({@link InvalidTrackingStampError}) — never something papered over.
10
+ *
11
+ * data-tot-el IDENTITY. This specific element. Assigned once,
12
+ * permanent, travels with the element through
13
+ * restructuring. A change here is drift = defect.
14
+ * data-tot-action ACTION / INTENT ("book-demo", "nav-pricing"). Stable,
15
+ * and deliberately SHARED across every placement of the
16
+ * same intent.
17
+ * data-tot-placement PLACEMENT / CONTEXT ("header", "hero", "footer",
18
+ * "mobile-menu"). Expected to change across redesigns; a
19
+ * change here is DATA, not drift. Emitted EXPLICITLY by
20
+ * the renderer — never inferred from DOM position or
21
+ * ancestry.
22
+ *
23
+ * Repeat containers carry the instance pair, resolved by nearest ancestor:
24
+ * data-tot-scope the repeat container ("nav-primary", "product-grid")
25
+ * data-tot-i the zero-based instance index within that scope
26
+ *
27
+ * OPT-OUT: `tracking: "none"` on a chrome item stamps NOTHING on the element —
28
+ * no `data-tot-el`, no action, no placement. ABSENCE is the signal; there is
29
+ * deliberately no sentinel value, because a sentinel would still match the
30
+ * listener's `[data-tot-el]` selector.
31
+ *
32
+ * PAYLOAD GUARDRAIL: this module never reads rendered content. No
33
+ * `textContent`, `innerText`, `innerHTML`, `alt`, `title`, `value`,
34
+ * `placeholder`, and no form-field reads — only `getAttribute` and `closest`
35
+ * against the declared attributes above. Machine-enforced by a source sweep in
36
+ * tests/interaction-tracking.test.ts, so the guarantee survives edits.
37
+ *
38
+ * Full contract, including how downstream units plug in:
39
+ * docs/declarative-interaction-tracking-contract.md
40
+ */
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // The attribute names — one source of truth for every emitter and reader.
44
+ // ---------------------------------------------------------------------------
45
+
46
+ export const TRACKING_ATTR_EL = "data-tot-el";
47
+ export const TRACKING_ATTR_ACTION = "data-tot-action";
48
+ export const TRACKING_ATTR_PLACEMENT = "data-tot-placement";
49
+ export const TRACKING_ATTR_SCOPE = "data-tot-scope";
50
+ export const TRACKING_ATTR_INDEX = "data-tot-i";
51
+
52
+ /** The DOM event the installed listener dispatches when given no `deliver` hook. */
53
+ export const TOT_INTERACTION_EVENT = "tot:interaction";
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Declaration — the authored half of the contract.
57
+ // ---------------------------------------------------------------------------
58
+
59
+ /**
60
+ * An element either declares a tracked ACTION or explicitly opts out. The union
61
+ * makes "neither" and "both" unrepresentable in TypeScript; `validateTracking`
62
+ * is the runtime oracle for authored JSON, which TypeScript never sees.
63
+ */
64
+ export type TrackingDeclaration =
65
+ | { action: string; tracking?: undefined }
66
+ | { action?: undefined; tracking: "none" };
67
+
68
+ /** Everything the renderer must know to stamp one element. */
69
+ export interface TrackingStamp {
70
+ /** IDENTITY — assigned once, permanent. */
71
+ el: string;
72
+ /** ACTION / INTENT — shared across placements. Absent iff opted out. */
73
+ action?: string | undefined;
74
+ /** Explicit opt-out. When "none", NOTHING is stamped. */
75
+ tracking?: "none" | undefined;
76
+ /** PLACEMENT / CONTEXT — always passed explicitly by the renderer. */
77
+ placement: string;
78
+ /** Repeat-container identity, when this element sits in one. */
79
+ scope?: string | undefined;
80
+ /** Zero-based instance index within `scope`. */
81
+ index?: number | undefined;
82
+ }
83
+
84
+ /**
85
+ * Thrown when a renderer asks to stamp an element without the fields the
86
+ * contract requires. Fails closed at render time with an actionable message
87
+ * rather than emitting a partial, un-analyzable element — a missing `el`,
88
+ * `action`, or `placement` is a bug in the caller, not a degraded-but-safe
89
+ * state.
90
+ */
91
+ export class InvalidTrackingStampError extends Error {
92
+ constructor(message: string) {
93
+ super(
94
+ `${message} — declarative interaction tracking requires an explicit ${TRACKING_ATTR_EL} ` +
95
+ `(identity), ${TRACKING_ATTR_ACTION} (intent) and ${TRACKING_ATTR_PLACEMENT} (context) on ` +
96
+ `every tracked element, or \`tracking: "none"\` to stamp nothing at all. There is no derived ` +
97
+ `default and no inference from DOM position.`,
98
+ );
99
+ this.name = "InvalidTrackingStampError";
100
+ }
101
+ }
102
+
103
+ function nonEmpty(v: unknown): v is string {
104
+ return typeof v === "string" && v.trim().length > 0;
105
+ }
106
+
107
+ /**
108
+ * The attributes to stamp on one element, or `{}` when the element opted out.
109
+ * The single emit path for every renderer — component-rendered chrome and
110
+ * string-built chrome alike — so no path can invent its own attribute set.
111
+ */
112
+ export function trackingAttributes(stamp: TrackingStamp): Record<string, string> {
113
+ if (stamp.tracking === "none") {
114
+ if (nonEmpty(stamp.action)) {
115
+ throw new InvalidTrackingStampError(
116
+ `${JSON.stringify(stamp.el)} declares both an \`action\` and \`tracking: "none"\``,
117
+ );
118
+ }
119
+ return {};
120
+ }
121
+ if (!nonEmpty(stamp.el)) throw new InvalidTrackingStampError("a tracked element has no `el` identity");
122
+ if (!nonEmpty(stamp.action)) {
123
+ throw new InvalidTrackingStampError(`${JSON.stringify(stamp.el)} has no \`action\``);
124
+ }
125
+ if (!nonEmpty(stamp.placement)) {
126
+ throw new InvalidTrackingStampError(`${JSON.stringify(stamp.el)} has no \`placement\``);
127
+ }
128
+ const attrs: Record<string, string> = {
129
+ [TRACKING_ATTR_EL]: stamp.el,
130
+ [TRACKING_ATTR_ACTION]: stamp.action,
131
+ [TRACKING_ATTR_PLACEMENT]: stamp.placement,
132
+ };
133
+ if (nonEmpty(stamp.scope)) attrs[TRACKING_ATTR_SCOPE] = stamp.scope;
134
+ if (stamp.index !== undefined) {
135
+ if (!Number.isInteger(stamp.index) || stamp.index < 0) {
136
+ throw new InvalidTrackingStampError(`${JSON.stringify(stamp.el)} has a non-integer \`index\``);
137
+ }
138
+ attrs[TRACKING_ATTR_INDEX] = String(stamp.index);
139
+ }
140
+ return attrs;
141
+ }
142
+
143
+ function escAttr(value: string): string {
144
+ return value
145
+ .replaceAll("&", "&amp;")
146
+ .replaceAll("<", "&lt;")
147
+ .replaceAll(">", "&gt;")
148
+ .replaceAll('"', "&quot;");
149
+ }
150
+
151
+ /**
152
+ * {@link trackingAttributes} serialized for the string-building renderers
153
+ * (rawChrome.ts). Returns "" for an opted-out element, otherwise a
154
+ * LEADING-SPACE-prefixed attribute run ready to splice inside a start tag.
155
+ */
156
+ export function trackingAttributesHtml(stamp: TrackingStamp): string {
157
+ const attrs = trackingAttributes(stamp);
158
+ return Object.keys(attrs)
159
+ .map((name) => ` ${name}="${escAttr(attrs[name] as string)}"`)
160
+ .join("");
161
+ }
162
+
163
+ /**
164
+ * Validate the tracking half of one authored chrome item: EXACTLY ONE of a
165
+ * non-empty `action` or `tracking: "none"`. Neither is a failure (an item
166
+ * nobody declared an intent for is un-analyzable); both is a failure (the
167
+ * author contradicted themselves). Push-into-`errors` shape so it composes
168
+ * with chrome.ts's collect-every-violation validators.
169
+ */
170
+ export function validateTracking(v: Record<string, unknown>, path: string, errors: string[]): void {
171
+ const hasAction = nonEmpty(v.action);
172
+ const optedOut = v.tracking === "none";
173
+ if (v.action !== undefined && !hasAction) {
174
+ errors.push(`${path}.action: must be a non-empty string`);
175
+ }
176
+ if (v.tracking !== undefined && !optedOut) {
177
+ errors.push(`${path}.tracking: the only accepted value is "none"`);
178
+ }
179
+ if (hasAction && optedOut) {
180
+ errors.push(
181
+ `${path}: declares both \`action\` and \`tracking: "none"\` — an element is either tracked ` +
182
+ `under one stable intent or explicitly untracked, never both`,
183
+ );
184
+ }
185
+ if (!hasAction && !optedOut && v.tracking === undefined) {
186
+ errors.push(
187
+ `${path}.action: required, non-empty string — the stable ACTION/INTENT this element performs ` +
188
+ `(e.g. "book-demo", "nav-pricing"), shared across every placement of that intent. Set ` +
189
+ `\`"tracking": "none"\` instead to stamp nothing on this element.`,
190
+ );
191
+ }
192
+ }
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // Read side — the ONE delegated click listener.
196
+ // ---------------------------------------------------------------------------
197
+
198
+ /** What the listener extracts. Attribute values only; never rendered content. */
199
+ export interface TotInteraction {
200
+ /** The interaction kind. Only "click" exists in V1. */
201
+ type: "click";
202
+ /** `data-tot-el` — the element's permanent identity. */
203
+ el: string;
204
+ /** `data-tot-action`, or null when the element carries no action attribute. */
205
+ action: string | null;
206
+ /** `data-tot-placement`, or null when unstamped. */
207
+ placement: string | null;
208
+ /** `data-tot-scope` from the nearest ancestor repeat container, else null. */
209
+ scope: string | null;
210
+ /** `data-tot-i` from the nearest ancestor carrying it, else null. */
211
+ index: number | null;
212
+ }
213
+
214
+ /** The delivery hook. Downstream units supply this; this module never delivers. */
215
+ export type InteractionDeliver = (interaction: TotInteraction) => void;
216
+
217
+ /** A node as this module reads it: declared attributes and ancestor lookup only. */
218
+ export interface TrackingNode {
219
+ getAttribute?(name: string): string | null;
220
+ closest?(selector: string): TrackingNode | null;
221
+ }
222
+
223
+ /** The event target the listener attaches to (`document` in a browser). */
224
+ export interface InteractionTarget {
225
+ addEventListener(type: string, handler: (event: unknown) => void, capture?: boolean): void;
226
+ removeEventListener(type: string, handler: (event: unknown) => void, capture?: boolean): void;
227
+ dispatchEvent?(event: unknown): unknown;
228
+ /** Install-once marker, so the three injection paths can all install safely. */
229
+ __totInteractionTracking?: boolean;
230
+ }
231
+
232
+ /**
233
+ * The listener itself. SELF-CONTAINED by construction: it references no
234
+ * module-scope binding, so {@link INTERACTION_TRACKING_BOOT_JS} can serialize it
235
+ * verbatim for the string-injection render paths and there is exactly ONE
236
+ * implementation of the read side in the repo. Type annotations are erased at
237
+ * build time and are therefore free.
238
+ *
239
+ * Keep it that way — a reference to an outer constant would survive
240
+ * minification as a mangled, undefined name in the serialized copy. The
241
+ * duplicated attribute-name literals are pinned to the exported constants by a
242
+ * test.
243
+ */
244
+ function interactionTrackingBoot(
245
+ deliver: InteractionDeliver | null,
246
+ target: InteractionTarget | null,
247
+ ): () => void {
248
+ const noop = function (): void {};
249
+ if (!target || typeof target.addEventListener !== "function") return noop;
250
+ if (target.__totInteractionTracking) return noop;
251
+ target.__totInteractionTracking = true;
252
+
253
+ const EL = "data-tot-el";
254
+ const ACTION = "data-tot-action";
255
+ const PLACEMENT = "data-tot-placement";
256
+ const SCOPE = "data-tot-scope";
257
+ const INDEX = "data-tot-i";
258
+ const EVENT = "tot:interaction";
259
+
260
+ const attr = function (node: TrackingNode | null, name: string): string | null {
261
+ return node && typeof node.getAttribute === "function" ? node.getAttribute(name) : null;
262
+ };
263
+ // Nearest ancestor-or-self carrying `name`. A tracked CTA commonly wraps child
264
+ // markup (an icon, a nested span), so the click target is often NOT the
265
+ // tracked element — closest-match, never exact-target-only.
266
+ const nearest = function (node: TrackingNode | null, name: string): TrackingNode | null {
267
+ return node && typeof node.closest === "function" ? node.closest("[" + name + "]") : null;
268
+ };
269
+
270
+ const handler = function (event: unknown): void {
271
+ const origin = (event ? (event as { target?: TrackingNode | null }).target : null) ?? null;
272
+ const el = nearest(origin, EL);
273
+ const id = attr(el, EL);
274
+ // No identity anywhere up the ancestor chain: nothing to report.
275
+ if (!el || !id) return;
276
+ const rawIndex = attr(nearest(el, INDEX), INDEX);
277
+ const parsedIndex = rawIndex === null || rawIndex.trim() === "" ? Number.NaN : Number(rawIndex);
278
+ const interaction: TotInteraction = {
279
+ type: "click",
280
+ el: id,
281
+ action: attr(el, ACTION),
282
+ placement: attr(el, PLACEMENT),
283
+ scope: attr(nearest(el, SCOPE), SCOPE),
284
+ index: Number.isInteger(parsedIndex) ? parsedIndex : null,
285
+ };
286
+ if (typeof deliver === "function") {
287
+ deliver(interaction);
288
+ return;
289
+ }
290
+ if (typeof target.dispatchEvent === "function" && typeof CustomEvent === "function") {
291
+ target.dispatchEvent(new CustomEvent(EVENT, { detail: interaction }));
292
+ }
293
+ };
294
+
295
+ // Capture phase: a tracked click still reports even when intervening markup
296
+ // calls stopPropagation().
297
+ target.addEventListener("click", handler, true);
298
+ return function (): void {
299
+ target.removeEventListener("click", handler, true);
300
+ target.__totInteractionTracking = false;
301
+ };
302
+ }
303
+
304
+ /**
305
+ * Install the delegated click listener on `target` (default: `document`).
306
+ * Idempotent — the second install on the same target is a no-op, which is what
307
+ * lets all three render paths install unconditionally.
308
+ *
309
+ * `deliver` is the ONLY extension point: consent gating, environment gating,
310
+ * ingest allowlisting and transport all live in the hook's implementation
311
+ * (other units), never here. Omit it and each interaction is dispatched as a
312
+ * {@link TOT_INTERACTION_EVENT} CustomEvent instead, which is how the
313
+ * string-injected render paths hand off.
314
+ *
315
+ * @returns an uninstall function.
316
+ */
317
+ export function installInteractionTracking(
318
+ options: { deliver?: InteractionDeliver; target?: InteractionTarget } = {},
319
+ ): () => void {
320
+ const target =
321
+ options.target ??
322
+ (typeof document !== "undefined" ? (document as unknown as InteractionTarget) : null);
323
+ return interactionTrackingBoot(options.deliver ?? null, target);
324
+ }
325
+
326
+ /**
327
+ * Subscribe to interactions delivered as {@link TOT_INTERACTION_EVENT} events —
328
+ * the hand-off for code that cannot pass a `deliver` hook at install time
329
+ * (anything running on a string-injected page). Returns an unsubscribe function.
330
+ */
331
+ export function onInteraction(
332
+ handler: InteractionDeliver,
333
+ target?: InteractionTarget,
334
+ ): () => void {
335
+ const t =
336
+ target ?? (typeof document !== "undefined" ? (document as unknown as InteractionTarget) : null);
337
+ if (!t || typeof t.addEventListener !== "function") return () => {};
338
+ const listener = (event: unknown): void => {
339
+ const detail = (event as { detail?: TotInteraction } | null)?.detail;
340
+ if (detail) handler(detail);
341
+ };
342
+ t.addEventListener(TOT_INTERACTION_EVENT, listener);
343
+ return () => t.removeEventListener(TOT_INTERACTION_EVENT, listener);
344
+ }
345
+
346
+ /**
347
+ * {@link interactionTrackingBoot}'s own compiled source — the anchor that makes
348
+ * "one implementation, every render path" checkable: the imported path calls the
349
+ * function, the string-injected paths embed exactly this text. Exported so a
350
+ * test can evaluate the serialized copy and assert it behaves identically to the
351
+ * imported one.
352
+ */
353
+ export const INTERACTION_TRACKING_LISTENER_SOURCE = String(interactionTrackingBoot);
354
+
355
+ /**
356
+ * The listener as an inline `<script>` body, for the render paths that build
357
+ * HTML as strings and have no module graph to import from. Serialized from
358
+ * {@link interactionTrackingBoot} itself, so the string-injected paths and the
359
+ * imported path can never drift apart.
360
+ *
361
+ * Contains no `</script>` sequence, and is safe to embed more than once per
362
+ * document (the install-once marker makes the later copies no-ops).
363
+ */
364
+ export const INTERACTION_TRACKING_BOOT_JS = `(${INTERACTION_TRACKING_LISTENER_SOURCE})(null, typeof document !== "undefined" ? document : null);`;