@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
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ingest validation for `POST /api/tracking/interaction` (dt-collector-v1).
|
|
3
|
+
*
|
|
4
|
+
* PURE (no I/O) — a thin auth-free parse/validate shell, same split as
|
|
5
|
+
* `activity/ingest.ts`: this module decides accept/reject, the route
|
|
6
|
+
* (`pages/api/tracking/interaction.ts`) does auth/transport/persistence.
|
|
7
|
+
*
|
|
8
|
+
* DEFAULT-DENY ALLOWLIST (reuses the posture of `activity/redaction.ts` /
|
|
9
|
+
* `activity/ingest.ts`'s `createActivityEvent()` re-validation, not their
|
|
10
|
+
* code — there is no per-action catalog for a fixed-shape event):
|
|
11
|
+
* 1. Only keys in `INTERACTION_WIRE_ALLOWLIST` are ever read from the raw
|
|
12
|
+
* body. Every other key is DROPPED and COUNTED (`droppedKeys`) — never
|
|
13
|
+
* stored, never logged with its value.
|
|
14
|
+
* 2. A declared key with the WRONG TYPE is never coerced — the whole event
|
|
15
|
+
* is rejected with a reason naming the field, exactly like a malformed
|
|
16
|
+
* `activity` event is rejected rather than papered over.
|
|
17
|
+
*
|
|
18
|
+
* TENANT + STREAM ISOLATION. `tenant_id` and `stream` are NEVER read from the
|
|
19
|
+
* posted body (they are not even in the allowlist) — the route stamps both
|
|
20
|
+
* server-side on every accepted event: `tenant_id` from the authenticated
|
|
21
|
+
* `context.locals.tenant.tenant_id`, `stream` as the fixed literal this module
|
|
22
|
+
* exports (`INTERACTION_STREAM`). A client cannot claim another tenant's scope
|
|
23
|
+
* or another stream.
|
|
24
|
+
*/
|
|
25
|
+
import { isIsoTimestamp } from "@tot/public-runtime";
|
|
26
|
+
import { isAllowedInteractionKey } from "./collectorAllowlist.js";
|
|
27
|
+
|
|
28
|
+
/** The single stream this collector accepts in V1. Never client-supplied —
|
|
29
|
+
* stamped server-side so `dt-storage-per-tenant-d1` has a stable discriminator
|
|
30
|
+
* column ready before a second stream (e.g. a future custom `track()` call)
|
|
31
|
+
* exists. */
|
|
32
|
+
export const INTERACTION_STREAM = "interaction" as const;
|
|
33
|
+
|
|
34
|
+
/** Interaction kinds this collector accepts. Mirrors `TotInteraction["type"]`
|
|
35
|
+
* (currently "click" only — V1 has no other interaction kind). */
|
|
36
|
+
const INTERACTION_TYPES = new Set(["click"]);
|
|
37
|
+
|
|
38
|
+
/** A validated wire event, before tenant/stream are stamped. */
|
|
39
|
+
export interface InteractionWireEvent {
|
|
40
|
+
type: "click";
|
|
41
|
+
el: string;
|
|
42
|
+
action: string | null;
|
|
43
|
+
placement: string | null;
|
|
44
|
+
scope: string | null;
|
|
45
|
+
index: number | null;
|
|
46
|
+
/** Client emit clock (ISO-8601). Informational only — never trusted for
|
|
47
|
+
* ordering guarantees; the store also has its own receive clock. */
|
|
48
|
+
ts: string;
|
|
49
|
+
/** Groups events emitted by one collector lifetime (roughly: one page load). */
|
|
50
|
+
correlationId: string;
|
|
51
|
+
/** Monotonic within `correlationId`, assigned by the client collector. */
|
|
52
|
+
seq: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The stored/forwarded shape: a validated wire event plus the server stamp. */
|
|
56
|
+
export interface StampedInteractionEvent extends InteractionWireEvent {
|
|
57
|
+
tenant_id: string;
|
|
58
|
+
stream: typeof INTERACTION_STREAM;
|
|
59
|
+
/** Server receive clock (ISO-8601), stamped once per ingest. */
|
|
60
|
+
ingested_at: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface InteractionRejection {
|
|
64
|
+
index: number;
|
|
65
|
+
reason:
|
|
66
|
+
| "not_object"
|
|
67
|
+
| "unknown_type"
|
|
68
|
+
| "el_invalid"
|
|
69
|
+
| "action_invalid"
|
|
70
|
+
| "placement_invalid"
|
|
71
|
+
| "scope_invalid"
|
|
72
|
+
| "index_invalid"
|
|
73
|
+
| "ts_invalid"
|
|
74
|
+
| "correlation_id_invalid"
|
|
75
|
+
| "seq_invalid";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface AcceptedInteraction {
|
|
79
|
+
event: StampedInteractionEvent;
|
|
80
|
+
/** Non-allowlisted keys present on the raw body, dropped before validation
|
|
81
|
+
* (fact-of, never the value) — for audit / alerting, same convention as
|
|
82
|
+
* `RedactionFieldHit`. */
|
|
83
|
+
droppedKeys: string[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface InteractionIngestOutcome {
|
|
87
|
+
accepted: AcceptedInteraction[];
|
|
88
|
+
rejected: InteractionRejection[];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const MAX_CORRELATION_ID_LEN = 128;
|
|
92
|
+
|
|
93
|
+
function nullableString(v: unknown): v is string | null {
|
|
94
|
+
return v === null || v === undefined || typeof v === "string";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function asNullableString(v: unknown): string | null {
|
|
98
|
+
return typeof v === "string" ? v : null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Validate + stamp one posted interaction under the authenticated tenant.
|
|
103
|
+
* Strips any non-allowlisted key first (dropped + counted, never stored),
|
|
104
|
+
* then validates each allowlisted field's TYPE — a wrong-typed field rejects
|
|
105
|
+
* the whole event rather than being coerced.
|
|
106
|
+
*/
|
|
107
|
+
export function ingestInteraction(
|
|
108
|
+
index: number,
|
|
109
|
+
raw: unknown,
|
|
110
|
+
tenantId: string,
|
|
111
|
+
ingestedAt: string,
|
|
112
|
+
): AcceptedInteraction | InteractionRejection {
|
|
113
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { index, reason: "not_object" };
|
|
114
|
+
const e = raw as Record<string, unknown>;
|
|
115
|
+
|
|
116
|
+
const droppedKeys = Object.keys(e).filter((k) => !isAllowedInteractionKey(k));
|
|
117
|
+
|
|
118
|
+
const type = e.type;
|
|
119
|
+
if (typeof type !== "string" || !INTERACTION_TYPES.has(type)) return { index, reason: "unknown_type" };
|
|
120
|
+
|
|
121
|
+
const el = e.el;
|
|
122
|
+
if (typeof el !== "string" || el.trim().length === 0) return { index, reason: "el_invalid" };
|
|
123
|
+
|
|
124
|
+
if (!nullableString(e.action)) return { index, reason: "action_invalid" };
|
|
125
|
+
if (!nullableString(e.placement)) return { index, reason: "placement_invalid" };
|
|
126
|
+
if (!nullableString(e.scope)) return { index, reason: "scope_invalid" };
|
|
127
|
+
|
|
128
|
+
const rawIndex = e.index;
|
|
129
|
+
let idx: number | null;
|
|
130
|
+
if (rawIndex === null || rawIndex === undefined) {
|
|
131
|
+
idx = null;
|
|
132
|
+
} else if (typeof rawIndex === "number" && Number.isInteger(rawIndex) && rawIndex >= 0) {
|
|
133
|
+
idx = rawIndex;
|
|
134
|
+
} else {
|
|
135
|
+
return { index, reason: "index_invalid" };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const ts = e.ts;
|
|
139
|
+
if (!isIsoTimestamp(ts)) return { index, reason: "ts_invalid" };
|
|
140
|
+
|
|
141
|
+
const correlationId = e.correlationId;
|
|
142
|
+
if (
|
|
143
|
+
typeof correlationId !== "string" ||
|
|
144
|
+
correlationId.trim().length === 0 ||
|
|
145
|
+
correlationId.length > MAX_CORRELATION_ID_LEN
|
|
146
|
+
) {
|
|
147
|
+
return { index, reason: "correlation_id_invalid" };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const seq = e.seq;
|
|
151
|
+
if (typeof seq !== "number" || !Number.isInteger(seq) || seq < 0) {
|
|
152
|
+
return { index, reason: "seq_invalid" };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const event: StampedInteractionEvent = {
|
|
156
|
+
type: "click",
|
|
157
|
+
el,
|
|
158
|
+
action: asNullableString(e.action),
|
|
159
|
+
placement: asNullableString(e.placement),
|
|
160
|
+
scope: asNullableString(e.scope),
|
|
161
|
+
index: idx,
|
|
162
|
+
ts,
|
|
163
|
+
correlationId,
|
|
164
|
+
seq,
|
|
165
|
+
// Server-stamped, never from the client.
|
|
166
|
+
tenant_id: tenantId,
|
|
167
|
+
stream: INTERACTION_STREAM,
|
|
168
|
+
ingested_at: ingestedAt,
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
return { event, droppedKeys };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Validate + stamp a whole posted batch under the authenticated tenant. */
|
|
175
|
+
export function ingestInteractions(
|
|
176
|
+
events: unknown[],
|
|
177
|
+
tenantId: string,
|
|
178
|
+
ingestedAt: string,
|
|
179
|
+
): InteractionIngestOutcome {
|
|
180
|
+
const accepted: AcceptedInteraction[] = [];
|
|
181
|
+
const rejected: InteractionRejection[] = [];
|
|
182
|
+
events.forEach((raw, i) => {
|
|
183
|
+
const r = ingestInteraction(i, raw, tenantId, ingestedAt);
|
|
184
|
+
if ("event" in r) accepted.push(r);
|
|
185
|
+
else rejected.push(r);
|
|
186
|
+
});
|
|
187
|
+
return { accepted, rejected };
|
|
188
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare D1 control + data plane over the REST API — how a PER-TENANT D1
|
|
3
|
+
* database is created and queried from inside the Worker.
|
|
4
|
+
*
|
|
5
|
+
* WHY REST AND NOT A BINDING. Worker bindings are STATIC: `wrangler.toml`
|
|
6
|
+
* declares a fixed set of `[[d1_databases]]` at build time, so there is no
|
|
7
|
+
* `env.lookupD1(name)` and no way for a request to bind a database that did not
|
|
8
|
+
* exist when the Worker was built. One database per tenant is therefore only
|
|
9
|
+
* reachable through the D1 HTTP API (`/accounts/:account/d1/database/...`),
|
|
10
|
+
* which addresses a database by UUID at call time. The shared bindings
|
|
11
|
+
* (`CATALOG_DB`, `STOREFRONT_APPS_DB`) stay exactly as they are; this module is
|
|
12
|
+
* additive and used ONLY for the per-tenant raw-interaction stores.
|
|
13
|
+
*
|
|
14
|
+
* CREDENTIALS. `CF_ACCOUNT_ID` + `TRACKING_D1_API_TOKEN` (a `wrangler secret`,
|
|
15
|
+
* scoped to D1:edit). Either one absent ⇒ `readD1Control()` returns undefined
|
|
16
|
+
* and every caller treats that as "sink unavailable, skip it" — never an error,
|
|
17
|
+
* the same posture `readAnalytics` + `activity/store.ts` already establish for
|
|
18
|
+
* an unbound sink. Nothing here ever throws to the caller.
|
|
19
|
+
*
|
|
20
|
+
* The query adapter satisfies the same `Queryable` seam the binding-backed
|
|
21
|
+
* stores use (`d1/catalog.ts`), so the insert code in `interactionStore.ts` is
|
|
22
|
+
* transport-agnostic and unit-testable against an in-memory sqlite.
|
|
23
|
+
*/
|
|
24
|
+
import { readEnv } from "../env.js";
|
|
25
|
+
import type { Queryable, QueryStatement } from "../d1/catalog.js";
|
|
26
|
+
|
|
27
|
+
const CF_API_BASE = "https://api.cloudflare.com/client/v4";
|
|
28
|
+
|
|
29
|
+
/** Resolved REST credentials for the D1 API. */
|
|
30
|
+
export interface D1ControlConfig {
|
|
31
|
+
accountId: string;
|
|
32
|
+
apiToken: string;
|
|
33
|
+
/** Injected for tests; defaults to the global `fetch`. */
|
|
34
|
+
fetchImpl?: typeof fetch;
|
|
35
|
+
/** Injected for tests; defaults to the public Cloudflare API. */
|
|
36
|
+
apiBase?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Read the D1 REST credentials from the runtime env. Undefined when either is
|
|
41
|
+
* unset — i.e. the per-tenant store is not configured in this environment
|
|
42
|
+
* (local `astro dev`, an un-provisioned tier). Callers SKIP, never fail.
|
|
43
|
+
*/
|
|
44
|
+
export async function readD1Control(): Promise<D1ControlConfig | undefined> {
|
|
45
|
+
const [accountId, apiToken] = await Promise.all([
|
|
46
|
+
readEnv("CF_ACCOUNT_ID"),
|
|
47
|
+
readEnv("TRACKING_D1_API_TOKEN"),
|
|
48
|
+
]);
|
|
49
|
+
if (!accountId || !apiToken) return undefined;
|
|
50
|
+
return { accountId, apiToken };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface CfEnvelope<T> {
|
|
54
|
+
success?: boolean;
|
|
55
|
+
result?: T;
|
|
56
|
+
errors?: Array<{ code?: number; message?: string }>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function cfRequest<T>(
|
|
60
|
+
cfg: D1ControlConfig,
|
|
61
|
+
path: string,
|
|
62
|
+
init?: { method?: string; body?: unknown },
|
|
63
|
+
): Promise<CfEnvelope<T>> {
|
|
64
|
+
const doFetch = cfg.fetchImpl ?? fetch;
|
|
65
|
+
const res = await doFetch(`${cfg.apiBase ?? CF_API_BASE}/accounts/${cfg.accountId}${path}`, {
|
|
66
|
+
method: init?.method ?? "GET",
|
|
67
|
+
headers: {
|
|
68
|
+
authorization: `Bearer ${cfg.apiToken}`,
|
|
69
|
+
"content-type": "application/json",
|
|
70
|
+
},
|
|
71
|
+
body: init?.body === undefined ? undefined : JSON.stringify(init.body),
|
|
72
|
+
});
|
|
73
|
+
const parsed = (await res.json()) as CfEnvelope<T>;
|
|
74
|
+
if (!res.ok || parsed.success === false) {
|
|
75
|
+
const detail = parsed.errors?.map((e) => e.message).filter(Boolean).join("; ") || `HTTP ${res.status}`;
|
|
76
|
+
throw new Error(`d1 api ${path} failed: ${detail}`);
|
|
77
|
+
}
|
|
78
|
+
return parsed;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface D1DatabaseSummary {
|
|
82
|
+
uuid?: string;
|
|
83
|
+
name?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The UUID of the D1 database with this exact name, or null if none exists. */
|
|
87
|
+
export async function findDatabaseByName(
|
|
88
|
+
cfg: D1ControlConfig,
|
|
89
|
+
name: string,
|
|
90
|
+
): Promise<string | null> {
|
|
91
|
+
const body = await cfRequest<D1DatabaseSummary[]>(
|
|
92
|
+
cfg,
|
|
93
|
+
`/d1/database?name=${encodeURIComponent(name)}&per_page=100`,
|
|
94
|
+
);
|
|
95
|
+
// `?name=` is a SUBSTRING filter on Cloudflare's side, so match exactly here —
|
|
96
|
+
// otherwise `…-dt-acme` could resolve to `…-dt-acme-staging` and route one
|
|
97
|
+
// tenant's rows into another tenant's database.
|
|
98
|
+
const exact = (body.result ?? []).find((d) => d.name === name);
|
|
99
|
+
return exact?.uuid ?? null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Create a D1 database and return its UUID. */
|
|
103
|
+
export async function createDatabase(cfg: D1ControlConfig, name: string): Promise<string> {
|
|
104
|
+
const body = await cfRequest<D1DatabaseSummary>(cfg, "/d1/database", {
|
|
105
|
+
method: "POST",
|
|
106
|
+
body: { name },
|
|
107
|
+
});
|
|
108
|
+
const uuid = body.result?.uuid;
|
|
109
|
+
if (!uuid) throw new Error(`d1 api create ${name} returned no uuid`);
|
|
110
|
+
return uuid;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Find-or-create, tolerant of a concurrent creator: two beacons for a brand-new
|
|
115
|
+
* tenant can race here, and the loser's `create` fails with "already exists" —
|
|
116
|
+
* which is a SUCCESS for our purposes, so we re-resolve by name instead of
|
|
117
|
+
* propagating it.
|
|
118
|
+
*/
|
|
119
|
+
export async function ensureDatabase(cfg: D1ControlConfig, name: string): Promise<string> {
|
|
120
|
+
const existing = await findDatabaseByName(cfg, name);
|
|
121
|
+
if (existing) return existing;
|
|
122
|
+
try {
|
|
123
|
+
return await createDatabase(cfg, name);
|
|
124
|
+
} catch (err) {
|
|
125
|
+
const raced = await findDatabaseByName(cfg, name);
|
|
126
|
+
if (raced) return raced;
|
|
127
|
+
throw err;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
interface D1QueryResult {
|
|
132
|
+
results?: unknown[];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** POST one (or a semicolon-separated script of) statement(s) to a database. */
|
|
136
|
+
async function query(
|
|
137
|
+
cfg: D1ControlConfig,
|
|
138
|
+
databaseUuid: string,
|
|
139
|
+
sql: string,
|
|
140
|
+
params: readonly unknown[],
|
|
141
|
+
): Promise<unknown[]> {
|
|
142
|
+
const body = await cfRequest<D1QueryResult[]>(cfg, `/d1/database/${databaseUuid}/query`, {
|
|
143
|
+
method: "POST",
|
|
144
|
+
body: { sql, params },
|
|
145
|
+
});
|
|
146
|
+
// One entry per statement; flatten to the rows of the LAST statement, which is
|
|
147
|
+
// what a single-statement `all`/`first` caller means.
|
|
148
|
+
const blocks = body.result ?? [];
|
|
149
|
+
return (blocks[blocks.length - 1]?.results ?? []) as unknown[];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Adapt a per-tenant database (addressed by UUID over REST) to the same
|
|
154
|
+
* `Queryable` seam the D1 bindings use. `batch` is emulated as a
|
|
155
|
+
* semicolon-joined script in ONE HTTP call — D1's `/query` runs a multi-
|
|
156
|
+
* statement script inside one implicit transaction, which is what the binding's
|
|
157
|
+
* `batch()` guarantees too.
|
|
158
|
+
*/
|
|
159
|
+
export function fromD1Rest(cfg: D1ControlConfig, databaseUuid: string): Queryable {
|
|
160
|
+
return {
|
|
161
|
+
all: async <T>(sql: string, ...params: unknown[]) =>
|
|
162
|
+
(await query(cfg, databaseUuid, sql, params)) as T[],
|
|
163
|
+
first: async <T>(sql: string, ...params: unknown[]) =>
|
|
164
|
+
(((await query(cfg, databaseUuid, sql, params))[0] ?? null) as T | null),
|
|
165
|
+
run: async (sql: string, ...params: unknown[]) => {
|
|
166
|
+
await query(cfg, databaseUuid, sql, params);
|
|
167
|
+
},
|
|
168
|
+
exec: async (sql: string) => {
|
|
169
|
+
await query(cfg, databaseUuid, sql, []);
|
|
170
|
+
},
|
|
171
|
+
batch: async (statements: readonly QueryStatement[]) => {
|
|
172
|
+
if (statements.length === 0) return;
|
|
173
|
+
// The REST `params` array is positional across the whole script, so the
|
|
174
|
+
// placeholders of every statement bind in order.
|
|
175
|
+
const sql = statements.map((s) => s.sql.trim().replace(/;$/, "")).join(";\n");
|
|
176
|
+
const params = statements.flatMap((s) => [...(s.params ?? [])]);
|
|
177
|
+
await query(cfg, databaseUuid, sql, params);
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dt-collector-v1 — the browser-side delivery destination for
|
|
3
|
+
* `TotInteraction` (`@tot/public-runtime`'s declarative interaction-tracking
|
|
4
|
+
* contract). This module OWNS batching + transport; it does not install the
|
|
5
|
+
* click listener and does not gate on consent/environment — those are
|
|
6
|
+
* `interaction-tracking.ts` and `dt-consent-environment-gate` respectively.
|
|
7
|
+
*
|
|
8
|
+
* Wiring (owned by whichever module composes the gate in front of delivery,
|
|
9
|
+
* e.g. `dt-consent-environment-gate`):
|
|
10
|
+
*
|
|
11
|
+
* import { onInteraction } from "@tot/public-runtime";
|
|
12
|
+
* import { collectInteraction } from "@/lib/tracking/interactionCollector";
|
|
13
|
+
* onInteraction((interaction) => {
|
|
14
|
+
* if (consentAndEnvironmentAllow()) collectInteraction(interaction);
|
|
15
|
+
* });
|
|
16
|
+
*
|
|
17
|
+
* or, where the caller controls install directly:
|
|
18
|
+
*
|
|
19
|
+
* installInteractionTracking({ deliver: collectInteraction });
|
|
20
|
+
*
|
|
21
|
+
* PAYLOAD GUARDRAIL: this module reads only the five `TotInteraction` fields
|
|
22
|
+
* handed to it, plus what it synthesizes itself (`ts`, `correlationId`,
|
|
23
|
+
* `seq`). No DOM access, no `textContent`/`href`/form reads — same prohibition
|
|
24
|
+
* as `interaction-tracking.ts`'s listener; see that module's guardrail test
|
|
25
|
+
* (`tests/interaction-tracking.test.ts`) for the pattern this module must not
|
|
26
|
+
* violate either.
|
|
27
|
+
*/
|
|
28
|
+
import type { TotInteraction } from "@tot/public-runtime";
|
|
29
|
+
|
|
30
|
+
/** Where the browser beacon POSTs interaction batches. */
|
|
31
|
+
export const INTERACTION_BEACON_PATH = "/api/tracking/interaction";
|
|
32
|
+
|
|
33
|
+
/** Max events held in memory before a forced flush — bounds one beacon's size
|
|
34
|
+
* and the worst case if flush triggers never fire (e.g. a test/SSR context). */
|
|
35
|
+
export const MAX_QUEUE_SIZE = 20;
|
|
36
|
+
|
|
37
|
+
/** The wire shape POSTed to `INTERACTION_BEACON_PATH` — `TotInteraction`'s five
|
|
38
|
+
* fields plus this module's own synthesized correlation fields. Must stay in
|
|
39
|
+
* lockstep with `INTERACTION_WIRE_ALLOWLIST`
|
|
40
|
+
* (`apps/storefront/src/lib/tracking/collectorAllowlist.ts`). */
|
|
41
|
+
export interface InteractionWireEvent {
|
|
42
|
+
type: TotInteraction["type"];
|
|
43
|
+
el: string;
|
|
44
|
+
action: string | null;
|
|
45
|
+
placement: string | null;
|
|
46
|
+
scope: string | null;
|
|
47
|
+
index: number | null;
|
|
48
|
+
/** Client emit clock, ISO-8601. */
|
|
49
|
+
ts: string;
|
|
50
|
+
/** Groups every event from one collector lifetime (one page load). */
|
|
51
|
+
correlationId: string;
|
|
52
|
+
/** Monotonic within `correlationId`, starting at 0. */
|
|
53
|
+
seq: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Build the wire event for one interaction. Pure — no DOM, no clock side
|
|
57
|
+
* effects beyond the `now` parameter, so it is unit-testable without faking
|
|
58
|
+
* globals. */
|
|
59
|
+
export function toWireEvent(
|
|
60
|
+
interaction: TotInteraction,
|
|
61
|
+
correlationId: string,
|
|
62
|
+
seq: number,
|
|
63
|
+
now: () => string = () => new Date().toISOString(),
|
|
64
|
+
): InteractionWireEvent {
|
|
65
|
+
return {
|
|
66
|
+
type: interaction.type,
|
|
67
|
+
el: interaction.el,
|
|
68
|
+
action: interaction.action,
|
|
69
|
+
placement: interaction.placement,
|
|
70
|
+
scope: interaction.scope,
|
|
71
|
+
index: interaction.index,
|
|
72
|
+
ts: now(),
|
|
73
|
+
correlationId,
|
|
74
|
+
seq,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A sink for a finished batch. The default posts one JSON beacon. */
|
|
79
|
+
export type InteractionBatchSender = (events: InteractionWireEvent[]) => void;
|
|
80
|
+
|
|
81
|
+
/** Default sender: one `sendBeacon` per flushed batch, falling back to a
|
|
82
|
+
* keepalive `fetch` where `sendBeacon` is absent (mirrors
|
|
83
|
+
* `analytics/webVitals.ts`'s `beaconReporter`). Never throws into the page. */
|
|
84
|
+
export function beaconBatchSender(endpoint: string = INTERACTION_BEACON_PATH): InteractionBatchSender {
|
|
85
|
+
return (events) => {
|
|
86
|
+
if (events.length === 0 || typeof navigator === "undefined") return;
|
|
87
|
+
const body = JSON.stringify({ events });
|
|
88
|
+
try {
|
|
89
|
+
if (typeof navigator.sendBeacon === "function") {
|
|
90
|
+
navigator.sendBeacon(endpoint, body);
|
|
91
|
+
} else if (typeof fetch === "function") {
|
|
92
|
+
void fetch(endpoint, { method: "POST", body, keepalive: true });
|
|
93
|
+
}
|
|
94
|
+
} catch {
|
|
95
|
+
// Never let telemetry throw into the page.
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function randomCorrelationId(): string {
|
|
101
|
+
const c: Crypto | undefined = (globalThis as { crypto?: Crypto }).crypto;
|
|
102
|
+
if (c?.randomUUID) return c.randomUUID();
|
|
103
|
+
return `ic-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* A collector instance: batches interactions in memory and flushes them to
|
|
108
|
+
* `send` on demand, on queue overflow, or when the caller wires
|
|
109
|
+
* `flush`-on-`visibilitychange`/`pagehide` (see {@link installFlushTriggers}).
|
|
110
|
+
* Exposed as a factory (rather than one module-level singleton) so tests can
|
|
111
|
+
* construct an isolated instance with a fake sender and clock.
|
|
112
|
+
*/
|
|
113
|
+
export interface InteractionCollector {
|
|
114
|
+
/** Enqueue one interaction; synthesizes `ts`/`correlationId`/`seq`. Flushes
|
|
115
|
+
* immediately once the queue reaches `MAX_QUEUE_SIZE`. */
|
|
116
|
+
collect(interaction: TotInteraction): void;
|
|
117
|
+
/** Send whatever is queued right now (no-op if empty). */
|
|
118
|
+
flush(): void;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function createInteractionCollector(
|
|
122
|
+
send: InteractionBatchSender = beaconBatchSender(),
|
|
123
|
+
correlationId: string = randomCorrelationId(),
|
|
124
|
+
now?: () => string,
|
|
125
|
+
): InteractionCollector {
|
|
126
|
+
let queue: InteractionWireEvent[] = [];
|
|
127
|
+
let seq = 0;
|
|
128
|
+
|
|
129
|
+
const flush = (): void => {
|
|
130
|
+
if (queue.length === 0) return;
|
|
131
|
+
const batch = queue;
|
|
132
|
+
queue = [];
|
|
133
|
+
send(batch);
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const collect = (interaction: TotInteraction): void => {
|
|
137
|
+
queue.push(toWireEvent(interaction, correlationId, seq, now));
|
|
138
|
+
seq += 1;
|
|
139
|
+
if (queue.length >= MAX_QUEUE_SIZE) flush();
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
return { collect, flush };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
// Browser wiring (thin — everything above is pure + unit-tested).
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Wire `collector.flush` to the two reliable end-of-visibility moments (same
|
|
151
|
+
* pair `analytics/webVitals.ts#startWebVitals` uses): the page first going
|
|
152
|
+
* hidden, and `pagehide` — the last point mobile Safari guarantees a beacon
|
|
153
|
+
* can still be sent. No-op teardown when there is no `document`/`window`
|
|
154
|
+
* (SSR, tests).
|
|
155
|
+
*/
|
|
156
|
+
export function installFlushTriggers(collector: InteractionCollector): () => void {
|
|
157
|
+
if (typeof document === "undefined" || typeof window === "undefined") return () => {};
|
|
158
|
+
const onVisibility = (): void => {
|
|
159
|
+
if (document.visibilityState === "hidden") collector.flush();
|
|
160
|
+
};
|
|
161
|
+
document.addEventListener("visibilitychange", onVisibility, { capture: true });
|
|
162
|
+
window.addEventListener("pagehide", collector.flush, { capture: true });
|
|
163
|
+
return () => {
|
|
164
|
+
document.removeEventListener("visibilitychange", onVisibility, { capture: true });
|
|
165
|
+
window.removeEventListener("pagehide", collector.flush, { capture: true });
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
let defaultCollector: InteractionCollector | null = null;
|
|
170
|
+
let defaultTeardown: (() => void) | null = null;
|
|
171
|
+
|
|
172
|
+
function getDefaultCollector(): InteractionCollector {
|
|
173
|
+
if (!defaultCollector) {
|
|
174
|
+
defaultCollector = createInteractionCollector();
|
|
175
|
+
defaultTeardown = installFlushTriggers(defaultCollector);
|
|
176
|
+
}
|
|
177
|
+
return defaultCollector;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The default delivery destination: `installInteractionTracking({ deliver:
|
|
182
|
+
* collectInteraction })` or forward into it from an `onInteraction` subscriber
|
|
183
|
+
* (that is what the consent/environment gate does). Lazily creates the
|
|
184
|
+
* module-level collector and its flush wiring on first call, so importing
|
|
185
|
+
* this module has no side effects until an interaction actually arrives.
|
|
186
|
+
*/
|
|
187
|
+
export function collectInteraction(interaction: TotInteraction): void {
|
|
188
|
+
getDefaultCollector().collect(interaction);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Force-flush the default collector (e.g. from a manual "send now" hook). */
|
|
192
|
+
export function flushInteractions(): void {
|
|
193
|
+
defaultCollector?.flush();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Test-only: drop the module-level singleton so a fresh collector/wiring is
|
|
197
|
+
* created on the next {@link collectInteraction} call. */
|
|
198
|
+
export function resetDefaultCollectorForTests(): void {
|
|
199
|
+
defaultTeardown?.();
|
|
200
|
+
defaultCollector = null;
|
|
201
|
+
defaultTeardown = null;
|
|
202
|
+
}
|