@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.
- package/apps/storefront/migrations-apps/0011_dt_tracking_tenant_databases.sql +23 -0
- package/apps/storefront/node_modules/.bin/vitest +2 -2
- package/apps/storefront/node_modules/.vite/deps/_metadata.json +19 -19
- package/apps/storefront/node_modules/.vite/deps_ssr/_metadata.json +3 -3
- package/apps/storefront/src/components/chrome/MegaMenu.astro +11 -1
- package/apps/storefront/src/components/chrome/NavDropdown.astro +49 -7
- package/apps/storefront/src/components/chrome/SiteFooter.astro +29 -4
- package/apps/storefront/src/components/chrome/SiteHeader.astro +39 -6
- package/apps/storefront/src/components/chrome/UtilityNav.astro +13 -1
- package/apps/storefront/src/components/islands/MobileNav.tsx +42 -2
- package/apps/storefront/src/layouts/Layout.astro +64 -6
- package/apps/storefront/src/lib/chrome/ctaIdentity.ts +21 -0
- package/apps/storefront/src/lib/chrome/examples/tokenoftrust.chrome.example.json +22 -22
- package/apps/storefront/src/lib/chrome/trackingPlacements.ts +36 -0
- package/apps/storefront/src/lib/d1/schema-apps.ts +57 -0
- package/apps/storefront/src/lib/rawChrome.ts +73 -14
- package/apps/storefront/src/lib/rawMarketingHtml.ts +30 -0
- package/apps/storefront/src/lib/storyblok/provider.ts +21 -5
- package/apps/storefront/src/lib/tracking/collectorAllowlist.ts +40 -0
- package/apps/storefront/src/lib/tracking/collectorIngest.ts +188 -0
- package/apps/storefront/src/lib/tracking/d1Control.ts +180 -0
- package/apps/storefront/src/lib/tracking/interactionCollector.ts +202 -0
- package/apps/storefront/src/lib/tracking/interactionHealth.ts +252 -0
- package/apps/storefront/src/lib/tracking/interactionSchema.ts +107 -0
- package/apps/storefront/src/lib/tracking/interactionStore.ts +0 -0
- package/apps/storefront/src/lib/tracking/pruneRollup.ts +260 -0
- package/apps/storefront/src/lib/tracking/publishSafetyNet.ts +162 -0
- package/apps/storefront/src/lib/tracking/tenantD1Registry.ts +214 -0
- package/apps/storefront/src/lib/tracking/tenantReadAccess.ts +138 -0
- package/apps/storefront/src/lib/tracking-writeback/rewriteTrackingIds.ts +175 -0
- package/apps/storefront/src/lib/tracking-writeback/writebackCandidate.ts +110 -0
- package/apps/storefront/src/pages/api/admin/interaction-insights.ts +101 -0
- package/apps/storefront/src/pages/api/tracking/interaction.ts +123 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +33 -14
- package/apps/storefront/src/workers/pruneRollupWorker.ts +120 -0
- package/package.json +1 -1
- package/packages/public-runtime/node_modules/.bin/vitest +2 -2
- package/packages/public-runtime/src/chrome.ts +18 -7
- package/packages/public-runtime/src/index.ts +10 -0
- package/packages/public-runtime/src/integration/index.ts +1 -0
- package/packages/public-runtime/src/integration/interaction-tracking-gate.ts +142 -0
- package/packages/public-runtime/src/interaction-tracking.ts +364 -0
- package/packages/public-runtime/src/tracking-id-derivation.ts +1008 -0
- package/scripts/tenant/validate.mjs +306 -1
- package/scripts/tot-dev.mjs +59 -3
- 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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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) =>
|
|
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)}"
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
261
|
-
// served DOM rather than implied by label
|
|
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
|
-
|
|
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
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|