@tokenoftrust/storefront-runner 2.0.4 → 2.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/apps/storefront/migrations-apps/0011_dt_tracking_tenant_databases.sql +23 -0
  2. package/apps/storefront/node_modules/.bin/vitest +2 -2
  3. package/apps/storefront/node_modules/.vite/deps/_metadata.json +19 -19
  4. package/apps/storefront/node_modules/.vite/deps_ssr/_metadata.json +3 -3
  5. package/apps/storefront/src/components/chrome/MegaMenu.astro +11 -1
  6. package/apps/storefront/src/components/chrome/NavDropdown.astro +49 -7
  7. package/apps/storefront/src/components/chrome/SiteFooter.astro +29 -4
  8. package/apps/storefront/src/components/chrome/SiteHeader.astro +39 -6
  9. package/apps/storefront/src/components/chrome/UtilityNav.astro +13 -1
  10. package/apps/storefront/src/components/islands/MobileNav.tsx +42 -2
  11. package/apps/storefront/src/layouts/Layout.astro +64 -6
  12. package/apps/storefront/src/lib/chrome/ctaIdentity.ts +21 -0
  13. package/apps/storefront/src/lib/chrome/examples/tokenoftrust.chrome.example.json +22 -22
  14. package/apps/storefront/src/lib/chrome/trackingPlacements.ts +36 -0
  15. package/apps/storefront/src/lib/d1/schema-apps.ts +57 -0
  16. package/apps/storefront/src/lib/rawChrome.ts +73 -14
  17. package/apps/storefront/src/lib/rawMarketingHtml.ts +30 -0
  18. package/apps/storefront/src/lib/storyblok/provider.ts +21 -5
  19. package/apps/storefront/src/lib/tracking/collectorAllowlist.ts +40 -0
  20. package/apps/storefront/src/lib/tracking/collectorIngest.ts +188 -0
  21. package/apps/storefront/src/lib/tracking/d1Control.ts +180 -0
  22. package/apps/storefront/src/lib/tracking/interactionCollector.ts +202 -0
  23. package/apps/storefront/src/lib/tracking/interactionHealth.ts +252 -0
  24. package/apps/storefront/src/lib/tracking/interactionSchema.ts +107 -0
  25. package/apps/storefront/src/lib/tracking/interactionStore.ts +0 -0
  26. package/apps/storefront/src/lib/tracking/pruneRollup.ts +260 -0
  27. package/apps/storefront/src/lib/tracking/publishSafetyNet.ts +162 -0
  28. package/apps/storefront/src/lib/tracking/tenantD1Registry.ts +214 -0
  29. package/apps/storefront/src/lib/tracking/tenantReadAccess.ts +138 -0
  30. package/apps/storefront/src/lib/tracking-writeback/rewriteTrackingIds.ts +175 -0
  31. package/apps/storefront/src/lib/tracking-writeback/writebackCandidate.ts +110 -0
  32. package/apps/storefront/src/pages/api/admin/interaction-insights.ts +101 -0
  33. package/apps/storefront/src/pages/api/tracking/interaction.ts +123 -0
  34. package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +33 -14
  35. package/apps/storefront/src/workers/pruneRollupWorker.ts +120 -0
  36. package/package.json +1 -1
  37. package/packages/public-runtime/node_modules/.bin/vitest +2 -2
  38. package/packages/public-runtime/src/chrome.ts +18 -7
  39. package/packages/public-runtime/src/index.ts +10 -0
  40. package/packages/public-runtime/src/integration/index.ts +1 -0
  41. package/packages/public-runtime/src/integration/interaction-tracking-gate.ts +142 -0
  42. package/packages/public-runtime/src/interaction-tracking.ts +364 -0
  43. package/packages/public-runtime/src/tracking-id-derivation.ts +1008 -0
  44. package/scripts/tenant/validate.mjs +306 -1
  45. package/scripts/tot-dev.mjs +59 -3
  46. package/tenants/home/public/js/storefront.js +6 -87
@@ -0,0 +1,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
- { title: "Shop", links: [{ id: "ftr-shop-disposables", label: "Disposables", href: "#" }, { id: "ftr-shop-eliquid", label: "E-Liquid", href: "#" }, { id: "ftr-shop-devices", label: "Devices", href: "#" }] },
111
- { title: "Support", links: [{ id: "ftr-support-contact", label: "Contact", href: "#" }, { id: "ftr-support-shipping", label: "Shipping", href: "#" }, { id: "ftr-support-returns", label: "Returns", href: "#" }] },
112
- { title: "Company", links: [{ id: "ftr-company-about", label: "About", href: "#" }, { id: "ftr-company-blog", label: "Blog", href: "#" }] },
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: [{ label: "Disposables", href: "#" }, { label: "E-Liquid", href: "#" }],
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) => <NavDropdown item={item} mode="accordion" />)}
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>&lt;details&gt;</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.4",
3
+ "version": "2.0.5",
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.1_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.1_lightningcss@1.33.0/node_modules:/home/runner/work/storefront/storefront/storefront-runner-dist/node_modules/.pnpm/node_modules"
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.1_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.1_lightningcss@1.33.0/node_modules:/home/runner/work/storefront/storefront/storefront-runner-dist/node_modules/.pnpm/node_modules:$NODE_PATH"
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" "$@"