@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,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dt-id-writeback — turns {@link deriveTrackingWriteback} proposals for one or
|
|
3
|
+
* more tenant body documents into the SAME reviewable-candidate shape the admin
|
|
4
|
+
* Blog tab already rides (`pages/api/admin/blog.ts`): a content-only
|
|
5
|
+
* {@link FileChange} patch opened via `ChangeClient.openCandidate`, so a human
|
|
6
|
+
* reviews a real PR diff and accepts it — never a silent runtime mutation.
|
|
7
|
+
*
|
|
8
|
+
* This module is pure (no MCP call, no ChangeClient dependency) so the patch +
|
|
9
|
+
* PR body construction is unit-testable without a live forge; the route that
|
|
10
|
+
* calls `openCandidate` is a thin wrapper (see the sibling API route).
|
|
11
|
+
*/
|
|
12
|
+
import { deriveTrackingWriteback, type TrackingWritebackProposal } from "./rewriteTrackingIds.js";
|
|
13
|
+
import type { DerivationOptions } from "@tot/public-runtime";
|
|
14
|
+
|
|
15
|
+
/** One tenant body document to scan — repo-relative path + its current content. */
|
|
16
|
+
export interface WritebackSourceFile {
|
|
17
|
+
/** e.g. "content/home.html" or "content/acme/pages-html/pricing.html". */
|
|
18
|
+
path: string;
|
|
19
|
+
content: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** One file this writeback pass actually changed, plus its full proposal list. */
|
|
23
|
+
export interface WritebackFileResult {
|
|
24
|
+
path: string;
|
|
25
|
+
content: string;
|
|
26
|
+
proposals: readonly TrackingWritebackProposal[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface WritebackCandidatePlan {
|
|
30
|
+
/** Files that gained at least one new `data-tot-el` — the `FileChange` patch. */
|
|
31
|
+
patch: { path: string; content: string }[];
|
|
32
|
+
/** Every changed file's full proposal list, for the PR body + a caller's own reporting. */
|
|
33
|
+
files: WritebackFileResult[];
|
|
34
|
+
/** True iff `patch` is non-empty — nothing to open when false. */
|
|
35
|
+
hasChanges: boolean;
|
|
36
|
+
/** Count of `needsReview: true` rows across every changed file — the review flag. */
|
|
37
|
+
needsReviewCount: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Run the derivation writeback over every source file and assemble the patch a
|
|
42
|
+
* candidate PR would carry. Files with no undeclared trackable elements (already
|
|
43
|
+
* fully migrated, or nothing trackable) are omitted from `patch` entirely — this
|
|
44
|
+
* never proposes a no-op change.
|
|
45
|
+
*/
|
|
46
|
+
export function planTrackingWriteback(
|
|
47
|
+
sources: readonly WritebackSourceFile[],
|
|
48
|
+
options?: DerivationOptions,
|
|
49
|
+
): WritebackCandidatePlan {
|
|
50
|
+
const files: WritebackFileResult[] = [];
|
|
51
|
+
const patch: { path: string; content: string }[] = [];
|
|
52
|
+
let needsReviewCount = 0;
|
|
53
|
+
|
|
54
|
+
for (const source of sources) {
|
|
55
|
+
const result = deriveTrackingWriteback(source.content, options);
|
|
56
|
+
if (!result.changed) continue;
|
|
57
|
+
files.push({ path: source.path, content: result.html, proposals: result.proposals });
|
|
58
|
+
patch.push({ path: source.path, content: result.html });
|
|
59
|
+
needsReviewCount += result.proposals.filter((p) => p.needsReview && !p.alreadyDeclared).length;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { patch, files, hasChanges: patch.length > 0, needsReviewCount };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Render the candidate PR's body: a per-file listing of every NEWLY-assigned
|
|
67
|
+
* `data-tot-el`, with `needsReview: true` rows called out in their own section
|
|
68
|
+
* (per the derivation engine's own design intent — a positional id is the one a
|
|
69
|
+
* human should rename before accepting). `signals` is the diff annotation.
|
|
70
|
+
*/
|
|
71
|
+
export function formatWritebackReviewBody(plan: WritebackCandidatePlan): string {
|
|
72
|
+
const lines: string[] = [
|
|
73
|
+
"Derived `data-tot-el` identity for tenant-authored body elements that had none.",
|
|
74
|
+
"`confidence: \"declared\"` elements are untouched (never overwritten).",
|
|
75
|
+
"",
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
const needsReview = plan.files.flatMap((f) =>
|
|
79
|
+
f.proposals
|
|
80
|
+
.filter((p) => p.needsReview && !p.alreadyDeclared)
|
|
81
|
+
.map((p) => ({ file: f.path, proposal: p })),
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
if (needsReview.length > 0) {
|
|
85
|
+
lines.push(
|
|
86
|
+
`## ⚠ Needs review — ${needsReview.length} element${needsReview.length === 1 ? "" : "s"} named by position`,
|
|
87
|
+
"",
|
|
88
|
+
"These derived ids leaned on sibling order (no author-chosen name, class, or link found). " +
|
|
89
|
+
"Rename them to something meaningful before accepting.",
|
|
90
|
+
"",
|
|
91
|
+
);
|
|
92
|
+
for (const { file, proposal } of needsReview) {
|
|
93
|
+
lines.push(`- \`${file}\`: \`${proposal.el}\` — ${proposal.snippet} _(${proposal.signals.join(", ")})_`);
|
|
94
|
+
}
|
|
95
|
+
lines.push("");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
for (const file of plan.files) {
|
|
99
|
+
const assigned = file.proposals.filter((p) => !p.alreadyDeclared);
|
|
100
|
+
if (assigned.length === 0) continue;
|
|
101
|
+
lines.push(`## \`${file.path}\` — ${assigned.length} id${assigned.length === 1 ? "" : "s"} assigned`, "");
|
|
102
|
+
for (const p of assigned) {
|
|
103
|
+
const flag = p.needsReview ? "⚠ " : "";
|
|
104
|
+
lines.push(`- ${flag}\`${p.el}\` (${p.confidence}) — ${p.snippet} _(${p.signals.join(", ")})_`);
|
|
105
|
+
}
|
|
106
|
+
lines.push("");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return lines.join("\n").trimEnd() + "\n";
|
|
110
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GET /api/admin/interaction-insights` — dt-tenant-isolation. The first
|
|
3
|
+
* merchant-visible read over the per-tenant declarative-interaction D1
|
|
4
|
+
* databases `dt-storage-per-tenant-d1` built. The release-order doc's hard
|
|
5
|
+
* invariant — "no merchant-visible surface ships until dt-tenant-isolation
|
|
6
|
+
* lands" — is this route: it is the read authorization layer, not a rebuild
|
|
7
|
+
* of the storage layer underneath it.
|
|
8
|
+
*
|
|
9
|
+
* AUTH mirrors `/api/admin/activity-alerts` and every other tenant-scoped
|
|
10
|
+
* admin JSON route: `requireAdminApiEntry` — fail-closed, bound to
|
|
11
|
+
* `Astro.locals.tenant` (Host-resolved, never client input). The tenant this
|
|
12
|
+
* route reads for is `context.locals.tenant.tenant_id`, ALWAYS — there is no
|
|
13
|
+
* code path here that reads a tenant id from the query string, the body, or
|
|
14
|
+
* any header, so a malicious/malformed request cannot make this route open a
|
|
15
|
+
* different tenant's database no matter what it sends.
|
|
16
|
+
*
|
|
17
|
+
* ISOLATION — `resolveTenantStore` (dt-storage-per-tenant-d1) returns a
|
|
18
|
+
* handle bound to exactly one tenant's database UUID; `assertStoreOwnership`
|
|
19
|
+
* (dt-tenant-isolation, `tenantReadAccess.ts`) additionally confirms the
|
|
20
|
+
* resolved store's own `interaction_store_meta` row names THIS tenant before
|
|
21
|
+
* any row is read. Every query in `tenantReadAccess.ts` runs against that one
|
|
22
|
+
* scoped handle — never a query spanning tenants.
|
|
23
|
+
*
|
|
24
|
+
* No cross-tenant ADMIN/operator path is added here. There is no concrete
|
|
25
|
+
* near-term consumer for "ToT ops views any tenant's interaction data" (the
|
|
26
|
+
* existing staff cross-vendor path — `staffAdmits`/`enter-vendor` — grants
|
|
27
|
+
* DASHBOARD entry to a vendor, not a bypass of this route's own tenant
|
|
28
|
+
* scoping) — see the unit's completion report for the reasoning.
|
|
29
|
+
*/
|
|
30
|
+
import type { APIContext } from "astro";
|
|
31
|
+
import { requireAdminApiEntry } from "@/lib/auth/adminApiGuard";
|
|
32
|
+
import { readD1 } from "@/lib/env";
|
|
33
|
+
import { fromD1 } from "@/lib/d1/catalog";
|
|
34
|
+
import { resolveTenantStore, trackingNamePrefix } from "@/lib/tracking/tenantD1Registry";
|
|
35
|
+
import { readD1Control } from "@/lib/tracking/d1Control";
|
|
36
|
+
import { readTenantInteractionInsights } from "@/lib/tracking/tenantReadAccess";
|
|
37
|
+
|
|
38
|
+
export const prerender = false;
|
|
39
|
+
|
|
40
|
+
const JSON_HEADERS = {
|
|
41
|
+
"content-type": "application/json; charset=utf-8",
|
|
42
|
+
"cache-control": "no-store",
|
|
43
|
+
"x-robots-tag": "noindex, nofollow",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** Query-string cap on the recent-events feed; clamped again in tenantReadAccess. */
|
|
47
|
+
const MAX_LIMIT = 200;
|
|
48
|
+
|
|
49
|
+
function json(body: unknown, status = 200): Response {
|
|
50
|
+
return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function GET(context: APIContext): Promise<Response> {
|
|
54
|
+
const access = await requireAdminApiEntry(context);
|
|
55
|
+
if (!access.ok) return json({ ok: false, reason: access.reason }, access.status);
|
|
56
|
+
|
|
57
|
+
// The ONLY tenant id this route ever uses. Host-resolved by tenant-routing
|
|
58
|
+
// middleware, never taken from `context.request` — see the module header.
|
|
59
|
+
const tenantId = context.locals.tenant?.tenant_id;
|
|
60
|
+
if (!tenantId) return json({ ok: false, status: "no_tenant", message: "no store resolved for this request" }, 400);
|
|
61
|
+
|
|
62
|
+
const rawLimit = Number(new URL(context.request.url).searchParams.get("limit") ?? "");
|
|
63
|
+
const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? Math.min(rawLimit, MAX_LIMIT) : undefined;
|
|
64
|
+
|
|
65
|
+
const [appsD1, control, namePrefix] = await Promise.all([
|
|
66
|
+
readD1("STOREFRONT_APPS_DB"),
|
|
67
|
+
readD1Control(),
|
|
68
|
+
trackingNamePrefix(),
|
|
69
|
+
]);
|
|
70
|
+
if (!appsD1 || !control) {
|
|
71
|
+
// Sink not provisioned in this environment (local dev, an un-provisioned
|
|
72
|
+
// tier) — best-effort, same posture as activity-alerts' dbAvailable:false.
|
|
73
|
+
return json({ ok: true, dbAvailable: false, totalEvents: 0, byAction: [], recent: [] });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const store = await resolveTenantStore(tenantId, {
|
|
77
|
+
apps: fromD1(appsD1),
|
|
78
|
+
control,
|
|
79
|
+
namePrefix,
|
|
80
|
+
at: new Date().toISOString(),
|
|
81
|
+
});
|
|
82
|
+
if (!store) {
|
|
83
|
+
return json({ ok: true, dbAvailable: false, totalEvents: 0, byAction: [], recent: [] });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const insights = await readTenantInteractionInsights(store, { limit });
|
|
87
|
+
if (!insights) {
|
|
88
|
+
// Ownership assertion failed — fail-closed, never serve a store we could
|
|
89
|
+
// not verify belongs to this tenant.
|
|
90
|
+
return json({ ok: false, status: "store_unverified", message: "could not verify tenant store ownership" }, 503);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return json({
|
|
94
|
+
ok: true,
|
|
95
|
+
dbAvailable: true,
|
|
96
|
+
tenantId,
|
|
97
|
+
totalEvents: insights.totalEvents,
|
|
98
|
+
byAction: insights.byAction,
|
|
99
|
+
recent: insights.recent,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/tracking/interaction (dt-collector-v1) — the beacon sink for
|
|
3
|
+
* declarative interaction tracking.
|
|
4
|
+
*
|
|
5
|
+
* The browser collector (`@/lib/tracking/interactionCollector`) batches
|
|
6
|
+
* `TotInteraction`s captured off `installInteractionTracking` /
|
|
7
|
+
* `onInteraction` (`@tot/public-runtime`) — via `dt-consent-environment-gate`'s
|
|
8
|
+
* consent/environment wrapper in front of it — and POSTs them here with
|
|
9
|
+
* `navigator.sendBeacon`, same transport contract as the F14 RUM beacon
|
|
10
|
+
* (`pages/api/rum/vitals.ts`): unauthenticated (a beacon carries no
|
|
11
|
+
* credentials), tenant resolved from `context.locals.tenant.tenant_id`
|
|
12
|
+
* (never the body), 204 on every outcome so a beacon never errors, retries,
|
|
13
|
+
* or blocks page unload.
|
|
14
|
+
*
|
|
15
|
+
* VALIDATION — `collectorIngest.ts` re-derives every field from the raw body
|
|
16
|
+
* under a default-deny allowlist (`collectorAllowlist.ts`): an unlisted key is
|
|
17
|
+
* dropped and counted, a listed-but-wrong-typed field rejects that event. Never
|
|
18
|
+
* trust the client for `tenant_id` or `stream` — both are stamped here, always.
|
|
19
|
+
*
|
|
20
|
+
* STORAGE — two sinks, fanned out past the response (`interactionStore.ts`,
|
|
21
|
+
* same shape as `pages/api/activity.ts`'s `fanOutActivity`):
|
|
22
|
+
*
|
|
23
|
+
* • RAW ROWS → the tenant's OWN D1 database, resolved (and provisioned on
|
|
24
|
+
* first sight) by `tenantD1Registry.resolveTenantStore`. ONE DATABASE PER
|
|
25
|
+
* TENANT, so cross-tenant leakage is impossible by construction, not by a
|
|
26
|
+
* correct `WHERE tenant_id = ?`. The directory that maps tenant → database
|
|
27
|
+
* lives in the shared `STOREFRONT_APPS_DB`
|
|
28
|
+
* (`tracking_tenant_databases`); the rows never do.
|
|
29
|
+
* • AGGREGATES → the DEDICATED `INTERACTION_ANALYTICS` Analytics Engine
|
|
30
|
+
* dataset (`storefront_interactions`) — NOT the operational spine's
|
|
31
|
+
* `storefront_activity` dataset — rolled up to one point per dimension
|
|
32
|
+
* tuple, so no per-event identity reaches AE.
|
|
33
|
+
*
|
|
34
|
+
* Both sinks are best-effort: unconfigured or failing, the batch falls back to
|
|
35
|
+
* the Workers Logs firehose and the beacon still 204s. `stream` (always
|
|
36
|
+
* `"interaction"` today) is the discriminator column to route on if a second
|
|
37
|
+
* stream is ever added.
|
|
38
|
+
*/
|
|
39
|
+
import type { APIContext } from "astro";
|
|
40
|
+
import { readD1, readAnalytics, runAfterResponse, type CfExecutionContextLike } from "@/lib/env";
|
|
41
|
+
import { fromD1 } from "@/lib/d1/catalog";
|
|
42
|
+
import { ingestInteractions, type StampedInteractionEvent } from "@/lib/tracking/collectorIngest";
|
|
43
|
+
import { readD1Control } from "@/lib/tracking/d1Control";
|
|
44
|
+
import { resolveTenantStore, trackingNamePrefix } from "@/lib/tracking/tenantD1Registry";
|
|
45
|
+
import { persistInteractions } from "@/lib/tracking/interactionStore";
|
|
46
|
+
import type { AnalyticsEngineLike } from "@/lib/activity/store";
|
|
47
|
+
|
|
48
|
+
export const prerender = false;
|
|
49
|
+
|
|
50
|
+
/** Cap the batch so one beacon can't fan out unbounded off-path work. */
|
|
51
|
+
const MAX_BATCH = 50;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Resolve both sinks and write the batch. Runs PAST the response
|
|
55
|
+
* (`runAfterResponse`), so binding resolution + the D1 REST round-trip never
|
|
56
|
+
* hold the beacon open. Never throws — a telemetry beacon must not fail because
|
|
57
|
+
* storage is unconfigured or unreachable.
|
|
58
|
+
*/
|
|
59
|
+
async function storeInteractions(
|
|
60
|
+
tenantId: string,
|
|
61
|
+
events: StampedInteractionEvent[],
|
|
62
|
+
ingestedAt: string,
|
|
63
|
+
): Promise<void> {
|
|
64
|
+
try {
|
|
65
|
+
const [appsDb, analyticsBinding, control, namePrefix] = await Promise.all([
|
|
66
|
+
readD1("STOREFRONT_APPS_DB"),
|
|
67
|
+
readAnalytics("INTERACTION_ANALYTICS"),
|
|
68
|
+
readD1Control(),
|
|
69
|
+
trackingNamePrefix(),
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
// The raw sink needs BOTH the directory (shared apps D1) and the D1 REST
|
|
73
|
+
// credentials that address a per-tenant database; either absent ⇒ skip it.
|
|
74
|
+
const store =
|
|
75
|
+
appsDb && control
|
|
76
|
+
? await resolveTenantStore(tenantId, {
|
|
77
|
+
apps: fromD1(appsDb),
|
|
78
|
+
control,
|
|
79
|
+
namePrefix,
|
|
80
|
+
at: ingestedAt,
|
|
81
|
+
})
|
|
82
|
+
: undefined;
|
|
83
|
+
|
|
84
|
+
await persistInteractions(events, {
|
|
85
|
+
store,
|
|
86
|
+
analytics: (analyticsBinding as AnalyticsEngineLike | undefined) ?? undefined,
|
|
87
|
+
});
|
|
88
|
+
} catch {
|
|
89
|
+
/* never fail a telemetry beacon */
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function POST(context: APIContext): Promise<Response> {
|
|
94
|
+
try {
|
|
95
|
+
const tenantId = (context.locals as { tenant?: { tenant_id?: string } })?.tenant?.tenant_id;
|
|
96
|
+
// No resolved tenant (e.g. a request that never went through tenant
|
|
97
|
+
// resolution) ⇒ nothing to stamp or store; still 204, never error the beacon.
|
|
98
|
+
if (!tenantId) return new Response(null, { status: 204 });
|
|
99
|
+
|
|
100
|
+
const parsed: unknown = await context.request.json();
|
|
101
|
+
const events = Array.isArray(parsed)
|
|
102
|
+
? parsed
|
|
103
|
+
: parsed && typeof parsed === "object" && Array.isArray((parsed as { events?: unknown }).events)
|
|
104
|
+
? (parsed as { events: unknown[] }).events
|
|
105
|
+
: [];
|
|
106
|
+
if (events.length === 0) return new Response(null, { status: 204 });
|
|
107
|
+
|
|
108
|
+
const batch = events.slice(0, MAX_BATCH);
|
|
109
|
+
const ingestedAt = new Date().toISOString();
|
|
110
|
+
const { accepted } = ingestInteractions(batch, tenantId, ingestedAt);
|
|
111
|
+
|
|
112
|
+
if (accepted.length > 0) {
|
|
113
|
+
const cfContext = (context.locals as { cfContext?: CfExecutionContextLike })?.cfContext ?? null;
|
|
114
|
+
runAfterResponse(
|
|
115
|
+
storeInteractions(tenantId, accepted.map((a) => a.event), ingestedAt),
|
|
116
|
+
cfContext,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
} catch {
|
|
120
|
+
/* never fail a telemetry beacon */
|
|
121
|
+
}
|
|
122
|
+
return new Response(null, { status: 204 });
|
|
123
|
+
}
|
|
@@ -43,6 +43,7 @@ import SiteHeader from "@/components/chrome/SiteHeader.astro";
|
|
|
43
43
|
import MegaMenu from "@/components/chrome/MegaMenu.astro";
|
|
44
44
|
import NavDropdown from "@/components/chrome/NavDropdown.astro";
|
|
45
45
|
import SiteFooter from "@/components/chrome/SiteFooter.astro";
|
|
46
|
+
import { CHROME_PLACEMENT } from "@/lib/chrome/trackingPlacements";
|
|
46
47
|
import NicotineWarning from "@/components/compliance/NicotineWarning.astro";
|
|
47
48
|
import PactActNotice from "@/components/compliance/PactActNotice.astro";
|
|
48
49
|
import AdultSignatureNotice from "@/components/compliance/AdultSignatureNotice.astro";
|
|
@@ -88,28 +89,33 @@ const { tenant, theme } = Astro.params;
|
|
|
88
89
|
const cspNonce = Astro.locals.cspNonce;
|
|
89
90
|
|
|
90
91
|
// --- Sample chrome data (preview-only; no live tenant content) ----------------
|
|
92
|
+
// Every actionable item declares its tracking intent (`action`) alongside its
|
|
93
|
+
// identity (`id`) — the two are different axes and neither is derived from the
|
|
94
|
+
// other. See docs/architecture/interaction-tracking-attribute-contract.md.
|
|
91
95
|
const sampleNav: ChromeNavItem[] = [
|
|
92
|
-
{ id: "nav-new", label: "New", href: "#", flag: true, badge: "NEW" },
|
|
96
|
+
{ id: "nav-new", action: "nav-new", label: "New", href: "#", flag: true, badge: "NEW" },
|
|
93
97
|
{
|
|
94
98
|
id: "nav-shop",
|
|
99
|
+
action: "nav-shop",
|
|
95
100
|
label: "Shop",
|
|
96
101
|
href: "#",
|
|
97
102
|
columns: [
|
|
98
|
-
{ title: "Category", links: [{ id: "nav-shop-disposables", label: "Disposables", href: "#" }, { id: "nav-shop-eliquid", label: "E-Liquid", href: "#" }, { id: "nav-shop-devices", label: "Devices", href: "#" }] },
|
|
99
|
-
{ title: "Featured", links: [{ id: "nav-shop-bestsellers", label: "Best Sellers", href: "#" }, { id: "nav-shop-onsale", label: "On Sale", href: "#", badge: "%" }] },
|
|
100
|
-
{ title: "Brands", links: [{ id: "nav-shop-northwind", label: "Northwind", href: "#" }, { id: "nav-shop-summit", label: "Summit Co.", href: "#" }] },
|
|
103
|
+
{ title: "Category", links: [{ id: "nav-shop-disposables", action: "nav-disposables", label: "Disposables", href: "#" }, { id: "nav-shop-eliquid", action: "nav-eliquid", label: "E-Liquid", href: "#" }, { id: "nav-shop-devices", action: "nav-devices", label: "Devices", href: "#" }] },
|
|
104
|
+
{ title: "Featured", links: [{ id: "nav-shop-bestsellers", action: "nav-bestsellers", label: "Best Sellers", href: "#" }, { id: "nav-shop-onsale", action: "nav-on-sale", label: "On Sale", href: "#", badge: "%" }] },
|
|
105
|
+
{ title: "Brands", links: [{ id: "nav-shop-northwind", action: "nav-brand-northwind", label: "Northwind", href: "#" }, { id: "nav-shop-summit", action: "nav-brand-summit", label: "Summit Co.", href: "#" }] },
|
|
101
106
|
],
|
|
102
107
|
},
|
|
103
|
-
{ id: "nav-coils", label: "Coils & Parts", href: "#", children: [{ id: "nav-coils-coils", label: "Coils", href: "#" }, { id: "nav-coils-pods", label: "Pods", href: "#" }, { id: "nav-coils-batteries", label: "Batteries", href: "#" }] },
|
|
104
|
-
{ id: "nav-sale", label: "On Sale", href: "#" },
|
|
108
|
+
{ id: "nav-coils", action: "nav-coils-and-parts", label: "Coils & Parts", href: "#", children: [{ id: "nav-coils-coils", action: "nav-coils", label: "Coils", href: "#" }, { id: "nav-coils-pods", action: "nav-pods", label: "Pods", href: "#" }, { id: "nav-coils-batteries", action: "nav-batteries", label: "Batteries", href: "#" }] },
|
|
109
|
+
{ id: "nav-sale", action: "nav-on-sale", label: "On Sale", href: "#" },
|
|
105
110
|
];
|
|
106
111
|
const sampleSocial = [
|
|
107
112
|
{ label: "X", href: "#", iconPath: "M18.9 2H22l-7.1 8.1L23 22h-6.6l-5-6.6L5.7 22H2.6l7.6-8.7L1.5 2h6.8l4.5 6 5.1-6z" },
|
|
108
113
|
];
|
|
109
114
|
const sampleFooterCols = [
|
|
110
|
-
|
|
111
|
-
{ title: "
|
|
112
|
-
{ title: "
|
|
115
|
+
// The footer repeats the header's intents: same `action`, different placement.
|
|
116
|
+
{ title: "Shop", links: [{ id: "ftr-shop-disposables", action: "nav-disposables", label: "Disposables", href: "#" }, { id: "ftr-shop-eliquid", action: "nav-eliquid", label: "E-Liquid", href: "#" }, { id: "ftr-shop-devices", action: "nav-devices", label: "Devices", href: "#" }] },
|
|
117
|
+
{ title: "Support", links: [{ id: "ftr-support-contact", action: "nav-contact", label: "Contact", href: "#" }, { id: "ftr-support-shipping", action: "nav-shipping", label: "Shipping", href: "#" }, { id: "ftr-support-returns", action: "nav-returns", label: "Returns", href: "#" }] },
|
|
118
|
+
{ title: "Company", links: [{ id: "ftr-company-about", action: "nav-about", label: "About", href: "#" }, { id: "ftr-company-blog", action: "nav-blog", label: "Blog", href: "#" }] },
|
|
113
119
|
];
|
|
114
120
|
// BrandGrid samples: text-wordmark tiles and logo-image tiles (Img renders
|
|
115
121
|
// deterministic placeholders offline so the image code path is exercised
|
|
@@ -321,12 +327,17 @@ const sampleManageEntries: SubscriptionManageEntry[] = [
|
|
|
321
327
|
// --- Sample islands data (preview-only) ----------------------------------
|
|
322
328
|
const sampleMobileNav = [
|
|
323
329
|
{
|
|
330
|
+
id: "mnav-shop",
|
|
331
|
+
action: "nav-shop",
|
|
324
332
|
label: "Shop",
|
|
325
333
|
href: "#",
|
|
326
|
-
children: [
|
|
334
|
+
children: [
|
|
335
|
+
{ id: "mnav-shop-disposables", action: "nav-disposables", label: "Disposables", href: "#" },
|
|
336
|
+
{ id: "mnav-shop-eliquid", action: "nav-eliquid", label: "E-Liquid", href: "#" },
|
|
337
|
+
],
|
|
327
338
|
},
|
|
328
|
-
{ label: "Coils & Parts", href: "#" },
|
|
329
|
-
{ label: "On Sale", href: "#" },
|
|
339
|
+
{ id: "mnav-coils", action: "nav-coils-and-parts", label: "Coils & Parts", href: "#" },
|
|
340
|
+
{ id: "mnav-sale", action: "nav-on-sale", label: "On Sale", href: "#" },
|
|
330
341
|
];
|
|
331
342
|
const sampleFromProduct = sampleProducts.find((p) => p.handle === "sample-from")!;
|
|
332
343
|
const sampleRecentlyViewed: RVItem[] = [
|
|
@@ -1149,7 +1160,7 @@ const componentVarsInline = Object.entries(componentVars)
|
|
|
1149
1160
|
<section class="sg-block">
|
|
1150
1161
|
<p class="sg-h">Chrome — UtilityNav</p>
|
|
1151
1162
|
<UtilityNav
|
|
1152
|
-
links={[{ id: "util-news", label: "News", href: "#" }, { id: "util-about", label: "About", href: "#" }, { id: "util-contact", label: "Contact", href: "#" }]}
|
|
1163
|
+
links={[{ id: "util-news", action: "nav-news", label: "News", href: "#" }, { id: "util-about", action: "nav-about", label: "About", href: "#" }, { id: "util-contact", action: "nav-contact", label: "Contact", href: "#" }]}
|
|
1153
1164
|
locale="English"
|
|
1154
1165
|
currency="United States (USD $)"
|
|
1155
1166
|
social={sampleSocial}
|
|
@@ -1181,7 +1192,15 @@ const componentVarsInline = Object.entries(componentVars)
|
|
|
1181
1192
|
<p class="sg-h">Chrome — NavDropdown accordion (mobile mega parity, zero-JS)</p>
|
|
1182
1193
|
<div style="max-width:18rem;background:var(--color-surface);border:1px solid var(--color-border);border-radius:8px;padding:8px 12px">
|
|
1183
1194
|
<ul class="grid">
|
|
1184
|
-
{sampleNav.map((item) =>
|
|
1195
|
+
{sampleNav.map((item, i) => (
|
|
1196
|
+
<NavDropdown
|
|
1197
|
+
item={item}
|
|
1198
|
+
mode="accordion"
|
|
1199
|
+
placement={CHROME_PLACEMENT.mobileMenu}
|
|
1200
|
+
menuPlacement={CHROME_PLACEMENT.mobileMenu}
|
|
1201
|
+
index={i}
|
|
1202
|
+
/>
|
|
1203
|
+
))}
|
|
1185
1204
|
</ul>
|
|
1186
1205
|
</div>
|
|
1187
1206
|
<p class="form-help" style="margin-top:8px;color:var(--color-muted)">Same nav items in the mobile menu: mega columns become inline <code><details></code> accordions (keyboard + touch operable). Toggle a caret item to expand.</p>
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The scheduled Cloudflare Worker entry for `dt-prune-and-rollup`.
|
|
3
|
+
*
|
|
4
|
+
* A SEPARATE, DEDICATED Worker script from the Astro-adapted storefront
|
|
5
|
+
* Worker — deliberately, not a `scheduled` export bolted onto the Astro
|
|
6
|
+
* entry. The @astrojs/cloudflare adapter OWNS `dist/server/entry.mjs` and
|
|
7
|
+
* bakes its target environment in at BUILD time via `CLOUDFLARE_ENV`
|
|
8
|
+
* (`wrangler.toml`'s own "DEPLOY FOOTGUN" comment); the documented way to add
|
|
9
|
+
* a non-`fetch` handler there is a custom entrypoint that re-imports the
|
|
10
|
+
* adapter's generated `handle()`, which would make this cron trigger's
|
|
11
|
+
* correctness depend on the SAME footgun-prone build/deploy sequencing as
|
|
12
|
+
* the whole storefront. This module instead has nothing to do with Astro at
|
|
13
|
+
* all — it is a plain `ExportedHandler` deployed under its own Worker name
|
|
14
|
+
* (`wrangler.prune-rollup.toml`, `[triggers]` below), reusing the tracking
|
|
15
|
+
* library's already-built, framework-agnostic pieces
|
|
16
|
+
* (`lib/tracking/pruneRollup.ts`, `d1Control.ts`, `interactionStore.ts`).
|
|
17
|
+
* There is no other scheduled-Worker precedent in this repo to follow; this
|
|
18
|
+
* is the first one, chosen to keep the footgun-prone Astro deploy path
|
|
19
|
+
* completely untouched.
|
|
20
|
+
*
|
|
21
|
+
* BINDINGS (declared in `wrangler.prune-rollup.toml`, not this file):
|
|
22
|
+
* - `STOREFRONT_APPS_DB` — the SAME shared D1 database the main storefront
|
|
23
|
+
* Worker binds, for the `tracking_tenant_databases` directory.
|
|
24
|
+
* - `INTERACTION_ANALYTICS` — the SAME Analytics Engine dataset the main
|
|
25
|
+
* Worker writes rollups to.
|
|
26
|
+
* - `CF_ACCOUNT_ID` / `TRACKING_D1_API_TOKEN` (secret) — D1 REST
|
|
27
|
+
* credentials for reaching each tenant's OWN database (Worker bindings
|
|
28
|
+
* are static; a per-tenant database can only be addressed over the D1
|
|
29
|
+
* HTTP API, same reasoning as `d1Control.ts`'s module doc).
|
|
30
|
+
* - `TRACKING_D1_NAME_PREFIX` (optional) — unused by this worker directly,
|
|
31
|
+
* kept only for parity/documentation; the prune sweep addresses tenants
|
|
32
|
+
* by `database_uuid` from the directory, never by re-deriving the name.
|
|
33
|
+
*/
|
|
34
|
+
import type { Queryable } from "../lib/d1/catalog.js";
|
|
35
|
+
import type { D1Like } from "../lib/d1/catalog.js";
|
|
36
|
+
import { fromD1 } from "../lib/d1/catalog.js";
|
|
37
|
+
import type { AnalyticsEngineLike } from "../lib/activity/store.js";
|
|
38
|
+
import type { D1ControlConfig } from "../lib/tracking/d1Control.js";
|
|
39
|
+
import { runPruneSweep, type PruneSweepResult } from "../lib/tracking/pruneRollup.js";
|
|
40
|
+
|
|
41
|
+
/** The env bindings this Worker needs — a local, minimal slice (this repo
|
|
42
|
+
* does not take a `@cloudflare/workers-types` dependency; see `d1Control.ts`
|
|
43
|
+
* and `activity/store.ts` for the same convention). */
|
|
44
|
+
export interface PruneRollupEnv {
|
|
45
|
+
STOREFRONT_APPS_DB: D1Like;
|
|
46
|
+
INTERACTION_ANALYTICS?: AnalyticsEngineLike;
|
|
47
|
+
CF_ACCOUNT_ID?: string;
|
|
48
|
+
TRACKING_D1_API_TOKEN?: string;
|
|
49
|
+
/** Tenants processed per invocation; overridable for a slower/faster cron. */
|
|
50
|
+
PRUNE_BATCH_SIZE?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The minimal slice of Cloudflare's `ScheduledController` this handler uses. */
|
|
54
|
+
export interface ScheduledControllerLike {
|
|
55
|
+
scheduledTime: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The minimal slice of Cloudflare's `ExecutionContext` this handler uses. */
|
|
59
|
+
export interface ExecutionContextLike {
|
|
60
|
+
waitUntil(promise: Promise<unknown>): void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Build the D1 REST control config from env, or undefined when unconfigured
|
|
64
|
+
* (mirrors `d1Control.ts`'s `readD1Control()` "skip, never fail" posture). */
|
|
65
|
+
function controlFromEnv(env: PruneRollupEnv): D1ControlConfig | undefined {
|
|
66
|
+
if (!env.CF_ACCOUNT_ID || !env.TRACKING_D1_API_TOKEN) return undefined;
|
|
67
|
+
return { accountId: env.CF_ACCOUNT_ID, apiToken: env.TRACKING_D1_API_TOKEN };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The pure orchestration the `scheduled` export below delegates to — kept
|
|
72
|
+
* separate so it is callable/testable without a real `ScheduledController`.
|
|
73
|
+
* Never throws: a misconfigured environment (no D1 REST credentials) logs
|
|
74
|
+
* and no-ops rather than failing the cron invocation.
|
|
75
|
+
*/
|
|
76
|
+
export async function handleScheduledPrune(
|
|
77
|
+
env: PruneRollupEnv,
|
|
78
|
+
now: string = new Date().toISOString(),
|
|
79
|
+
): Promise<PruneSweepResult | undefined> {
|
|
80
|
+
const control = controlFromEnv(env);
|
|
81
|
+
if (!control) {
|
|
82
|
+
console.error(
|
|
83
|
+
"[tracking:prune] skipped — CF_ACCOUNT_ID/TRACKING_D1_API_TOKEN not configured",
|
|
84
|
+
);
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const apps: Queryable = fromD1(env.STOREFRONT_APPS_DB);
|
|
89
|
+
const batchSize = env.PRUNE_BATCH_SIZE ? Number.parseInt(env.PRUNE_BATCH_SIZE, 10) : undefined;
|
|
90
|
+
|
|
91
|
+
const result = await runPruneSweep({
|
|
92
|
+
apps,
|
|
93
|
+
control,
|
|
94
|
+
analytics: env.INTERACTION_ANALYTICS,
|
|
95
|
+
now,
|
|
96
|
+
...(batchSize && Number.isFinite(batchSize) ? { batchSize } : {}),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
console.info(
|
|
100
|
+
`[tracking:prune] swept ${result.tenantsSwept} tenant(s), pruned ${result.totalPrunedRows} row(s), ` +
|
|
101
|
+
`wrote ${result.totalAggregatesWritten} aggregate(s)`,
|
|
102
|
+
);
|
|
103
|
+
return result;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export default {
|
|
107
|
+
async scheduled(
|
|
108
|
+
controller: ScheduledControllerLike,
|
|
109
|
+
env: PruneRollupEnv,
|
|
110
|
+
ctx: ExecutionContextLike,
|
|
111
|
+
): Promise<void> {
|
|
112
|
+
ctx.waitUntil(
|
|
113
|
+
handleScheduledPrune(env, new Date(controller.scheduledTime).toISOString()).catch((err) => {
|
|
114
|
+
console.error(
|
|
115
|
+
`[tracking:prune] sweep threw: ${err instanceof Error ? err.message : String(err)}`,
|
|
116
|
+
);
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
119
|
+
},
|
|
120
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.6",
|
|
4
4
|
"license": "SEE LICENSE IN LICENSE",
|
|
5
5
|
"description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
|
|
6
6
|
"packageManager": "pnpm@11.9.0",
|
|
@@ -25,9 +25,9 @@ case `uname -a` in
|
|
|
25
25
|
esac
|
|
26
26
|
|
|
27
27
|
if [ -z "$NODE_PATH" ]; then
|
|
28
|
-
export NODE_PATH="/home/runner/work/storefront/storefront/storefront-runner-dist/node_modules/.pnpm/vitest@2.1.8_@types+node@26.4.1_happy-dom@20.13.
|
|
28
|
+
export NODE_PATH="/home/runner/work/storefront/storefront/storefront-runner-dist/node_modules/.pnpm/vitest@2.1.8_@types+node@26.4.1_happy-dom@20.13.2_lightningcss@1.33.0/node_modules/vitest/node_modules:/home/runner/work/storefront/storefront/storefront-runner-dist/node_modules/.pnpm/vitest@2.1.8_@types+node@26.4.1_happy-dom@20.13.2_lightningcss@1.33.0/node_modules:/home/runner/work/storefront/storefront/storefront-runner-dist/node_modules/.pnpm/node_modules"
|
|
29
29
|
else
|
|
30
|
-
export NODE_PATH="/home/runner/work/storefront/storefront/storefront-runner-dist/node_modules/.pnpm/vitest@2.1.8_@types+node@26.4.1_happy-dom@20.13.
|
|
30
|
+
export NODE_PATH="/home/runner/work/storefront/storefront/storefront-runner-dist/node_modules/.pnpm/vitest@2.1.8_@types+node@26.4.1_happy-dom@20.13.2_lightningcss@1.33.0/node_modules/vitest/node_modules:/home/runner/work/storefront/storefront/storefront-runner-dist/node_modules/.pnpm/vitest@2.1.8_@types+node@26.4.1_happy-dom@20.13.2_lightningcss@1.33.0/node_modules:/home/runner/work/storefront/storefront/storefront-runner-dist/node_modules/.pnpm/node_modules:$NODE_PATH"
|
|
31
31
|
fi
|
|
32
32
|
if [ -n "$exe" ] && [ -x "$basedir/node.exe" ]; then
|
|
33
33
|
exec "$basedir/node.exe" "$basedir_win/../vitest/vitest.mjs" "$@"
|