@tokenoftrust/storefront-runner 2.0.4 → 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 (46) hide show
  1. package/apps/storefront/migrations-apps/0011_dt_tracking_tenant_databases.sql +23 -0
  2. package/apps/storefront/node_modules/.bin/vitest +2 -2
  3. package/apps/storefront/node_modules/.vite/deps/_metadata.json +19 -19
  4. package/apps/storefront/node_modules/.vite/deps_ssr/_metadata.json +3 -3
  5. package/apps/storefront/src/components/chrome/MegaMenu.astro +11 -1
  6. package/apps/storefront/src/components/chrome/NavDropdown.astro +49 -7
  7. package/apps/storefront/src/components/chrome/SiteFooter.astro +29 -4
  8. package/apps/storefront/src/components/chrome/SiteHeader.astro +39 -6
  9. package/apps/storefront/src/components/chrome/UtilityNav.astro +13 -1
  10. package/apps/storefront/src/components/islands/MobileNav.tsx +42 -2
  11. package/apps/storefront/src/layouts/Layout.astro +64 -6
  12. package/apps/storefront/src/lib/chrome/ctaIdentity.ts +21 -0
  13. package/apps/storefront/src/lib/chrome/examples/tokenoftrust.chrome.example.json +22 -22
  14. package/apps/storefront/src/lib/chrome/trackingPlacements.ts +36 -0
  15. package/apps/storefront/src/lib/d1/schema-apps.ts +57 -0
  16. package/apps/storefront/src/lib/rawChrome.ts +73 -14
  17. package/apps/storefront/src/lib/rawMarketingHtml.ts +30 -0
  18. package/apps/storefront/src/lib/storyblok/provider.ts +21 -5
  19. package/apps/storefront/src/lib/tracking/collectorAllowlist.ts +40 -0
  20. package/apps/storefront/src/lib/tracking/collectorIngest.ts +188 -0
  21. package/apps/storefront/src/lib/tracking/d1Control.ts +180 -0
  22. package/apps/storefront/src/lib/tracking/interactionCollector.ts +202 -0
  23. package/apps/storefront/src/lib/tracking/interactionHealth.ts +252 -0
  24. package/apps/storefront/src/lib/tracking/interactionSchema.ts +107 -0
  25. package/apps/storefront/src/lib/tracking/interactionStore.ts +0 -0
  26. package/apps/storefront/src/lib/tracking/pruneRollup.ts +260 -0
  27. package/apps/storefront/src/lib/tracking/publishSafetyNet.ts +162 -0
  28. package/apps/storefront/src/lib/tracking/tenantD1Registry.ts +214 -0
  29. package/apps/storefront/src/lib/tracking/tenantReadAccess.ts +138 -0
  30. package/apps/storefront/src/lib/tracking-writeback/rewriteTrackingIds.ts +175 -0
  31. package/apps/storefront/src/lib/tracking-writeback/writebackCandidate.ts +110 -0
  32. package/apps/storefront/src/pages/api/admin/interaction-insights.ts +101 -0
  33. package/apps/storefront/src/pages/api/tracking/interaction.ts +123 -0
  34. package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +33 -14
  35. package/apps/storefront/src/workers/pruneRollupWorker.ts +120 -0
  36. package/package.json +1 -1
  37. package/packages/public-runtime/node_modules/.bin/vitest +2 -2
  38. package/packages/public-runtime/src/chrome.ts +18 -7
  39. package/packages/public-runtime/src/index.ts +10 -0
  40. package/packages/public-runtime/src/integration/index.ts +1 -0
  41. package/packages/public-runtime/src/integration/interaction-tracking-gate.ts +142 -0
  42. package/packages/public-runtime/src/interaction-tracking.ts +364 -0
  43. package/packages/public-runtime/src/tracking-id-derivation.ts +1008 -0
  44. package/scripts/tenant/validate.mjs +306 -1
  45. package/scripts/tot-dev.mjs +59 -3
  46. package/tenants/home/public/js/storefront.js +6 -87
@@ -23,9 +23,10 @@ import { referenceTheme } from "@/themes/reference";
23
23
  import { mergeTheme } from "@/themes/schema";
24
24
  import { organizationLd, websiteLd } from "@/lib/jsonld";
25
25
  import { nonceInlineTags, CHROME_BODY_MARKER } from "@/lib/rawMarketingHtml";
26
+ import { reportTrackingChainHealth } from "@/lib/tracking/interactionHealth";
26
27
  import { resolveHeaderVariant, resolveFooterVariant } from "@/lib/chrome/variant";
27
28
  import type { HeaderVariant, FooterVariant } from "@/lib/chrome/model";
28
- import { renderIntegrationPhases } from "@tot/public-runtime";
29
+ import { renderIntegrationPhases, INTERACTION_TRACKING_BOOT_JS } from "@tot/public-runtime";
29
30
  import { readViewerSession } from "@/lib/auth/route";
30
31
  import AppWidgetFrame from "@/components/apps/AppWidgetFrame.astro";
31
32
  import { getRegistryService } from "@/lib/apps/registryAccess.js";
