@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,202 @@
1
+ /**
2
+ * dt-collector-v1 — the browser-side delivery destination for
3
+ * `TotInteraction` (`@tot/public-runtime`'s declarative interaction-tracking
4
+ * contract). This module OWNS batching + transport; it does not install the
5
+ * click listener and does not gate on consent/environment — those are
6
+ * `interaction-tracking.ts` and `dt-consent-environment-gate` respectively.
7
+ *
8
+ * Wiring (owned by whichever module composes the gate in front of delivery,
9
+ * e.g. `dt-consent-environment-gate`):
10
+ *
11
+ * import { onInteraction } from "@tot/public-runtime";
12
+ * import { collectInteraction } from "@/lib/tracking/interactionCollector";
13
+ * onInteraction((interaction) => {
14
+ * if (consentAndEnvironmentAllow()) collectInteraction(interaction);
15
+ * });
16
+ *
17
+ * or, where the caller controls install directly:
18
+ *
19
+ * installInteractionTracking({ deliver: collectInteraction });
20
+ *
21
+ * PAYLOAD GUARDRAIL: this module reads only the five `TotInteraction` fields
22
+ * handed to it, plus what it synthesizes itself (`ts`, `correlationId`,
23
+ * `seq`). No DOM access, no `textContent`/`href`/form reads — same prohibition
24
+ * as `interaction-tracking.ts`'s listener; see that module's guardrail test
25
+ * (`tests/interaction-tracking.test.ts`) for the pattern this module must not
26
+ * violate either.
27
+ */
28
+ import type { TotInteraction } from "@tot/public-runtime";
29
+
30
+ /** Where the browser beacon POSTs interaction batches. */
31
+ export const INTERACTION_BEACON_PATH = "/api/tracking/interaction";
32
+
33
+ /** Max events held in memory before a forced flush — bounds one beacon's size
34
+ * and the worst case if flush triggers never fire (e.g. a test/SSR context). */
35
+ export const MAX_QUEUE_SIZE = 20;
36
+
37
+ /** The wire shape POSTed to `INTERACTION_BEACON_PATH` — `TotInteraction`'s five
38
+ * fields plus this module's own synthesized correlation fields. Must stay in
39
+ * lockstep with `INTERACTION_WIRE_ALLOWLIST`
40
+ * (`apps/storefront/src/lib/tracking/collectorAllowlist.ts`). */
41
+ export interface InteractionWireEvent {
42
+ type: TotInteraction["type"];
43
+ el: string;
44
+ action: string | null;
45
+ placement: string | null;
46
+ scope: string | null;
47
+ index: number | null;
48
+ /** Client emit clock, ISO-8601. */
49
+ ts: string;
50
+ /** Groups every event from one collector lifetime (one page load). */
51
+ correlationId: string;
52
+ /** Monotonic within `correlationId`, starting at 0. */
53
+ seq: number;
54
+ }
55
+
56
+ /** Build the wire event for one interaction. Pure — no DOM, no clock side
57
+ * effects beyond the `now` parameter, so it is unit-testable without faking
58
+ * globals. */
59
+ export function toWireEvent(
60
+ interaction: TotInteraction,
61
+ correlationId: string,
62
+ seq: number,
63
+ now: () => string = () => new Date().toISOString(),
64
+ ): InteractionWireEvent {
65
+ return {
66
+ type: interaction.type,
67
+ el: interaction.el,
68
+ action: interaction.action,
69
+ placement: interaction.placement,
70
+ scope: interaction.scope,
71
+ index: interaction.index,
72
+ ts: now(),
73
+ correlationId,
74
+ seq,
75
+ };
76
+ }
77
+
78
+ /** A sink for a finished batch. The default posts one JSON beacon. */
79
+ export type InteractionBatchSender = (events: InteractionWireEvent[]) => void;
80
+
81
+ /** Default sender: one `sendBeacon` per flushed batch, falling back to a
82
+ * keepalive `fetch` where `sendBeacon` is absent (mirrors
83
+ * `analytics/webVitals.ts`'s `beaconReporter`). Never throws into the page. */
84
+ export function beaconBatchSender(endpoint: string = INTERACTION_BEACON_PATH): InteractionBatchSender {
85
+ return (events) => {
86
+ if (events.length === 0 || typeof navigator === "undefined") return;
87
+ const body = JSON.stringify({ events });
88
+ try {
89
+ if (typeof navigator.sendBeacon === "function") {
90
+ navigator.sendBeacon(endpoint, body);
91
+ } else if (typeof fetch === "function") {
92
+ void fetch(endpoint, { method: "POST", body, keepalive: true });
93
+ }
94
+ } catch {
95
+ // Never let telemetry throw into the page.
96
+ }
97
+ };
98
+ }
99
+
100
+ function randomCorrelationId(): string {
101
+ const c: Crypto | undefined = (globalThis as { crypto?: Crypto }).crypto;
102
+ if (c?.randomUUID) return c.randomUUID();
103
+ return `ic-${Date.now()}-${Math.random().toString(36).slice(2)}`;
104
+ }
105
+
106
+ /**
107
+ * A collector instance: batches interactions in memory and flushes them to
108
+ * `send` on demand, on queue overflow, or when the caller wires
109
+ * `flush`-on-`visibilitychange`/`pagehide` (see {@link installFlushTriggers}).
110
+ * Exposed as a factory (rather than one module-level singleton) so tests can
111
+ * construct an isolated instance with a fake sender and clock.
112
+ */
113
+ export interface InteractionCollector {
114
+ /** Enqueue one interaction; synthesizes `ts`/`correlationId`/`seq`. Flushes
115
+ * immediately once the queue reaches `MAX_QUEUE_SIZE`. */
116
+ collect(interaction: TotInteraction): void;
117
+ /** Send whatever is queued right now (no-op if empty). */
118
+ flush(): void;
119
+ }
120
+
121
+ export function createInteractionCollector(
122
+ send: InteractionBatchSender = beaconBatchSender(),
123
+ correlationId: string = randomCorrelationId(),
124
+ now?: () => string,
125
+ ): InteractionCollector {
126
+ let queue: InteractionWireEvent[] = [];
127
+ let seq = 0;
128
+
129
+ const flush = (): void => {
130
+ if (queue.length === 0) return;
131
+ const batch = queue;
132
+ queue = [];
133
+ send(batch);
134
+ };
135
+
136
+ const collect = (interaction: TotInteraction): void => {
137
+ queue.push(toWireEvent(interaction, correlationId, seq, now));
138
+ seq += 1;
139
+ if (queue.length >= MAX_QUEUE_SIZE) flush();
140
+ };
141
+
142
+ return { collect, flush };
143
+ }
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // Browser wiring (thin — everything above is pure + unit-tested).
147
+ // ---------------------------------------------------------------------------
148
+
149
+ /**
150
+ * Wire `collector.flush` to the two reliable end-of-visibility moments (same
151
+ * pair `analytics/webVitals.ts#startWebVitals` uses): the page first going
152
+ * hidden, and `pagehide` — the last point mobile Safari guarantees a beacon
153
+ * can still be sent. No-op teardown when there is no `document`/`window`
154
+ * (SSR, tests).
155
+ */
156
+ export function installFlushTriggers(collector: InteractionCollector): () => void {
157
+ if (typeof document === "undefined" || typeof window === "undefined") return () => {};
158
+ const onVisibility = (): void => {
159
+ if (document.visibilityState === "hidden") collector.flush();
160
+ };
161
+ document.addEventListener("visibilitychange", onVisibility, { capture: true });
162
+ window.addEventListener("pagehide", collector.flush, { capture: true });
163
+ return () => {
164
+ document.removeEventListener("visibilitychange", onVisibility, { capture: true });
165
+ window.removeEventListener("pagehide", collector.flush, { capture: true });
166
+ };
167
+ }
168
+
169
+ let defaultCollector: InteractionCollector | null = null;
170
+ let defaultTeardown: (() => void) | null = null;
171
+
172
+ function getDefaultCollector(): InteractionCollector {
173
+ if (!defaultCollector) {
174
+ defaultCollector = createInteractionCollector();
175
+ defaultTeardown = installFlushTriggers(defaultCollector);
176
+ }
177
+ return defaultCollector;
178
+ }
179
+
180
+ /**
181
+ * The default delivery destination: `installInteractionTracking({ deliver:
182
+ * collectInteraction })` or forward into it from an `onInteraction` subscriber
183
+ * (that is what the consent/environment gate does). Lazily creates the
184
+ * module-level collector and its flush wiring on first call, so importing
185
+ * this module has no side effects until an interaction actually arrives.
186
+ */
187
+ export function collectInteraction(interaction: TotInteraction): void {
188
+ getDefaultCollector().collect(interaction);
189
+ }
190
+
191
+ /** Force-flush the default collector (e.g. from a manual "send now" hook). */
192
+ export function flushInteractions(): void {
193
+ defaultCollector?.flush();
194
+ }
195
+
196
+ /** Test-only: drop the module-level singleton so a fresh collector/wiring is
197
+ * created on the next {@link collectInteraction} call. */
198
+ export function resetDefaultCollectorForTests(): void {
199
+ defaultTeardown?.();
200
+ defaultCollector = null;
201
+ defaultTeardown = null;
202
+ }
@@ -0,0 +1,252 @@
1
+ /**
2
+ * The interaction-tracking chain's HEALTH ASSERTION — the thing that makes a
3
+ * broken collection chain loud instead of silent.
4
+ *
5
+ * Why it exists. Every other part of this system is correctly fail-quiet at
6
+ * runtime: the delegated listener returns without an event when there is no
7
+ * `data-tot-el` up the ancestor chain (that is the `tracking: "none"` opt-out,
8
+ * where ABSENCE is the declaration), and the consent/environment gate drops a
9
+ * disallowed interaction with no error. Both are right. Together they mean a
10
+ * surface that was never stamped at all produces exactly the same observable
11
+ * result as a surface nobody clicked — which is the failure mode that made the
12
+ * legacy mixin useless for years: no way to tell "collection is broken" from
13
+ * "legitimately idle".
14
+ *
15
+ * This module is the one place that CAN tell them apart, because it looks at
16
+ * the rendered markup rather than at the event stream:
17
+ *
18
+ * - `mount_missing` — the document carries actionable elements but not the
19
+ * serialized listener, so no click on it can ever be seen. FATAL.
20
+ * - `chrome_unstamped` — the surface carries actionable elements and NOT ONE
21
+ * of them carries `data-tot-el`. FATAL. Zero-of-N is not an opt-out
22
+ * declaration; a real opt-out is per element, and an author opting out of
23
+ * every single element still leaves the OTHER elements stamped. This is
24
+ * the finding that covers the **sharedChrome bypass path**: a tenant that
25
+ * ships a hand-authored `content/<id>/chrome.html` bypasses governed
26
+ * chrome entirely, so `validateChromeConfig` never sees it,
27
+ * `trackingAttributes()` never stamps it, and `rewriteTrackingIds`
28
+ * (dt-id-writeback) explicitly refuses to touch chrome. Nothing else in
29
+ * the chain would ever say a word about it.
30
+ * - `stamp_incomplete` — elements carry `data-tot-el` but no
31
+ * `data-tot-action`. NOT fatal: the publish-time safety net
32
+ * (`publishSafetyNet.ts`) stamps identity only, by design, and there is no
33
+ * derivation path from identity to intent. Reported because those rows
34
+ * land in the store un-analyzable, which is worth seeing.
35
+ *
36
+ * NO DOM-TEXT READS: only `tagName` and the parsed attribute list are ever
37
+ * read, matching the payload guardrail the listener and every consumer are
38
+ * held to. Actionability is decided by `@tot/public-runtime`'s shared
39
+ * `isTrackableNode` — the same oracle the derivation engine and the writeback
40
+ * rewriter use, so "actionable" cannot mean three different things.
41
+ */
42
+ import { parse } from "parse5";
43
+ import {
44
+ INTERACTION_TRACKING_BOOT_JS,
45
+ TRACKING_ATTR_ACTION,
46
+ TRACKING_ATTR_EL,
47
+ isTrackableNode,
48
+ type StructuralNode,
49
+ } from "@tot/public-runtime";
50
+
51
+ /** The finding codes, in the order this module reports them. */
52
+ export type TrackingHealthCode = "mount_missing" | "chrome_unstamped" | "stamp_incomplete";
53
+
54
+ /** The codes that mean the chain is BROKEN rather than merely imperfect. */
55
+ const FATAL_CODES: ReadonlySet<TrackingHealthCode> = new Set<TrackingHealthCode>([
56
+ "mount_missing",
57
+ "chrome_unstamped",
58
+ ]);
59
+
60
+ export interface TrackingHealthFinding {
61
+ code: TrackingHealthCode;
62
+ /** Human-actionable one-liner; carries counts and tags, never content. */
63
+ detail: string;
64
+ fatal: boolean;
65
+ }
66
+
67
+ export interface TrackingHealthReport {
68
+ /** True iff there is no FATAL finding — i.e. a click here can be collected. */
69
+ ok: boolean;
70
+ /** Elements an interaction can originate from (`isTrackableNode`). */
71
+ actionable: number;
72
+ /** Of those, how many carry `data-tot-el`. */
73
+ stamped: number;
74
+ /** Of the stamped ones, how many also carry `data-tot-action`. */
75
+ attributed: number;
76
+ findings: TrackingHealthFinding[];
77
+ }
78
+
79
+ export interface TrackingHealthOptions {
80
+ /**
81
+ * Require the serialized listener to be present. True for a whole document,
82
+ * false for a chrome WRAPPER inspected before `rawHtmlResponse()` has
83
+ * injected the listener into it.
84
+ */
85
+ requireMount?: boolean;
86
+ /** A label for the log/error line — e.g. `chrome.html (tenant acme)`. */
87
+ surface?: string;
88
+ }
89
+
90
+ /** Thrown by {@link assertTrackingChainHealthy} on a FATAL finding. */
91
+ export class TrackingChainBrokenError extends Error {
92
+ readonly report: TrackingHealthReport;
93
+
94
+ constructor(report: TrackingHealthReport, surface: string) {
95
+ const codes = report.findings.filter((f) => f.fatal).map((f) => f.code);
96
+ super(
97
+ `interaction tracking is BROKEN on ${surface} [${codes.join(", ")}] — ` +
98
+ report.findings
99
+ .filter((f) => f.fatal)
100
+ .map((f) => f.detail)
101
+ .join("; ") +
102
+ ` (actionable=${report.actionable}, stamped=${report.stamped}, ` +
103
+ `attributed=${report.attributed}). A click on this surface produces NO ` +
104
+ `retrievable event, and nothing else in the chain reports it: the listener ` +
105
+ `returns silently when no ${TRACKING_ATTR_EL} is present, which is also how a ` +
106
+ `legitimate \`tracking: "none"\` opt-out looks.`,
107
+ );
108
+ this.name = "TrackingChainBrokenError";
109
+ this.report = report;
110
+ }
111
+ }
112
+
113
+ interface P5Attr {
114
+ name: string;
115
+ value: string;
116
+ }
117
+ interface P5Node {
118
+ nodeName: string;
119
+ tagName?: string;
120
+ attrs?: P5Attr[];
121
+ childNodes?: P5Node[];
122
+ }
123
+
124
+ function nonEmpty(v: string | undefined): boolean {
125
+ return typeof v === "string" && v.trim().length > 0;
126
+ }
127
+
128
+ /**
129
+ * Inspect one rendered surface (a full document, a chrome wrapper, or a body
130
+ * fragment). PURE — no I/O, no logging, no throwing. `html` that parses to
131
+ * nothing actionable is healthy by definition: a surface with no actionable
132
+ * element has nothing to collect, and saying otherwise would make every
133
+ * text-only page a false alarm.
134
+ */
135
+ export function inspectTrackingChain(
136
+ html: string,
137
+ options: TrackingHealthOptions = {},
138
+ ): TrackingHealthReport {
139
+ let actionable = 0;
140
+ let stamped = 0;
141
+ let attributed = 0;
142
+ const unstampedTags = new Set<string>();
143
+
144
+ const walk = (node: P5Node): void => {
145
+ if (node.tagName) {
146
+ const attrs: Record<string, string> = {};
147
+ for (const a of node.attrs ?? []) attrs[a.name] = a.value;
148
+ const structural: StructuralNode = { tag: node.tagName.toLowerCase(), attrs };
149
+ const hasEl = nonEmpty(attrs[TRACKING_ATTR_EL]);
150
+ if (isTrackableNode(structural)) {
151
+ actionable += 1;
152
+ if (hasEl) {
153
+ stamped += 1;
154
+ if (nonEmpty(attrs[TRACKING_ATTR_ACTION])) attributed += 1;
155
+ } else {
156
+ unstampedTags.add(structural.tag);
157
+ }
158
+ } else if (hasEl) {
159
+ // Stamped but not actionable by the shared oracle (e.g. a stamped
160
+ // wrapper). Counted so `stamped` never understates the surface.
161
+ stamped += 1;
162
+ if (nonEmpty(attrs[TRACKING_ATTR_ACTION])) attributed += 1;
163
+ }
164
+ }
165
+ for (const child of node.childNodes ?? []) walk(child);
166
+ };
167
+ walk(parse(html) as P5Node);
168
+
169
+ const findings: TrackingHealthFinding[] = [];
170
+
171
+ if (options.requireMount && actionable > 0 && !html.includes(INTERACTION_TRACKING_BOOT_JS)) {
172
+ findings.push({
173
+ code: "mount_missing",
174
+ detail: `${actionable} actionable element(s) but the serialized interaction-tracking listener is not in this document`,
175
+ fatal: true,
176
+ });
177
+ }
178
+
179
+ if (actionable > 0 && stamped === 0) {
180
+ findings.push({
181
+ code: "chrome_unstamped",
182
+ detail:
183
+ `${actionable} actionable element(s) (${[...unstampedTags].sort().join(", ")}) and NOT ONE ` +
184
+ `carries ${TRACKING_ATTR_EL} — an unstamped surface, not a per-element opt-out`,
185
+ fatal: true,
186
+ });
187
+ } else if (stamped > attributed) {
188
+ findings.push({
189
+ code: "stamp_incomplete",
190
+ detail: `${stamped - attributed} of ${stamped} stamped element(s) carry no ${TRACKING_ATTR_ACTION}, so their rows land un-analyzable`,
191
+ fatal: false,
192
+ });
193
+ }
194
+
195
+ return {
196
+ ok: !findings.some((f) => f.fatal),
197
+ actionable,
198
+ stamped,
199
+ attributed,
200
+ findings,
201
+ };
202
+ }
203
+
204
+ /**
205
+ * {@link inspectTrackingChain}, but a FATAL finding THROWS. For build/publish
206
+ * gates and tests — anywhere a broken chain should stop the line rather than
207
+ * be noted.
208
+ */
209
+ export function assertTrackingChainHealthy(
210
+ html: string,
211
+ options: TrackingHealthOptions = {},
212
+ ): TrackingHealthReport {
213
+ const report = inspectTrackingChain(html, options);
214
+ if (!report.ok) throw new TrackingChainBrokenError(report, options.surface ?? "this surface");
215
+ return report;
216
+ }
217
+
218
+ /**
219
+ * The render-path form: NEVER throws, emits ONE distinct structured line per
220
+ * distinct outcome so a broken surface is visible in Workers Logs. Returns the
221
+ * report so a caller can act on it.
222
+ *
223
+ * Non-throwing on purpose — a tenant whose hand-authored chrome was never
224
+ * stamped should get a loud log line, not a blank page.
225
+ */
226
+ const reported = new Set<string>();
227
+ /** Bound the dedupe set so a long-lived isolate cannot grow it without limit. */
228
+ const REPORTED_MAX = 64;
229
+
230
+ export function reportTrackingChainHealth(
231
+ html: string,
232
+ options: TrackingHealthOptions = {},
233
+ ): TrackingHealthReport {
234
+ const report = inspectTrackingChain(html, options);
235
+ if (report.findings.length === 0) return report;
236
+ const surface = options.surface ?? "unknown-surface";
237
+ const key = `${surface}|${report.findings.map((f) => f.code).join(",")}|${report.actionable}/${report.stamped}`;
238
+ if (reported.has(key)) return report;
239
+ if (reported.size >= REPORTED_MAX) reported.clear();
240
+ reported.add(key);
241
+ for (const finding of report.findings) {
242
+ const line = `[tracking:health] ${finding.fatal ? "BROKEN" : "degraded"} ${finding.code} on ${surface}: ${finding.detail}`;
243
+ if (finding.fatal) console.error(line);
244
+ else console.warn(line);
245
+ }
246
+ return report;
247
+ }
248
+
249
+ /** Test-only: forget which lines have already been emitted. */
250
+ export function resetTrackingHealthReportingForTests(): void {
251
+ reported.clear();
252
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * The RAW interaction-event table — the schema of a TENANT'S OWN D1 database.
3
+ *
4
+ * PER-TENANT, NOT PER-COLUMN. Every other D1 table in this repo lives in a
5
+ * shared database and carries a `tenant_id` column as its isolation boundary
6
+ * (see `d1/schema-apps.ts`). This one does NOT work that way: each tenant gets
7
+ * its OWN D1 database holding only its own rows, so cross-tenant leakage is
8
+ * impossible BY CONSTRUCTION rather than by a correct `WHERE tenant_id = ?` in
9
+ * every query. A missing predicate here cannot reach another tenant's data —
10
+ * there is no other tenant's data in the file.
11
+ *
12
+ * `tenant_id` is still stored on the row (redundantly, one constant value per
13
+ * database) deliberately: it makes an exported/backed-up/rolled-up row
14
+ * self-describing, and it lets a mis-routed insert be DETECTED (a row whose
15
+ * `tenant_id` disagrees with the database's owner is a routing bug, and
16
+ * `dt-tenant-isolation` can assert on exactly that).
17
+ *
18
+ * WHY D1 AND NOT A SHARED STORE: D1 is not billed per database — thousands of
19
+ * databases cost the same as one — so per-tenant databases are the endorsed
20
+ * shape, not a workaround. The hard limit is 10GB PER DATABASE (~30M rows at a
21
+ * 90-day window for a busy tenant), which is a per-tenant ceiling rather than a
22
+ * global one; `interaction_store_meta` below carries the counters
23
+ * `dt-prune-and-rollup` needs to surface a tenant approaching it.
24
+ *
25
+ * RETENTION is a 90-day window, enforced by a DELETE-based prune in a later
26
+ * unit (`dt-prune-and-rollup`) — D1 has no TTL. This schema only has to make
27
+ * that prune cheap, which is what `idx_interaction_events_ingested_at` is for:
28
+ * `DELETE FROM interaction_events WHERE ingested_at < ?` is an index range
29
+ * scan, not a table scan. `ingested_at` (the SERVER receive clock) is the prune
30
+ * key, never `ts` (a CLIENT clock a beacon can skew arbitrarily).
31
+ *
32
+ * SCHEMA IS APPLIED, NOT MIGRATED. A per-tenant database is created on demand
33
+ * at runtime, so it cannot ride the numbered `migrations-apps/` lineage (which
34
+ * `wrangler d1 migrations apply` runs against ONE named binding). Instead this
35
+ * DDL is idempotent (`IF NOT EXISTS` throughout) and applied at provision time;
36
+ * `INTERACTION_SCHEMA_VERSION` is recorded in `interaction_store_meta` so a
37
+ * future version bump can be detected and re-applied per tenant.
38
+ */
39
+
40
+ /** Bumped whenever `INTERACTION_D1_SCHEMA` changes; recorded per database. */
41
+ export const INTERACTION_SCHEMA_VERSION = 1;
42
+
43
+ /**
44
+ * Idempotent DDL for one tenant's raw-interaction database. Multi-statement:
45
+ * applied via `Queryable.exec`, which both the D1 binding and the D1 HTTP API
46
+ * accept as a semicolon-separated script.
47
+ */
48
+ export const INTERACTION_D1_SCHEMA = `
49
+ CREATE TABLE IF NOT EXISTS interaction_events (
50
+ -- Owning tenant. One constant value per database (see the module note): the
51
+ -- isolation boundary is the DATABASE, this column is provenance + a
52
+ -- mis-routing tripwire.
53
+ tenant_id TEXT NOT NULL,
54
+ -- Stream discriminator, always "interaction" today. Present from row one so a
55
+ -- second stream can land in the same table without a migration.
56
+ stream TEXT NOT NULL,
57
+ -- The six TotInteraction fields, flattened for SQL aggregation.
58
+ type TEXT NOT NULL,
59
+ el TEXT NOT NULL,
60
+ action TEXT,
61
+ placement TEXT,
62
+ scope TEXT,
63
+ idx INTEGER,
64
+ -- Groups the events of one collector lifetime (~one page load).
65
+ correlation_id TEXT NOT NULL,
66
+ -- Monotonic within correlation_id, assigned by the client collector.
67
+ seq INTEGER NOT NULL,
68
+ -- Client emit clock (ISO-8601). Informational: never the prune or ordering key.
69
+ ts TEXT NOT NULL,
70
+ -- Server receive clock (ISO-8601). THE retention/prune key.
71
+ ingested_at TEXT NOT NULL,
72
+ -- (correlation_id, seq) is the collector's own natural identity, so a
73
+ -- retried/duplicated beacon re-inserting the same event is a no-op under
74
+ -- INSERT OR IGNORE rather than a double-count.
75
+ PRIMARY KEY (correlation_id, seq)
76
+ );
77
+ CREATE INDEX IF NOT EXISTS idx_interaction_events_ingested_at
78
+ ON interaction_events (ingested_at);
79
+ CREATE INDEX IF NOT EXISTS idx_interaction_events_stream_ingested_at
80
+ ON interaction_events (stream, ingested_at);
81
+ CREATE INDEX IF NOT EXISTS idx_interaction_events_el
82
+ ON interaction_events (el, ingested_at);
83
+ CREATE TABLE IF NOT EXISTS interaction_store_meta (
84
+ -- Single-row table. The constant primary key makes that a schema invariant.
85
+ -- (Keep every inline comment free of semicolons: this DDL is executed as one
86
+ -- semicolon-separated script.)
87
+ id INTEGER PRIMARY KEY CHECK (id = 1),
88
+ tenant_id TEXT NOT NULL,
89
+ schema_version INTEGER NOT NULL,
90
+ created_at TEXT NOT NULL,
91
+ updated_at TEXT NOT NULL
92
+ );
93
+ `;
94
+
95
+ /** Statements that stamp/refresh the single `interaction_store_meta` row. */
96
+ export function interactionMetaUpsert(tenantId: string, at: string): {
97
+ sql: string;
98
+ params: readonly unknown[];
99
+ } {
100
+ return {
101
+ sql: `INSERT INTO interaction_store_meta (id, tenant_id, schema_version, created_at, updated_at)
102
+ VALUES (1, ?, ?, ?, ?)
103
+ ON CONFLICT(id) DO UPDATE SET schema_version = excluded.schema_version,
104
+ updated_at = excluded.updated_at`,
105
+ params: [tenantId, INTERACTION_SCHEMA_VERSION, at, at],
106
+ };
107
+ }