@tokenoftrust/storefront-runner 2.0.4 → 2.0.6
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/shared/developerInvites.ts +6 -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
|
@@ -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
|
+
}
|