@@ -130,6 +131,15 @@ const resolvedFooterVariant = resolveFooterVariant(footerVariant, chrome.footer.
130
131
  // stylesheet links into <head>; nonce its inline tags for CSP. Tenants without a
131
132
  // chrome.html keep the token-driven Header/Footer (below).
132
133
  const chromeHtml = await Astro.locals.content.getChromeHtml(path);
134
+ if (chromeHtml) {
135
+ // HEALTH: this is the sharedChrome BYPASS path — a hand-authored chrome.html
136
+ // wins outright over the governed chrome components below, so nothing
137
+ // validated its tracking declarations and nothing stamped them. A wholly
138
+ // unstamped wrapper means no click on this page's chrome can ever be
139
+ // collected, and every other layer stays silent about it (absence is also how
140
+ // a legitimate opt-out looks). One structured line per surface, never a throw.
141
+ reportTrackingChainHealth(chromeHtml, { surface: `sharedChrome:${tenant.tenant_id}` });
142
+ }
133
143
  let sharedChrome: { header: string; footer: string; css: string[] } | null = null;
134
144
  if (chromeHtml && chromeHtml.includes(CHROME_BODY_MARKER)) {
135
145
  const headEnd = chromeHtml.indexOf("</head>");
@@ -277,13 +287,44 @@ const footerWidgets = await renderWidgetSlot(
277
287
  alternates={alternates}
278
288
  />
279
289
 
290
+ {
291
+ /* Cloudflare Web Analytics is a non-essential measurement destination —
292
+ gate it on the SAME neutral consent broker every vendor loader reads
293
+ (ext-kernel-04 `consent.defaults` phase, published above as
294
+ `window.__TOT_INTEGRATION_CONSENT__`). The token rides a <meta> tag
295
+ (any script attribute, incl. define:vars, forces is:inline and drops
296
+ module processing — this script needs a real import) rather than a
297
+ page global; the script fail-closes like every other consumer of that
298
+ global: an absent/tampered snapshot denies, so an unconfigured CMP
299
+ means this never loads rather than loading ungated. */
300
+ analyticsEnabled && <meta name="cf-beacon-token" content={analyticsToken} />
301
+ }
280
302
  {
281
303
  analyticsEnabled && (
282
- <script
283
- defer
284
- src="https://static.cloudflareinsights.com/beacon.min.js"
285
- data-cf-beacon={`{"token": "${analyticsToken}"}`}
286
- />
304
+ <script>
305
+ import {
306
+ parseConsentSnapshot,
307
+ isPurposeEligible,
308
+ defaultConsentSnapshot,
309
+ INTEGRATION_CONSENT_GLOBAL,
310
+ } from "@tot/public-runtime";
311
+
312
+ const snapshot =
313
+ parseConsentSnapshot((window as unknown as Record<string, unknown>)[INTEGRATION_CONSENT_GLOBAL]) ??
314
+ defaultConsentSnapshot();
315
+
316
+ const token = document
317
+ .querySelector('meta[name="cf-beacon-token"]')
318
+ ?.getAttribute("content");
319
+
320
+ if (token && isPurposeEligible(snapshot, "analytics")) {
321
+ const beacon = document.createElement("script");
322
+ beacon.defer = true;
323
+ beacon.src = "https://static.cloudflareinsights.com/beacon.min.js";
324
+ beacon.setAttribute("data-cf-beacon", JSON.stringify({ token }));
325
+ document.head.appendChild(beacon);
326
+ }
327
+ </script>
287
328
  )
288
329
  }
289
330
  </head>
@@ -554,6 +595,23 @@ const footerWidgets = await renderWidgetSlot(
554
595
  bootTamperSentinel();
555
596
  </script>
556
597
 
598
+ {/* Declarative interaction tracking (dt-contract-v1) — ONE delegated click
599
+ listener, installed once per document, that resolves the nearest
600
+ ancestor carrying `data-tot-el` and reports its declared
601
+ identity/action/placement (plus the data-tot-scope/data-tot-i instance
602
+ pair). It reads declared attributes ONLY: no textContent/innerText/alt/
603
+ value/placeholder, no form-field reads. Delivery, consent gating and
604
+ environment gating are NOT here — each interaction is dispatched as a
605
+ `tot:interaction` CustomEvent for the collector to consume.
606
+
607
+ This is the Astro-mount half of the install; `rawHtmlResponse()` is the
608
+ other (ADR 0015 one-engine-two-mounts). Both embed the SAME serialized
609
+ listener, and the install-once marker makes a double install a no-op —
610
+ which is what makes the raw chrome wrapper spliced into this document
611
+ (above) safe. Emitted as `is:inline` + the page nonce so it needs no
612
+ entry in lib/cspStaticHashes.ts. */}
613
+ <script is:inline nonce={cspNonce} set:html={INTERACTION_TRACKING_BOOT_JS}></script>
614
+
557
615
  {/* document.deferred global-entry boundary (ext-kernel-04) — the last phase,
558
616
  the renderer-owned mount point for the deferred integration entry. */}
559
617
  <Fragment set:html={integrationPhases.bodyDeferred} />
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Governed-CTA identity attribute — the ONE mapping from a `ChromeCta` config
3
+ * to the markup identity every renderer stamps onto its rendered anchor.
4
+ *
5
+ * Two render paths draw on the same governed `ChromeCta` config: `rawChrome.ts`
6
+ * (the RAW-page chrome wrapper) and `SiteHeader.astro` (the app-Layout header).
7
+ * Both must emit the identical identity attribute for the identical CTA, so a
8
+ * downstream consumer (tracking, an operator's own selectors) never has to care
9
+ * which surface rendered the page. Keeping the derivation in ONE function,
10
+ * imported by both, makes that a structural guarantee rather than a convention
11
+ * two files have to remember to keep in sync.
12
+ */
13
+ import type { ChromeCta } from "./model.js";
14
+
15
+ /** The identity attribute name governed CTAs are stamped with. */
16
+ export const CTA_IDENTITY_ATTR = "data-cta-id";
17
+
18
+ /** The (name, value) pair a governed CTA's config resolves to for markup identity. */
19
+ export function ctaIdentityAttr(cta: ChromeCta): { name: string; value: string } {
20
+ return { name: CTA_IDENTITY_ATTR, value: cta.id };
21
+ }
@@ -18,15 +18,15 @@
18
18
  }
19
19
  },
20
20
  "nav": [
21
- { "id": "nav-platform", "label": "Platform", "href": "/#platform" },
22
- { "id": "nav-industries", "label": "Industries", "href": "/#industries" },
23
- { "id": "nav-resources", "label": "Resources", "href": "https://help.tokenoftrust.com", "external": true },
24
- { "id": "nav-pricing", "label": "Pricing", "href": "https://tokenoftrust.com/pricing/", "external": true },
25
- { "id": "nav-login", "label": "Login", "href": "https://app.tokenoftrust.com", "external": true }
21
+ { "id": "nav-platform", "action": "nav-platform", "label": "Platform", "href": "/#platform" },
22
+ { "id": "nav-industries", "action": "nav-industries", "label": "Industries", "href": "/#industries" },
23
+ { "id": "nav-resources", "action": "nav-resources", "label": "Resources", "href": "https://help.tokenoftrust.com", "external": true },
24
+ { "id": "nav-pricing", "action": "nav-pricing", "label": "Pricing", "href": "https://tokenoftrust.com/pricing/", "external": true },
25
+ { "id": "nav-login", "action": "nav-login", "label": "Login", "href": "https://app.tokenoftrust.com", "external": true }
26
26
  ],
27
27
  "ctas": [
28
- { "id": "cta-book-demo", "label": "Book Demo", "href": "https://tokenoftrust.com/contact/", "variant": "secondary" },
29
- { "id": "cta-start-free", "label": "Start For Free", "href": "https://app.tokenoftrust.com", "variant": "primary" }
28
+ { "id": "cta-book-demo", "action": "cta-book-demo", "label": "Book Demo", "href": "https://tokenoftrust.com/contact/", "variant": "secondary" },
29
+ { "id": "cta-start-free", "action": "cta-start-for-free", "label": "Start For Free", "href": "https://app.tokenoftrust.com", "variant": "primary" }
30
30
  ]
31
31
  },
32
32
  "footer": {
@@ -38,36 +38,36 @@
38
38
  {
39
39
  "title": "Platform",
40
40
  "links": [
41
- { "id": "ftr-platform-idv", "label": "Identity Verification", "href": "/#platform" },
42
- { "id": "ftr-platform-age", "label": "Age Assurance", "href": "/#platform" },
43
- { "id": "ftr-platform-fraud", "label": "Fraud Prevention", "href": "/#platform" },
44
- { "id": "ftr-platform-evidence", "label": "Compliance Evidence", "href": "/#platform" },
45
- { "id": "ftr-platform-aml", "label": "AML · PACT Act · Tax", "href": "/#platform" }
41
+ { "id": "ftr-platform-idv", "action": "nav-identity-verification", "label": "Identity Verification", "href": "/#platform" },
42
+ { "id": "ftr-platform-age", "action": "nav-age-assurance", "label": "Age Assurance", "href": "/#platform" },
43
+ { "id": "ftr-platform-fraud", "action": "nav-fraud-prevention", "label": "Fraud Prevention", "href": "/#platform" },
44
+ { "id": "ftr-platform-evidence", "action": "nav-compliance-evidence", "label": "Compliance Evidence", "href": "/#platform" },
45
+ { "id": "ftr-platform-aml", "action": "nav-aml-pact-act-tax", "label": "AML · PACT Act · Tax", "href": "/#platform" }
46
46
  ]
47
47
  },
48
48
  {
49
49
  "title": "Industries",
50
50
  "links": [
51
- { "id": "ftr-industries-alcohol", "label": "Alcohol", "href": "/#industries" },
52
- { "id": "ftr-industries-vape", "label": "Tobacco & Vape", "href": "/#industries" },
53
- { "id": "ftr-industries-age-restricted", "label": "Age-Restricted Goods", "href": "/#industries" },
54
- { "id": "ftr-industries-marketplaces", "label": "Marketplaces", "href": "/#industries" }
51
+ { "id": "ftr-industries-alcohol", "action": "nav-alcohol", "label": "Alcohol", "href": "/#industries" },
52
+ { "id": "ftr-industries-vape", "action": "nav-tobacco-and-vape", "label": "Tobacco & Vape", "href": "/#industries" },
53
+ { "id": "ftr-industries-age-restricted", "action": "nav-age-restricted-goods", "label": "Age-Restricted Goods", "href": "/#industries" },
54
+ { "id": "ftr-industries-marketplaces", "action": "nav-marketplaces", "label": "Marketplaces", "href": "/#industries" }
55
55
  ]
56
56
  },
57
57
  {
58
58
  "title": "Company",
59
59
  "links": [
60
- { "id": "ftr-company-pricing", "label": "Pricing", "href": "https://tokenoftrust.com/pricing/", "external": true },
61
- { "id": "ftr-company-contact", "label": "Contact Sales", "href": "https://tokenoftrust.com/contact/", "external": true },
62
- { "id": "ftr-company-resources", "label": "Resources", "href": "https://help.tokenoftrust.com", "external": true },
63
- { "id": "ftr-company-login", "label": "Login", "href": "https://app.tokenoftrust.com", "external": true }
60
+ { "id": "ftr-company-pricing", "action": "nav-pricing", "label": "Pricing", "href": "https://tokenoftrust.com/pricing/", "external": true },
61
+ { "id": "ftr-company-contact", "action": "nav-contact-sales", "label": "Contact Sales", "href": "https://tokenoftrust.com/contact/", "external": true },
62
+ { "id": "ftr-company-resources", "action": "nav-resources", "label": "Resources", "href": "https://help.tokenoftrust.com", "external": true },
63
+ { "id": "ftr-company-login", "action": "nav-login", "label": "Login", "href": "https://app.tokenoftrust.com", "external": true }
64
64
  ]
65
65
  },
66
66
  {
67
67
  "title": "Legal",
68
68
  "links": [
69
- { "id": "ftr-legal-privacy", "label": "Privacy Policy", "href": "https://tokenoftrust.com/privacy/", "external": true },
70
- { "id": "ftr-legal-terms", "label": "Terms", "href": "https://tokenoftrust.com/terms/", "external": true }
69
+ { "id": "ftr-legal-privacy", "action": "nav-privacy-policy", "label": "Privacy Policy", "href": "https://tokenoftrust.com/privacy/", "external": true },
70
+ { "id": "ftr-legal-terms", "action": "nav-terms", "label": "Terms", "href": "https://tokenoftrust.com/terms/", "external": true }
71
71
  ]
72
72
  }
73
73
  ],
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Canonical `data-tot-placement` values for governed chrome + navigation (V1
3
+ * pilot scope: chrome CTAs, primary nav, mega-menu children, utility nav,
4
+ * footer, mobile nav — see docs/declarative-interaction-tracking-contract.md
5
+ * and decision v1-scope-chrome-nav-pilot). ONE source of truth shared by both
6
+ * the component renderer (SiteHeader.astro / MegaMenu.astro / NavDropdown.astro
7
+ * / MobileNav.tsx / UtilityNav.astro / SiteFooter.astro) and the raw-string
8
+ * renderer (rawChrome.ts), so the same section can never emit a different
9
+ * placement literal on the two surfaces.
10
+ *
11
+ * Placement is always passed explicitly by whichever renderer knows where it
12
+ * is rendering (decision identity-action-placement-split-v2) — never inferred
13
+ * from DOM position, component nesting, or render mode.
14
+ */
15
+ export const CHROME_PLACEMENT = {
16
+ /** Header-actions cluster CTAs (rawChrome's `.header-cta`, SiteHeader's `headerCtas`). */
17
+ header: "header",
18
+ /** Primary-nav top-level items — desktop bar (raw `.primary-nav`, MegaMenu/NavDropdown mode="bar"). */
19
+ primaryNav: "primary-nav",
20
+ /** Mega-menu / single-column dropdown children, on either surface. */
21
+ megaMenu: "mega-menu",
22
+ /** The thin utility bar above the header. */
23
+ utilityNav: "utility-nav",
24
+ /** Footer link columns. */
25
+ footer: "footer",
26
+ /**
27
+ * Footer bottom legal/policy link row — component surface only. The raw
28
+ * renderer's `ChromeFooter.bottom` carries text only (no links); its footer
29
+ * columns cover the same links under `footer` above.
30
+ */
31
+ footerBottom: "footer-bottom",
32
+ /** The mobile nav drawer (MobileNav island) and its no-JS accordion fallback. */
33
+ mobileMenu: "mobile-menu",
34
+ } as const;
35
+
36
+ export type ChromePlacement = (typeof CHROME_PLACEMENT)[keyof typeof CHROME_PLACEMENT];
@@ -149,6 +149,63 @@ export const activityEvents = sqliteTable(
149
149
  }),
150
150
  );
