@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
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Publish-time `data-tot-el` safety net — the FALLBACK identity path for
3
+ * content that never flows through git review.
4
+ *
5
+ * `dt-id-writeback` (a sibling unit) is the PRIMARY path: a git-time rewriter
6
+ * that proposes `data-tot-el` for git-tracked tenant content and writes it
7
+ * back to source as a reviewable diff. This module exists for everything
8
+ * that bypasses that review — AI-edit drafts materialized into
9
+ * `dev-draft:` KV (see `DevDraftContentProvider`, lib/storyblok/provider.ts)
10
+ * and direct KV-published edits (`PublishedContentProvider`'s overlay over
11
+ * the customization store). Content sourced from `LocalContentProvider`
12
+ * (build-bundled from git-tracked `tenants/<id>/content/*`) is explicitly
13
+ * OUT of scope here — it is dt-id-writeback's job, and re-stamping it here
14
+ * would fight a human-reviewed, permanently-committed id.
15
+ *
16
+ * Shares the SAME structural derivation engine
17
+ * (`@tot/public-runtime`'s tracking-id-derivation.ts) the git-time rewriter
18
+ * uses, fed from an `HTMLRewriter` element/end-tag callback pair instead of
19
+ * a `parse5` tree — see the "Feeding it" section of
20
+ * docs/architecture/interaction-tracking-attribute-contract.md and
21
+ * dt-id-derivation's handoff note ("keep an ancestor stack across
22
+ * HTMLRewriter element/end-tag callbacks and derive at the open tag; one
23
+ * deriver per document").
24
+ *
25
+ * NO DOM-TEXT READS: only `element.tagName` and `element.attributes` are
26
+ * ever read here, matching the payload guardrail
27
+ * tracking-id-derivation.ts's own module doc documents and tests (a source
28
+ * sweep + a closed attribute allowlist) — never `element.text`, never a
29
+ * child's character data.
30
+ */
31
+ import {
32
+ createTrackingIdDeriver,
33
+ isTrackableNode,
34
+ TRACKING_ATTR_EL,
35
+ type StructuralNode,
36
+ } from "@tot/public-runtime";
37
+
38
+ /**
39
+ * HTML elements with no end tag. Never pushed onto the ancestor stack, and
40
+ * `onEndTag` is never registered for them — HTMLRewriter would never call a
41
+ * callback registered on a void element's (nonexistent) end tag.
42
+ */
43
+ const VOID_TAGS: ReadonlySet<string> = new Set([
44
+ "area",
45
+ "base",
46
+ "br",
47
+ "col",
48
+ "embed",
49
+ "hr",
50
+ "img",
51
+ "input",
52
+ "link",
53
+ "meta",
54
+ "param",
55
+ "source",
56
+ "track",
57
+ "wbr",
58
+ ]);
59
+
60
+ /**
61
+ * Minimal shape of the Workers `HTMLRewriter` Element handler this module
62
+ * needs. Deliberately NOT `@cloudflare/workers-types` (not installed in this
63
+ * repo — see apps/storefront/env.d.ts's own note) — a local, narrow surface
64
+ * so this file typechecks in both the Workers runtime and plain Node/vitest.
65
+ */
66
+ interface RewriterElement {
67
+ tagName: string;
68
+ attributes: IterableIterator<[string, string]>;
69
+ /** True for a void element or one written self-closed (`<foo />`) — either
70
+ * way, no end tag will ever fire for it. */
71
+ selfClosing?: boolean;
72
+ setAttribute(name: string, value: string): void;
73
+ onEndTag(handler: () => void | Promise<void>): void;
74
+ }
75
+
76
+ interface MinimalHTMLRewriter {
77
+ on(selector: string, handlers: { element(el: RewriterElement): void }): MinimalHTMLRewriter;
78
+ transform(response: Response): Response;
79
+ }
80
+
81
+ type HTMLRewriterCtor = new () => MinimalHTMLRewriter;
82
+
83
+ function getHtmlRewriterCtor(): HTMLRewriterCtor | null {
84
+ const ctor = (globalThis as Record<string, unknown>).HTMLRewriter;
85
+ return typeof ctor === "function" ? (ctor as HTMLRewriterCtor) : null;
86
+ }
87
+
88
+ /**
89
+ * True when the Workers `HTMLRewriter` global is available in this runtime.
90
+ * Absent in plain Node (vitest, `astro check`, a bare `node` script) — every
91
+ * caller must treat a false return as "pass the content through unchanged",
92
+ * never throw. Exported so tests can assert the no-op fallback explicitly.
93
+ */
94
+ export function hasHtmlRewriter(): boolean {
95
+ return getHtmlRewriterCtor() !== null;
96
+ }
97
+
98
+ /** Build the closed-allowlist-fed attrs bag HTMLRewriter's Element exposes. */
99
+ function attrsOf(element: RewriterElement): Record<string, string> {
100
+ const attrs: Record<string, string> = {};
101
+ for (const [name, value] of element.attributes) attrs[name] = value;
102
+ return attrs;
103
+ }
104
+
105
+ /**
106
+ * Stamp `data-tot-el` onto every trackable element in `html` that doesn't
107
+ * already declare one — a single streaming pass, ONE deriver for the whole
108
+ * document (so ids stay unique within it), maintaining an outermost-first
109
+ * open-tag stack across element/end-tag callbacks the same way a `parse5`
110
+ * tree walk would.
111
+ *
112
+ * An element that already carries `data-tot-el` (confidence: "declared") is
113
+ * left completely untouched — its value is only `reserve()`-d into this
114
+ * document's deriver, so a later element in the SAME pass can never propose
115
+ * a colliding id. There is no pre-scan pass (this repo has no prior art for
116
+ * one over raw tenant HTML at publish time — see the module doc): a
117
+ * declared id that appears LATER in the document than an already-derived
118
+ * proposal is a known, accepted single-pass limitation, no worse than
119
+ * shipping no identity for this content at all.
120
+ *
121
+ * No-op (returns `html` unchanged) when `HTMLRewriter` isn't available in
122
+ * this runtime.
123
+ */
124
+ export async function stampTrackingIdsAtPublish(html: string): Promise<string> {
125
+ const HTMLRewriter = getHtmlRewriterCtor();
126
+ if (!HTMLRewriter) return html;
127
+
128
+ const deriver = createTrackingIdDeriver();
129
+ // Outermost-first open-tag stack, shared across every element/end-tag
130
+ // callback for the WHOLE document — one deriver, one stack, per document
131
+ // (dt-id-derivation's handoff note).
132
+ const stack: StructuralNode[] = [];
133
+
134
+ const rewriter = new HTMLRewriter().on("*", {
135
+ element(element: RewriterElement) {
136
+ const tag = element.tagName.toLowerCase();
137
+ const attrs = attrsOf(element);
138
+ const node: StructuralNode = { tag, attrs };
139
+ const existing = attrs[TRACKING_ATTR_EL];
140
+
141
+ if (existing && existing.trim().length > 0) {
142
+ // confidence: "declared" — already identified. Leave it alone; just
143
+ // reserve it so nothing derived later in this pass can collide.
144
+ deriver.reserve(existing);
145
+ } else if (isTrackableNode(node)) {
146
+ const ancestors = stack.slice();
147
+ const derived = deriver.derive({ node, ancestors });
148
+ element.setAttribute(TRACKING_ATTR_EL, derived.el);
149
+ }
150
+
151
+ if (!VOID_TAGS.has(tag) && !element.selfClosing) {
152
+ stack.push(node);
153
+ element.onEndTag(() => {
154
+ stack.pop();
155
+ });
156
+ }
157
+ },
158
+ });
159
+
160
+ const rewritten = rewriter.transform(new Response(html));
161
+ return await rewritten.text();
162
+ }
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Resolve (and, on first sight, provision) ONE TENANT'S OWN raw-interaction D1
3
+ * database.
4
+ *
5
+ * The isolation claim of `dt-storage-per-tenant-d1` rests on this module: the
6
+ * ONLY way the write path gets a database handle is `resolveTenantStore()`,
7
+ * which takes a tenant id and returns a `Queryable` bound to that tenant's
8
+ * database UUID and nothing else. There is no handle in this codebase that
9
+ * spans two tenants' raw rows, so a forgotten `WHERE tenant_id = ?` cannot leak
10
+ * across tenants — the wrong-tenant rows are not in the database being queried.
11
+ *
12
+ * THE THREE STEPS, all idempotent and all cached per isolate:
13
+ * 1. NAME — derive the database name deterministically from the tenant id
14
+ * (`<prefix>-<sanitized tenant>-<8 hex of sha256(tenant)>`). The
15
+ * hash suffix is what keeps the mapping injective after
16
+ * sanitizing: two distinct tenant ids that sanitize to the same
17
+ * slug still get different databases.
18
+ * 2. DIRECTORY — read `tracking_tenant_databases` in the shared
19
+ * STOREFRONT_APPS_DB for an existing `(name, uuid)`. Present ⇒
20
+ * done, zero control-plane calls on the hot path.
21
+ * 3. PROVISION — absent ⇒ find-or-create the D1 database over REST, apply the
22
+ * idempotent schema, stamp `interaction_store_meta`, and record
23
+ * the mapping in the directory. Concurrent first-beacons race
24
+ * benignly: `ensureDatabase` re-resolves by name and the
25
+ * directory write is an upsert.
26
+ *
27
+ * BEST-EFFORT, NEVER THROWS. Every entry point returns `undefined` when the
28
+ * store cannot be reached (no REST credentials, no apps-DB directory, a
29
+ * control-plane error) — "sink unavailable, skip it", the posture
30
+ * `activity/store.ts` already sets for an unbound sink. A telemetry beacon must
31
+ * never fail because storage is unconfigured.
32
+ */
33
+ import { sha256Hex } from "@tot/public-runtime";
34
+ import { readEnv } from "../env.js";
35
+ import type { Queryable } from "../d1/catalog.js";
36
+ import {
37
+ ensureDatabase,
38
+ fromD1Rest,
39
+ type D1ControlConfig,
40
+ } from "./d1Control.js";
41
+ import {
42
+ INTERACTION_D1_SCHEMA,
43
+ INTERACTION_SCHEMA_VERSION,
44
+ interactionMetaUpsert,
45
+ } from "./interactionSchema.js";
46
+
47
+ /** Fallback name prefix when `TRACKING_D1_NAME_PREFIX` is unset. */
48
+ const DEFAULT_NAME_PREFIX = "tot-storefront-dt";
49
+
50
+ /** D1 database names are limited; keep the readable slug well inside it. */
51
+ const MAX_SLUG_LEN = 32;
52
+
53
+ /** A resolved per-tenant store: the tenant it belongs to and its query handle. */
54
+ export interface TenantInteractionStore {
55
+ tenantId: string;
56
+ databaseName: string;
57
+ databaseUuid: string;
58
+ db: Queryable;
59
+ }
60
+
61
+ /** One directory row of `tracking_tenant_databases`. */
62
+ export interface TenantDatabaseRecord {
63
+ tenant_id: string;
64
+ database_name: string;
65
+ database_uuid: string;
66
+ schema_version: number;
67
+ }
68
+
69
+ /**
70
+ * Deterministic, injective database name for a tenant. The readable slug is for
71
+ * humans in the Cloudflare dashboard; the hash suffix is what guarantees two
72
+ * different tenant ids never collide onto one database even when their slugs do.
73
+ */
74
+ export async function tenantDatabaseName(tenantId: string, prefix: string): Promise<string> {
75
+ const slug =
76
+ tenantId
77
+ .toLowerCase()
78
+ .replace(/[^a-z0-9]+/g, "-")
79
+ .replace(/^-+|-+$/g, "")
80
+ .slice(0, MAX_SLUG_LEN)
81
+ .replace(/-+$/, "") || "tenant";
82
+ const digest = (await sha256Hex(tenantId)).slice(0, 8);
83
+ return `${prefix}-${slug}-${digest}`;
84
+ }
85
+
86
+ /** The configured name prefix for this environment. */
87
+ export async function trackingNamePrefix(): Promise<string> {
88
+ return (await readEnv("TRACKING_D1_NAME_PREFIX")) ?? DEFAULT_NAME_PREFIX;
89
+ }
90
+
91
+ /** Read a tenant's directory row, or null. Never throws. */
92
+ export async function readTenantDatabase(
93
+ apps: Queryable,
94
+ tenantId: string,
95
+ ): Promise<TenantDatabaseRecord | null> {
96
+ try {
97
+ return await apps.first<TenantDatabaseRecord>(
98
+ `SELECT tenant_id, database_name, database_uuid, schema_version
99
+ FROM tracking_tenant_databases WHERE tenant_id = ?`,
100
+ tenantId,
101
+ );
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
106
+
107
+ /** Upsert a tenant's directory row. Never throws. */
108
+ export async function recordTenantDatabase(
109
+ apps: Queryable,
110
+ record: { tenantId: string; databaseName: string; databaseUuid: string; at: string },
111
+ ): Promise<void> {
112
+ try {
113
+ await apps.run(
114
+ `INSERT INTO tracking_tenant_databases
115
+ (tenant_id, database_name, database_uuid, schema_version, created_at, updated_at, pruned_rows)
116
+ VALUES (?,?,?,?,?,?,0)
117
+ ON CONFLICT(tenant_id) DO UPDATE SET
118
+ database_name = excluded.database_name,
119
+ database_uuid = excluded.database_uuid,
120
+ schema_version = excluded.schema_version,
121
+ updated_at = excluded.updated_at`,
122
+ record.tenantId,
123
+ record.databaseName,
124
+ record.databaseUuid,
125
+ INTERACTION_SCHEMA_VERSION,
126
+ record.at,
127
+ record.at,
128
+ );
129
+ } catch (err) {
130
+ console.error(
131
+ `[tracking:interaction] directory write failed for ${record.tenantId}: ${
132
+ err instanceof Error ? err.message : String(err)
133
+ }`,
134
+ );
135
+ }
136
+ }
137
+
138
+ /** Apply the idempotent per-tenant schema and stamp its meta row. */
139
+ export async function applyInteractionSchema(
140
+ db: Queryable,
141
+ tenantId: string,
142
+ at: string,
143
+ ): Promise<void> {
144
+ await db.exec(INTERACTION_D1_SCHEMA);
145
+ const meta = interactionMetaUpsert(tenantId, at);
146
+ await db.run(meta.sql, ...meta.params);
147
+ }
148
+
149
+ /** Injected seams — so the whole resolution is testable without Cloudflare. */
150
+ export interface ResolveDeps {
151
+ /** The shared STOREFRONT_APPS_DB handle holding the directory table. */
152
+ apps: Queryable;
153
+ /** D1 REST credentials + transport. */
154
+ control: D1ControlConfig;
155
+ /** Environment-scoped database-name prefix. */
156
+ namePrefix: string;
157
+ /** ISO-8601 "now", for the directory + meta rows. */
158
+ at: string;
159
+ }
160
+
161
+ /**
162
+ * Resolve a tenant's store, provisioning it on first sight. Returns undefined
163
+ * when the store cannot be reached — never throws.
164
+ *
165
+ * A resolved handle is cached per isolate (`storeCache`) so the steady state is
166
+ * ZERO control-plane and ZERO directory calls per beacon.
167
+ */
168
+ const storeCache = new Map<string, TenantInteractionStore>();
169
+
170
+ /** Test seam: drop the per-isolate resolution cache. */
171
+ export function clearTenantStoreCache(): void {
172
+ storeCache.clear();
173
+ }
174
+
175
+ export async function resolveTenantStore(
176
+ tenantId: string,
177
+ deps: ResolveDeps,
178
+ ): Promise<TenantInteractionStore | undefined> {
179
+ if (!tenantId) return undefined;
180
+ const cached = storeCache.get(tenantId);
181
+ if (cached) return cached;
182
+
183
+ try {
184
+ const existing = await readTenantDatabase(deps.apps, tenantId);
185
+ if (existing && existing.schema_version >= INTERACTION_SCHEMA_VERSION) {
186
+ const store: TenantInteractionStore = {
187
+ tenantId,
188
+ databaseName: existing.database_name,
189
+ databaseUuid: existing.database_uuid,
190
+ db: fromD1Rest(deps.control, existing.database_uuid),
191
+ };
192
+ storeCache.set(tenantId, store);
193
+ return store;
194
+ }
195
+
196
+ // First sight for this tenant (or a schema bump): provision.
197
+ const databaseName = existing?.database_name ?? (await tenantDatabaseName(tenantId, deps.namePrefix));
198
+ const databaseUuid = existing?.database_uuid ?? (await ensureDatabase(deps.control, databaseName));
199
+ const db = fromD1Rest(deps.control, databaseUuid);
200
+ await applyInteractionSchema(db, tenantId, deps.at);
201
+ await recordTenantDatabase(deps.apps, { tenantId, databaseName, databaseUuid, at: deps.at });
202
+
203
+ const store: TenantInteractionStore = { tenantId, databaseName, databaseUuid, db };
204
+ storeCache.set(tenantId, store);
205
+ return store;
206
+ } catch (err) {
207
+ console.error(
208
+ `[tracking:interaction] store resolution failed for ${tenantId}: ${
209
+ err instanceof Error ? err.message : String(err)
210
+ }`,
211
+ );
212
+ return undefined;
213
+ }
214
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Merchant-facing READ authorization over the per-tenant interaction D1
3
+ * databases `dt-storage-per-tenant-d1` built (`tenantD1Registry.ts`,
4
+ * `interactionStore.ts`). This is `dt-tenant-isolation`: the release-order
5
+ * doc's hard invariant is "no merchant-visible surface ships until this
6
+ * lands", and this module is that surface's data-access layer.
7
+ *
8
+ * THE ISOLATION GUARANTEE THIS MODULE RELIES ON (built by
9
+ * `dt-storage-per-tenant-d1`, not re-implemented here): `resolveTenantStore`
10
+ * is the ONLY way to get a `Queryable`, and it is bound to exactly one
11
+ * tenant's database UUID. There is no handle anywhere that spans two
12
+ * tenants' rows, so every function below is safe to call with an UNSCOPED
13
+ * `SELECT` — the database itself is the isolation boundary, not the `WHERE`
14
+ * clause. A `tenant_id = ?` predicate is still included below (matching the
15
+ * `TenantInteractionStore.tenantId` the caller resolved — NEVER a
16
+ * client-suppliable value) as defence-in-depth and to keep the row's
17
+ * redundant `tenant_id` column meaningful, not because it is what makes the
18
+ * isolation hold.
19
+ *
20
+ * `assertStoreOwnership` is the extra check this unit adds on top: before
21
+ * trusting a resolved store for a read, confirm `interaction_store_meta`
22
+ * (stamped by `dt-storage-per-tenant-d1` at provision time) actually says
23
+ * this database belongs to the tenant we resolved it for. A resolution bug
24
+ * that somehow handed back the wrong store is caught here rather than
25
+ * silently serving the wrong tenant's rows to a merchant.
26
+ */
27
+ import type { TenantInteractionStore } from "./tenantD1Registry.js";
28
+
29
+ /** One row of the merchant-visible recent-events feed. */
30
+ export interface RecentInteractionEvent {
31
+ type: string;
32
+ el: string;
33
+ action: string | null;
34
+ placement: string | null;
35
+ scope: string | null;
36
+ ts: string;
37
+ ingestedAt: string;
38
+ }
39
+
40
+ /** One rolled-up (action, el) count — the merchant-visible summary shape. */
41
+ export interface InteractionActionCount {
42
+ el: string;
43
+ action: string;
44
+ count: number;
45
+ }
46
+
47
+ export interface InteractionInsights {
48
+ totalEvents: number;
49
+ byAction: InteractionActionCount[];
50
+ recent: RecentInteractionEvent[];
51
+ }
52
+
53
+ /** Cap on the recent-events feed — a dashboard preview, not an export. */
54
+ const DEFAULT_RECENT_LIMIT = 50;
55
+ const MAX_RECENT_LIMIT = 200;
56
+
57
+ /**
58
+ * Confirm a resolved store's `interaction_store_meta` row actually names the
59
+ * tenant we resolved it for. Returns `false` (never throws) on any mismatch,
60
+ * a missing meta row, or a query failure — all fail-closed: the caller must
61
+ * treat that as "cannot verify this store, do not read from it."
62
+ */
63
+ export async function assertStoreOwnership(
64
+ store: Pick<TenantInteractionStore, "tenantId" | "db">,
65
+ ): Promise<boolean> {
66
+ try {
67
+ const meta = await store.db.first<{ tenant_id: string }>(
68
+ `SELECT tenant_id FROM interaction_store_meta WHERE id = 1`,
69
+ );
70
+ return !!meta && meta.tenant_id === store.tenantId;
71
+ } catch {
72
+ return false;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Recent raw events for ONE tenant's own store, newest first. Scoped to the
78
+ * resolved store's database (the isolation boundary) AND to its
79
+ * `tenantId` (defence-in-depth) — there is no parameter here through which a
80
+ * caller can ask for a different tenant's rows; the only tenant this can
81
+ * ever return is the one `store` was resolved for.
82
+ */
83
+ export async function readRecentInteractionEvents(
84
+ store: Pick<TenantInteractionStore, "tenantId" | "db">,
85
+ opts?: { limit?: number },
86
+ ): Promise<RecentInteractionEvent[]> {
87
+ const limit = Math.max(1, Math.min(opts?.limit ?? DEFAULT_RECENT_LIMIT, MAX_RECENT_LIMIT));
88
+ return store.db.all<RecentInteractionEvent>(
89
+ `SELECT type, el, action, placement, scope, ts, ingested_at AS ingestedAt
90
+ FROM interaction_events
91
+ WHERE tenant_id = ?
92
+ ORDER BY ingested_at DESC
93
+ LIMIT ?`,
94
+ store.tenantId,
95
+ limit,
96
+ );
97
+ }
98
+
99
+ /**
100
+ * Simple aggregate over ONE tenant's own store: total row count + a rollup by
101
+ * (el, action). Mirrors the AE dimension tuple's spirit but computed directly
102
+ * off the tenant's own raw rows, scoped the same way as
103
+ * {@link readRecentInteractionEvents}.
104
+ */
105
+ export async function readInteractionSummary(
106
+ store: Pick<TenantInteractionStore, "tenantId" | "db">,
107
+ ): Promise<{ totalEvents: number; byAction: InteractionActionCount[] }> {
108
+ const totalRow = await store.db.first<{ n: number }>(
109
+ `SELECT COUNT(*) AS n FROM interaction_events WHERE tenant_id = ?`,
110
+ store.tenantId,
111
+ );
112
+ const byAction = await store.db.all<InteractionActionCount>(
113
+ `SELECT el, COALESCE(action, '') AS action, COUNT(*) AS count
114
+ FROM interaction_events
115
+ WHERE tenant_id = ?
116
+ GROUP BY el, action
117
+ ORDER BY count DESC`,
118
+ store.tenantId,
119
+ );
120
+ return { totalEvents: totalRow?.n ?? 0, byAction };
121
+ }
122
+
123
+ /**
124
+ * The full merchant-visible read: ownership-asserted, then summary + recent
125
+ * feed off the SAME scoped store. Returns `undefined` when the store fails
126
+ * the ownership assertion — fail-closed, never a partial/best-guess result.
127
+ */
128
+ export async function readTenantInteractionInsights(
129
+ store: Pick<TenantInteractionStore, "tenantId" | "db">,
130
+ opts?: { limit?: number },
131
+ ): Promise<InteractionInsights | undefined> {
132
+ if (!(await assertStoreOwnership(store))) return undefined;
133
+ const [summary, recent] = await Promise.all([
134
+ readInteractionSummary(store),
135
+ readRecentInteractionEvents(store, opts),
136
+ ]);
137
+ return { totalEvents: summary.totalEvents, byAction: summary.byAction, recent };
138
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * `data-tot-el` writeback (dt-id-writeback) — the git-time rewriter that turns
3
+ * `tracking-id-derivation.ts`'s PROPOSALS into an actual attribute in a tenant's
4
+ * source HTML, so a human reviews and accepts a real diff (never a silent
5
+ * runtime mutation). Mirrors the pattern `the-build/toc.ts` already uses for
6
+ * heading anchors: parse5 fragment/document tree, mutate in place, re-`serialize()`
7
+ * ONLY when something actually changed (byte-preserving otherwise).
8
+ *
9
+ * Scope: tenant-authored BODY documents only (`content/home.html`,
10
+ * `content/<tenant>/pages-html/<slug>.html`) — never governed chrome, which
11
+ * `trackingAttributes()` / `trackingAttributesHtml()` already stamp via a
12
+ * separate, non-derived path (`dt-stamp-chrome-nav`). This module does not know
13
+ * about chrome and must never be pointed at `chrome.html` / `chrome.json`.
14
+ *
15
+ * Contract: docs/architecture/interaction-tracking-attribute-contract.md
16
+ * ("Deriving `data-tot-el`").
17
+ */
18
+ import { parse, parseFragment, serialize } from "parse5";
19
+ import {
20
+ createTrackingIdDeriver,
21
+ isTrackableNode,
22
+ TRACKING_ATTR_EL,
23
+ type DerivationOptions,
24
+ type DerivationConfidence,
25
+ type StructuralNode,
26
+ } from "@tot/public-runtime";
27
+
28
+ /** Minimal structural view of a parse5 node (duck-typed, as toc.ts does). */
29
+ interface P5Attr {
30
+ name: string;
31
+ value: string;
32
+ }
33
+ interface P5Node {
34
+ nodeName: string;
35
+ tagName?: string;
36
+ attrs?: P5Attr[];
37
+ childNodes?: P5Node[];
38
+ }
39
+
40
+ /** One proposed (or already-declared) `data-tot-el` this pass encountered. */
41
+ export interface TrackingWritebackProposal {
42
+ /** The proposed (or, for a `"declared"` row, the pre-existing) value. */
43
+ el: string;
44
+ confidence: DerivationConfidence;
45
+ /** Most-significant-first, per `DerivedTrackingId.signals` — the diff annotation. */
46
+ signals: readonly string[];
47
+ /** True iff a human should name this element before accepting the diff. */
48
+ needsReview: boolean;
49
+ /** True iff `data-tot-el` was ALREADY present (nothing written; verbatim). */
50
+ alreadyDeclared: boolean;
51
+ /** The element's tag, for the review listing. */
52
+ tag: string;
53
+ /** A short open-tag snippet (no children) so a reviewer can locate the element. */
54
+ snippet: string;
55
+ }
56
+
57
+ export interface TrackingWritebackResult {
58
+ /** The (possibly) rewritten source. Byte-identical to the input when `changed` is false. */
59
+ html: string;
60
+ /** True iff at least one element gained a new `data-tot-el`. */
61
+ changed: boolean;
62
+ /** Every candidate element seen, in document order — declared rows included. */
63
+ proposals: TrackingWritebackProposal[];
64
+ }
65
+
66
+ function attrsToRecord(attrs?: P5Attr[]): Record<string, string> | undefined {
67
+ if (!attrs || attrs.length === 0) return undefined;
68
+ const rec: Record<string, string> = {};
69
+ for (const a of attrs) rec[a.name] = a.value;
70
+ return rec;
71
+ }
72
+
73
+ function toStructuralNode(node: P5Node): StructuralNode {
74
+ return { tag: (node.tagName ?? "").toLowerCase(), attrs: attrsToRecord(node.attrs) };
75
+ }
76
+
77
+ function openTagSnippet(node: StructuralNode): string {
78
+ const attrs = node.attrs ?? {};
79
+ const parts = Object.entries(attrs)
80
+ .filter((entry): entry is [string, string] => typeof entry[1] === "string")
81
+ .map(([k, v]) => `${k}="${v}"`);
82
+ return `<${node.tag}${parts.length ? " " + parts.join(" ") : ""}>`;
83
+ }
84
+
85
+ function isElement(node: P5Node): node is P5Node & { tagName: string } {
86
+ return typeof node.tagName === "string" && node.tagName.length > 0;
87
+ }
88
+
89
+ /** Every `data-tot-el` value anywhere in the tree, in document order — the
90
+ * pre-existing identities a fresh proposal must never collide with, per the
91
+ * derivation engine's own handoff ("reserve() every id already in the source,
92
+ * BEFORE deriving"). Deliberately unfiltered by {@link isTrackableNode}: a
93
+ * hand-authored id on an element this pass wouldn't otherwise touch still
94
+ * occupies the namespace. */
95
+ function* declaredIds(root: P5Node): Generator<string> {
96
+ for (const child of root.childNodes ?? []) {
97
+ if (isElement(child)) {
98
+ const value = child.attrs?.find((a) => a.name === TRACKING_ATTR_EL)?.value?.trim();
99
+ if (value) yield value;
100
+ }
101
+ yield* declaredIds(child);
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Walk the tree in document order, yielding every trackable element with its
107
+ * ancestor stack (OUTERMOST first — the derivation engine's own contract).
108
+ */
109
+ function* candidates(
110
+ root: P5Node,
111
+ ancestors: readonly P5Node[] = [],
112
+ ): Generator<{ node: P5Node; ancestors: readonly P5Node[] }> {
113
+ for (const child of root.childNodes ?? []) {
114
+ if (isElement(child)) {
115
+ if (isTrackableNode(toStructuralNode(child))) {
116
+ yield { node: child, ancestors };
117
+ }
118
+ yield* candidates(child, [...ancestors, child]);
119
+ } else {
120
+ yield* candidates(child, ancestors);
121
+ }
122
+ }
123
+ }
124
+
125
+ /** Whether `html` looks like a full document (vs. a body-only fragment). */
126
+ function isFullDocument(html: string): boolean {
127
+ return /^\s*(<!doctype\s|<html[\s>])/i.test(html);
128
+ }
129
+
130
+ /**
131
+ * Propose (and write) `data-tot-el` for every undeclared trackable element in
132
+ * `html`. Pure: no I/O, no network. `confidence: "declared"` elements are
133
+ * returned in `proposals` for a complete review listing but are NEVER mutated —
134
+ * a declared identity is permanent (per the derivation engine's own contract).
135
+ *
136
+ * `html` may be a full tenant document (`content/home.html`, doctype + `<html>`)
137
+ * or a body-only fragment (`content/<tenant>/pages-html/<slug>.html`) — detected
138
+ * automatically so one function serves both writeback call sites.
139
+ */
140
+ export function deriveTrackingWriteback(
141
+ html: string,
142
+ options?: DerivationOptions,
143
+ ): TrackingWritebackResult {
144
+ const fullDocument = isFullDocument(html);
145
+ const root = (fullDocument ? parse(html) : parseFragment(html)) as unknown as P5Node;
146
+
147
+ const deriver = createTrackingIdDeriver(options);
148
+ for (const id of declaredIds(root)) deriver.reserve(id);
149
+
150
+ const proposals: TrackingWritebackProposal[] = [];
151
+ let changed = false;
152
+
153
+ for (const { node, ancestors } of candidates(root)) {
154
+ const structural = toStructuralNode(node);
155
+ const alreadyDeclared = Boolean(structural.attrs?.[TRACKING_ATTR_EL]?.trim());
156
+ const derived = deriver.derive({
157
+ node: structural,
158
+ ancestors: ancestors.map(toStructuralNode),
159
+ });
160
+ proposals.push({
161
+ el: derived.el,
162
+ confidence: derived.confidence,
163
+ signals: derived.signals,
164
+ needsReview: derived.needsReview,
165
+ alreadyDeclared,
166
+ tag: structural.tag,
167
+ snippet: openTagSnippet(structural),
168
+ });
169
+ if (alreadyDeclared) continue; // declared verbatim — never overwritten
170
+ node.attrs = [...(node.attrs ?? []), { name: TRACKING_ATTR_EL, value: derived.el }];
171
+ changed = true;
172
+ }
173
+
174
+ return { html: changed ? serialize(root as never) : html, changed, proposals };
175
+ }