151
151
 
152
+ // --- Per-tenant raw-interaction store directory ------------------------------
153
+ // The DIRECTORY of per-tenant D1 databases that hold raw declarative-tracking
154
+ // interaction rows — one database per tenant (`lib/tracking/interactionSchema.ts`
155
+ // is that database's schema). This table holds NO interaction data: only the
156
+ // `tenant_id → (database_name, database_uuid)` mapping the Worker needs to
157
+ // address a tenant's own database over the D1 REST API, since Worker bindings
158
+ // are static and cannot be created per tenant at runtime.
159
+ //
160
+ // It lives in the SHARED STOREFRONT_APPS_DB on purpose: a directory of tenants
161
+ // is inherently cross-tenant, and keeping it here means the isolation boundary
162
+ // stays exactly one hop — a lookup returns ONE tenant's database handle, and
163
+ // that database contains nothing else. `tenant_id` is the primary key, so one
164
+ // tenant can never map to two databases (and two tenants can never share one).
165
+ //
166
+ // This is the seam the follow-on units read:
167
+ // • `dt-tenant-isolation` — resolves the caller's authorized tenant to
168
+ // exactly one `database_uuid`; a merchant read
169
+ // that is not in their own row cannot be built.
170
+ // • `dt-prune-and-rollup` — ENUMERATES this table to sweep every tenant's
171
+ // 90-day window (`last_pruned_at` / `pruned_rows`
172
+ // are its bookkeeping), and uses `size_bytes` /
173
+ // `size_checked_at` to surface a tenant nearing
174
+ // D1's 10GB per-database ceiling.
175
+ export const trackingTenantDatabases = sqliteTable(
176
+ "tracking_tenant_databases",
177
+ {
178
+ tenantId: text("tenant_id").primaryKey(),
179
+ // The Cloudflare D1 database name, derived deterministically from the tenant
180
+ // id (`lib/tracking/tenantD1Registry.ts`) — recorded so an operator can find
181
+ // it in the dashboard without re-deriving.
182
+ databaseName: text("database_name").notNull(),
183
+ // The D1 UUID — what the REST data plane actually addresses.
184
+ databaseUuid: text("database_uuid").notNull(),
185
+ // Applied `INTERACTION_SCHEMA_VERSION`; a bump re-applies the DDL per tenant.
186
+ schemaVersion: integer("schema_version").notNull(),
187
+ createdAt: text("created_at").notNull(),
188
+ updatedAt: text("updated_at").notNull(),
189
+ // Bookkeeping for the scheduled prune (written by `dt-prune-and-rollup`).
190
+ lastPrunedAt: text("last_pruned_at"),
191
+ prunedRows: integer("pruned_rows").notNull().default(0),
192
+ // Last observed database size + when, for the 10GB-ceiling warning.
193
+ sizeBytes: integer("size_bytes"),
194
+ sizeCheckedAt: text("size_checked_at"),
195
+ },
196
+ (t) => ({
197
+ // Two tenants sharing one database would defeat the whole structural-
198
+ // isolation claim, so make it impossible at the schema level.
199
+ uqTrackingTenantDatabasesUuid: uniqueIndex("uq_tracking_tenant_databases_uuid").on(
200
+ t.databaseUuid,
201
+ ),
202
+ // The prune sweep's work queue: oldest-pruned first.
203
+ idxTrackingTenantDatabasesPruned: index("idx_tracking_tenant_databases_pruned").on(
204
+ t.lastPrunedAt,
205
+ ),
206
+ }),
207
+ );
208
+
152
209
  // --- Webhook event spine ----------------------------------------------------
153
210
  // Immutable event log. `id` is the semantic event id we mint — distinct from
154
211
  // any per-delivery wire envelope id (see `webhookDeliveries.deliveryId` below).
@@ -26,6 +26,9 @@
26
26
  */
27
27
  import { CHROME_BODY_MARKER, CHROME_HEAD_MARKER } from "./rawMarketingHtml.js";
28
28
  import { isCurrentNavHref } from "./chrome/navCurrent.js";
29
+ import { ctaIdentityAttr } from "./chrome/ctaIdentity.js";
30
+ import { CHROME_PLACEMENT } from "./chrome/trackingPlacements.js";
31
+ import { trackingAttributesHtml } from "@tot/public-runtime";
29
32
  import type {
30
33
  ChromeAgeGate,
31
34
  ChromeAnnouncement,
@@ -165,12 +168,27 @@ function renderAnnouncement(a: ChromeAnnouncement): string {
165
168
 
166
169
  function renderUtilityNav(u: ChromeUtilityNav): string {
167
170
  const links = (u.links ?? [])
168
- .map((l) => `<li><a href="${esc(l.href)}"${relFor(l)}>${esc(l.label)}</a></li>`)
171
+ .map((l, i) => {
172
+ const stamp = trackingAttributesHtml({
173
+ el: l.id,
174
+ action: l.action,
175
+ tracking: l.tracking,
176
+ placement: CHROME_PLACEMENT.utilityNav,
177
+ scope: "utility-nav",
178
+ index: i,
179
+ });
180
+ return `<li><a href="${esc(l.href)}"${relFor(l)}${stamp}>${esc(l.label)}</a></li>`;
181
+ })
169
182
  .join("");
170
183
  const locale = (u.locale ?? []).map((s) => `<span class="sel">${esc(s)}</span>`).join("");
171
184
  const social = u.social?.length ? `<span class="social">${renderSocial(u.social)}</span>` : "";
172
185
  const cue = u.memberCue
173
- ? `<a class="member-cue" href="${esc(u.memberCue.href)}">${esc(u.memberCue.label)}</a>`
186
+ ? `<a class="member-cue" href="${esc(u.memberCue.href)}"${trackingAttributesHtml({
187
+ el: u.memberCue.id,
188
+ action: u.memberCue.action,
189
+ tracking: u.memberCue.tracking,
190
+ placement: CHROME_PLACEMENT.utilityNav,
191
+ })}>${esc(u.memberCue.label)}</a>`
174
192
  : "";
175
193
  const right = `${locale}${social}${cue}`;
176
194
  return (
@@ -194,7 +212,7 @@ function renderUtilityNav(u: ChromeUtilityNav): string {
194
212
  * unprefixed and exact-match-only (see navCurrent.ts). Omitted ⇒ no item is
195
213
  * ever marked current (callers that don't know the request path, e.g. tests).
196
214
  */
197
- function renderNavItem(item: ChromeNavItem, currentPath?: string): string {
215
+ function renderNavItem(item: ChromeNavItem, index: number, currentPath?: string): string {
198
216
  const columns = item.columns?.length
199
217
  ? item.columns
200
218
  : item.children?.length
@@ -207,9 +225,19 @@ function renderNavItem(item: ChromeNavItem, currentPath?: string): string {
207
225
  const haspopup = hasPanel ? ' aria-haspopup="true"' : "";
208
226
  const itemCurrent = currentPath !== undefined && isCurrentNavHref(currentPath, item.href);
209
227
  const ariaCurrent = itemCurrent ? ' aria-current="page"' : "";
210
- const link = `<a${linkClass} href="${esc(item.href)}"${haspopup}${ariaCurrent}>${esc(item.label)}${badge}${caret}</a>`;
228
+ const itemStamp = trackingAttributesHtml({
229
+ el: item.id,
230
+ action: item.action,
231
+ tracking: item.tracking,
232
+ placement: CHROME_PLACEMENT.primaryNav,
233
+ scope: "nav-primary",
234
+ index,
235
+ });
236
+ const link = `<a${linkClass} href="${esc(item.href)}"${haspopup}${ariaCurrent}${itemStamp}>${esc(item.label)}${badge}${caret}</a>`;
211
237
  if (!hasPanel) return `<li class="nav-item">${link}</li>`;
212
238
 
239
+ const megaScope = `nav-mega:${item.id}`;
240
+ let childIndex = 0;
213
241
  const wide = columns.length > 1 ? " mega--wide" : "";
214
242
  const cols = columns
215
243
  .map((c) => {
@@ -219,7 +247,15 @@ function renderNavItem(item: ChromeNavItem, currentPath?: string): string {
219
247
  const childCurrent =
220
248
  currentPath !== undefined && isCurrentNavHref(currentPath, l.href, l.external);
221
249
  const childAriaCurrent = childCurrent ? ' aria-current="page"' : "";
222
- return `<a href="${esc(l.href)}"${relFor(l)}${childAriaCurrent}>${esc(l.label)}</a>`;
250
+ const childStamp = trackingAttributesHtml({
251
+ el: l.id,
252
+ action: l.action,
253
+ tracking: l.tracking,
254
+ placement: CHROME_PLACEMENT.megaMenu,
255
+ scope: megaScope,
256
+ index: childIndex++,
257
+ });
258
+ return `<a href="${esc(l.href)}"${relFor(l)}${childAriaCurrent}${childStamp}>${esc(l.label)}</a>`;
223
259
  })
224
260
  .join("");
225
261
  return `<div class="mega__col">${heading}${links}</div>`;
@@ -257,22 +293,35 @@ function renderHeader(header: ChromeHeader, variant: HeaderVariant, currentPath?
257
293
  const minimal = variant === "minimal";
258
294
  // Header CTAs, on the same `!minimal` rule the Astro SiteHeader applies, so the
259
295
  // two surfaces render the same governed config identically. Each carries its
260
- // stable `id` as `data-cta-id`, so the governed identity is present in the
261
- // served DOM rather than implied by label or href.
296
+ // identity attribute (ctaIdentityAttr, shared with SiteHeader.astro), so the
297
+ // governed identity is present in the served DOM rather than implied by label
298
+ // or href.
262
299
  const ctas =
263
300
  minimal || !header.ctas?.length
264
301
  ? ""
265
302
  : header.ctas
266
- .map(
267
- (c) =>
303
+ .map((c, i) => {
304
+ const idAttr = ctaIdentityAttr(c);
305
+ const stamp = trackingAttributesHtml({
306
+ el: c.id,
307
+ action: c.action,
308
+ tracking: c.tracking,
309
+ placement: CHROME_PLACEMENT.header,
310
+ scope: "header-ctas",
311
+ index: i,
312
+ });
313
+ return (
268
314
  `<a class="header-cta header-cta--${esc(c.variant ?? "primary")}" ` +
269
- `data-cta-id="${esc(c.id)}" href="${esc(c.href)}">${esc(c.label)}</a>`,
270
- )
315
+ `${idAttr.name}="${esc(idAttr.value)}" href="${esc(c.href)}"${stamp}>${esc(c.label)}</a>`
316
+ );
317
+ })
271
318
  .join("");
272
319
  const hamburger = minimal
273
320
  ? ""
274
321
  : `<button class="hamburger" id="hamburger" aria-label="Toggle navigation menu" aria-expanded="false" aria-controls="primaryNav"><span></span><span></span><span></span></button>`;
275
- const navItems = header.nav.map((item) => renderNavItem(item, currentPath)).join("\n ");
322
+ const navItems = header.nav
323
+ .map((item, i) => renderNavItem(item, i, currentPath))
324
+ .join("\n ");
276
325
  const navBar = minimal
277
326
  ? ""
278
327
  : ` <nav class="primary-nav-bar" aria-label="Primary">\n` +
@@ -343,9 +392,19 @@ function renderFooter(footer: ChromeFooter): string {
343
392
  })()
344
393
  : "";
345
394
  const columns = footer.columns
346
- .map((c) => {
395
+ .map((c, colIndex) => {
347
396
  const links = c.links
348
- .map((l) => `<li><a href="${esc(l.href)}"${relFor(l)}>${esc(l.label)}</a></li>`)
397
+ .map((l, i) => {
398
+ const stamp = trackingAttributesHtml({
399
+ el: l.id,
400
+ action: l.action,
401
+ tracking: l.tracking,
402
+ placement: CHROME_PLACEMENT.footer,
403
+ scope: `footer-col:${colIndex}`,
404
+ index: i,
405
+ });
406
+ return `<li><a href="${esc(l.href)}"${relFor(l)}${stamp}>${esc(l.label)}</a></li>`;
407
+ })
349
408
  .join("");
350
409
  return ` <div class="footer-col">\n <h4>${esc(c.title)}</h4>\n <ul>${links}</ul>\n </div>`;
351
410
  })
@@ -17,8 +17,10 @@
17
17
  */
18
18
  import {
19
19
  renderIntegrationPhases,
20
+ INTERACTION_TRACKING_BOOT_JS,
20
21
  type EffectivePlan,
21
22
  } from "@tot/public-runtime";
23
+ import { reportTrackingChainHealth } from "./tracking/interactionHealth.js";
22
24
 
23
25
  export { rawHtmlPolicy } from "@tot/public-runtime";
24
26
 
@@ -113,6 +115,23 @@ function injectBodyEnd(html: string, snippet: string): string {
113
115
  return html + snippet;
114
116
  }
115
117
 
118
+ /**
119
+ * The declarative-interaction-tracking listener as a nonce-carrying inline
120
+ * `<script>` — the raw-mount half of the install (`Layout.astro` is the other,
121
+ * ADR 0015 one-engine-two-mounts). Both mounts embed the SAME serialized
122
+ * listener from `@tot/public-runtime`, so no markup path can drift into a
123
+ * second implementation, and the listener's install-once marker makes a second
124
+ * copy in the same document (a tenant `chrome.html` wrapper that also carries
125
+ * it) a no-op.
126
+ *
127
+ * Carries the nonce itself because it is spliced AFTER `nonceInlineTags` has
128
+ * run — same discipline as the render engine's phase markup.
129
+ */
130
+ export function interactionTrackingScript(nonce: string): string {
131
+ const nonceAttr = nonce ? ` nonce="${nonce}"` : "";
132
+ return `<script${nonceAttr}>${INTERACTION_TRACKING_BOOT_JS}</script>`;
133
+ }
134
+
116
135
  /** Stamp `nonce` onto every inline `<style>`/`<script>` so a strict CSP admits them. */
117
136
  export function nonceInlineTags(html: string, nonce: string): string {
118
137
  return html
@@ -167,6 +186,12 @@ export function rawHtmlResponse(
167
186
  isFullDocument(html) || !opts.chromeWrapper
168
187
  ? html
169
188
  : wrapFragmentInChrome(html, opts.chromeWrapper);
189
+ // HEALTH: a raw surface bypasses governed chrome, so `validateChromeConfig`
190
+ // never saw it and `trackingAttributes()` never stamped it. If NOT ONE of its
191
+ // actionable elements carries an identity, no click on this page can ever be
192
+ // collected — and every other layer is silent about that by design (absence
193
+ // is also how a legitimate opt-out looks). One structured line, never a throw.
194
+ reportTrackingChainHealth(composed, { surface: `raw:${opts.basePath || "/"}` });
170
195
  // DEV ONLY: these pages are served verbatim, bypassing Astro's render pipeline,
171
196
  // so the Vite HMR client is never injected — a save would reach the server (see
172
197
  // the tenantHotReload plugin in astro.config.mjs) but the browser would have no
@@ -201,6 +226,11 @@ export function rawHtmlResponse(
201
226
  composed = injectHeadStart(composed, phases.head);
202
227
  composed = injectBodyStart(composed, phases.bodyStart);
203
228
  composed = injectBodyEnd(composed, phases.bodyDeferred);
229
+ // Declarative interaction tracking, last in the body so the whole document —
230
+ // a verbatim tenant document, a fragment spliced into a hand-authored
231
+ // `chrome.html`, or a fragment spliced into the generated `renderRawChrome`
232
+ // wrapper — is already present when the delegated listener binds.
233
+ composed = injectBodyEnd(composed, interactionTrackingScript(opts.nonce));
204
234
  const doc = prefixTenantLinks(composed, opts.basePath);
205
235
  return new Response(doc, {
206
236
  headers: { "content-type": "text/html; charset=utf-8" },
@@ -30,6 +30,7 @@ import {
30
30
  import { renderRawChrome } from "../rawChrome.js";
31
31
  import { parseChromeConfig } from "../chrome/parseConfig.js";
32
32
  import { parseChromeAssignments } from "../chrome/model.js";
33
+ import { stampTrackingIdsAtPublish } from "../tracking/publishSafetyNet.js";
33
34
 
34
35
  /** Safe, always-valid fallback when neither the tenant's own chrome.json nor
35
36
  * the default scope's validates — the storefront must always render. */
@@ -599,12 +600,21 @@ export class PublishedContentProvider implements ContentProvider {
599
600
  // Raw whole-page documents (T0 marketing tenants) publish via KV too, so a
600
601
  // vendor edit previews / goes live with NO Worker rebuild. Artifact path mirrors
601
602
  // the source file: content/<tenant>/home.html and .../pages-html/<slug>.html.
603
+ //
604
+ // This is content that bypasses git review entirely (a direct KV publish,
605
+ // never touched by dt-id-writeback's reviewable-diff flow) — so the
606
+ // publish-time HTMLRewriter safety net (publishSafetyNet.ts) stamps
607
+ // `data-tot-el` on it here, at the read boundary, before it ever reaches a
608
+ // route. The base chain's own git-tracked content is NOT re-stamped: that
609
+ // is dt-id-writeback's job.
602
610
  async getHomeHtml(): Promise<string | null> {
603
- return (await this.publishedRaw("home.html")) ?? this.base.getHomeHtml();
611
+ const raw = await this.publishedRaw("home.html");
612
+ return raw !== null ? stampTrackingIdsAtPublish(raw) : this.base.getHomeHtml();
604
613
  }
605
614
  async getPageHtml(slug: string): Promise<string | null> {
606
615
  const rel = `pages-html/${normalizeRawPageSlug(slug)}.html`;
607
- return (await this.publishedRaw(rel)) ?? this.base.getPageHtml(slug);
616
+ const raw = await this.publishedRaw(rel);
617
+ return raw !== null ? stampTrackingIdsAtPublish(raw) : this.base.getPageHtml(slug);
608
618
  }
609
619
  listPageHtmlSlugs(): Promise<string[]> {
610
620
  return this.listArtifactPaths("pages-html/").then((paths) =>
@@ -618,8 +628,9 @@ export class PublishedContentProvider implements ContentProvider {
618
628
  async getChromeHtml(currentPath?: string): Promise<string | null> {
619
629
  // A published raw chrome.html override (parallels the home.html/pages-html
620
630
  // publish path) wins, so a vendor edit previews / goes live with no rebuild.
631
+ // Same bypasses-git-review reasoning as getHomeHtml/getPageHtml above: stamp it.
621
632
  const publishedHtml = await this.publishedRaw("chrome.html");
622
- if (publishedHtml) return publishedHtml;
633
+ if (publishedHtml) return stampTrackingIdsAtPublish(publishedHtml);
623
634
  // Else, if the PUBLISHED chrome.json opts into shared chrome, render
624
635
  // the canonical wrapper from it — so a config edit previews without a rebuild.
625
636
  const config = parseChromeConfig(await this.published<unknown>("chrome.json"));
@@ -696,12 +707,17 @@ export class DevDraftContentProvider implements ContentProvider {
696
707
  return this.get(`dev-draft:${this.tenantId}:${this.draftId}:${path}`);
697
708
  }
698
709
 
710
+ // AI-edit drafts (WS5 G4 `/api/content-edit/ai-edit`) never touch
711
+ // dt-id-writeback's git-reviewed flow either — same publish-time
712
+ // HTMLRewriter safety net as PublishedContentProvider above.
699
713
  async getHomeHtml(): Promise<string | null> {
700
- return (await this.draftRaw("content/home.html")) ?? this.base.getHomeHtml();
714
+ const draft = await this.draftRaw("content/home.html");
715
+ return draft !== null ? stampTrackingIdsAtPublish(draft) : this.base.getHomeHtml();
701
716
  }
702
717
  async getPageHtml(slug: string): Promise<string | null> {
703
718
  const rel = `content/pages-html/${normalizeRawPageSlug(slug)}.html`;
704
- return (await this.draftRaw(rel)) ?? this.base.getPageHtml(slug);
719
+ const draft = await this.draftRaw(rel);
720
+ return draft !== null ? stampTrackingIdsAtPublish(draft) : this.base.getPageHtml(slug);
705
721
  }
706
722
  getHome(): Promise<HomeContent> {
707
723
  return this.base.getHome();
@@ -0,0 +1,40 @@
1
+ /**
2
+ * OWNER: dt-collector-v1. Central allowlist for `POST /api/tracking/interaction`.
3
+ *
4
+ * Default-deny, same posture as `@tot/public-runtime`'s activity redaction
5
+ * contract (`activity/redaction.ts`): the wire payload posted by the browser
6
+ * collector may carry ONLY these keys. Anything else is dropped and counted —
7
+ * never stored, never coerced into a known field. There is no per-action
8
+ * catalog here (unlike the activity spine) because the shape is fixed by the
9
+ * `TotInteraction` contract (`packages/public-runtime/src/interaction-tracking.ts`):
10
+ * one event kind, always the same fields.
11
+ *
12
+ * Every field here is either a `TotInteraction` attribute-contract field (read
13
+ * from `data-tot-*`, never from rendered content) or something this unit's own
14
+ * client-side collector synthesizes (ts / correlationId / seq) — never
15
+ * anything DOM-derived. Extending this allowlist is the ONE place a new field
16
+ * must be declared before the ingest module (`collectorIngest.ts`) will accept
17
+ * it from the wire.
18
+ */
19
+
20
+ /** The exact keys a posted interaction event may carry. */
21
+ export const INTERACTION_WIRE_ALLOWLIST = [
22
+ "type",
23
+ "el",
24
+ "action",
25
+ "placement",
26
+ "scope",
27
+ "index",
28
+ "ts",
29
+ "correlationId",
30
+ "seq",
31
+ ] as const;
32
+
33
+ export type InteractionWireKey = (typeof INTERACTION_WIRE_ALLOWLIST)[number];
34
+
35
+ const ALLOWLIST_SET: ReadonlySet<string> = new Set(INTERACTION_WIRE_ALLOWLIST);
36
+
37
+ /** True when `key` is a declared field. Anything else must be dropped, not stored. */
38
+ export function isAllowedInteractionKey(key: string): key is InteractionWireKey {
39
+ return ALLOWLIST_SET.has(key);
40
+ }