@tokenoftrust/storefront-runner 2.0.3 → 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 (178) hide show
  1. package/apps/storefront/.astro/content.d.ts +159 -0
  2. package/apps/storefront/.astro/integrations/_astrojs_cloudflare/cloudflare.d.ts +1 -0
  3. package/apps/storefront/.astro/types.d.ts +3 -0
  4. package/apps/storefront/migrations-apps/0011_dt_tracking_tenant_databases.sql +23 -0
  5. package/apps/storefront/node_modules/.bin/acorn +43 -0
  6. package/apps/storefront/node_modules/.bin/astro +43 -0
  7. package/apps/storefront/node_modules/.bin/astro-check +43 -0
  8. package/apps/storefront/node_modules/.bin/browserslist +43 -0
  9. package/apps/storefront/node_modules/.bin/cf-wrangler +43 -0
  10. package/apps/storefront/node_modules/.bin/depcruise +43 -0
  11. package/apps/storefront/node_modules/.bin/depcruise-baseline +43 -0
  12. package/apps/storefront/node_modules/.bin/depcruise-fmt +43 -0
  13. package/apps/storefront/node_modules/.bin/depcruise-wrap-stream-in-html +43 -0
  14. package/apps/storefront/node_modules/.bin/dependency-cruise +43 -0
  15. package/apps/storefront/node_modules/.bin/dependency-cruiser +43 -0
  16. package/apps/storefront/node_modules/.bin/drizzle-kit +43 -0
  17. package/apps/storefront/node_modules/.bin/esbuild +43 -0
  18. package/apps/storefront/node_modules/.bin/jiti +43 -0
  19. package/apps/storefront/node_modules/.bin/playwright +43 -0
  20. package/apps/storefront/node_modules/.bin/playwright-core +43 -0
  21. package/apps/storefront/node_modules/.bin/prettier +43 -0
  22. package/apps/storefront/node_modules/.bin/rollup +43 -0
  23. package/apps/storefront/node_modules/.bin/tsc +43 -0
  24. package/apps/storefront/node_modules/.bin/tsserver +43 -0
  25. package/apps/storefront/node_modules/.bin/tsx +43 -0
  26. package/apps/storefront/node_modules/.bin/vite +43 -0
  27. package/apps/storefront/node_modules/.bin/vitest +43 -0
  28. package/apps/storefront/node_modules/.bin/workerd +43 -0
  29. package/apps/storefront/node_modules/.bin/wrangler +43 -0
  30. package/apps/storefront/node_modules/.bin/wrangler2 +43 -0
  31. package/apps/storefront/node_modules/.bin/yaml +43 -0
  32. package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_client__js.js +58 -0
  33. package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_client__js.js.map +1 -0
  34. package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_n_@preact_signals.js +741 -0
  35. package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_n_@preact_signals.js.map +1 -0
  36. package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_server__js.js +2549 -0
  37. package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_server__js.js.map +1 -0
  38. package/apps/storefront/node_modules/.vite/deps/_metadata.json +150 -0
  39. package/apps/storefront/node_modules/.vite/deps/astro-BIHuWFxm.js +434 -0
  40. package/apps/storefront/node_modules/.vite/deps/astro-BIHuWFxm.js.map +1 -0
  41. package/apps/storefront/node_modules/.vite/deps/astro_n_aria-query.js +6390 -0
  42. package/apps/storefront/node_modules/.vite/deps/astro_n_aria-query.js.map +1 -0
  43. package/apps/storefront/node_modules/.vite/deps/astro_n_axobject-query.js +2705 -0
  44. package/apps/storefront/node_modules/.vite/deps/astro_n_axobject-query.js.map +1 -0
  45. package/apps/storefront/node_modules/.vite/deps/astro_n_html-escaper.js +65 -0
  46. package/apps/storefront/node_modules/.vite/deps/astro_n_html-escaper.js.map +1 -0
  47. package/apps/storefront/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js +455 -0
  48. package/apps/storefront/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js.map +1 -0
  49. package/apps/storefront/node_modules/.vite/deps/audit-DKDpcdEI.js +1535 -0
  50. package/apps/storefront/node_modules/.vite/deps/audit-DKDpcdEI.js.map +1 -0
  51. package/apps/storefront/node_modules/.vite/deps/highlight-B9UoX1wa.js +48 -0
  52. package/apps/storefront/node_modules/.vite/deps/highlight-B9UoX1wa.js.map +1 -0
  53. package/apps/storefront/node_modules/.vite/deps/icons-BWi2wI9n.js +42 -0
  54. package/apps/storefront/node_modules/.vite/deps/icons-BWi2wI9n.js.map +1 -0
  55. package/apps/storefront/node_modules/.vite/deps/package.json +3 -0
  56. package/apps/storefront/node_modules/.vite/deps/preact.js +310 -0
  57. package/apps/storefront/node_modules/.vite/deps/preact.js.map +1 -0
  58. package/apps/storefront/node_modules/.vite/deps/preact_compat.js +420 -0
  59. package/apps/storefront/node_modules/.vite/deps/preact_compat.js.map +1 -0
  60. package/apps/storefront/node_modules/.vite/deps/preact_compat_jsx-runtime.js +4 -0
  61. package/apps/storefront/node_modules/.vite/deps/preact_debug.js +236 -0
  62. package/apps/storefront/node_modules/.vite/deps/preact_debug.js.map +1 -0
  63. package/apps/storefront/node_modules/.vite/deps/preact_devtools.js +12 -0
  64. package/apps/storefront/node_modules/.vite/deps/preact_devtools.js.map +1 -0
  65. package/apps/storefront/node_modules/.vite/deps/preact_hooks.js +192 -0
  66. package/apps/storefront/node_modules/.vite/deps/preact_hooks.js.map +1 -0
  67. package/apps/storefront/node_modules/.vite/deps/preact_jsx-dev-runtime.js +3 -0
  68. package/apps/storefront/node_modules/.vite/deps/preact_jsx-runtime.js +92 -0
  69. package/apps/storefront/node_modules/.vite/deps/preact_jsx-runtime.js.map +1 -0
  70. package/apps/storefront/node_modules/.vite/deps/preact_test-utils.js +59 -0
  71. package/apps/storefront/node_modules/.vite/deps/preact_test-utils.js.map +1 -0
  72. package/apps/storefront/node_modules/.vite/deps/rolldown-runtime-BPOCksWG.js +24 -0
  73. package/apps/storefront/node_modules/.vite/deps/settings-BVDV0Roj.js +189 -0
  74. package/apps/storefront/node_modules/.vite/deps/settings-BVDV0Roj.js.map +1 -0
  75. package/apps/storefront/node_modules/.vite/deps/static-html-DveYqwMo.js +13 -0
  76. package/apps/storefront/node_modules/.vite/deps/static-html-DveYqwMo.js.map +1 -0
  77. package/apps/storefront/node_modules/.vite/deps/toolbar-B3VIHCXg.js +458 -0
  78. package/apps/storefront/node_modules/.vite/deps/toolbar-B3VIHCXg.js.map +1 -0
  79. package/apps/storefront/node_modules/.vite/deps/ui-library-BK6ricUd.js +1016 -0
  80. package/apps/storefront/node_modules/.vite/deps/ui-library-BK6ricUd.js.map +1 -0
  81. package/apps/storefront/node_modules/.vite/deps/window-BvxDenKN.js +32 -0
  82. package/apps/storefront/node_modules/.vite/deps/window-BvxDenKN.js.map +1 -0
  83. package/apps/storefront/node_modules/.vite/deps/xray-YFiRUU0Z.js +113 -0
  84. package/apps/storefront/node_modules/.vite/deps/xray-YFiRUU0Z.js.map +1 -0
  85. package/apps/storefront/node_modules/.vite/deps_ssr/_metadata.json +8 -0
  86. package/apps/storefront/node_modules/.vite/deps_ssr/package.json +3 -0
  87. package/apps/storefront/src/components/chrome/MegaMenu.astro +11 -1
  88. package/apps/storefront/src/components/chrome/NavDropdown.astro +49 -7
  89. package/apps/storefront/src/components/chrome/SiteFooter.astro +29 -4
  90. package/apps/storefront/src/components/chrome/SiteHeader.astro +39 -6
  91. package/apps/storefront/src/components/chrome/UtilityNav.astro +13 -1
  92. package/apps/storefront/src/components/islands/MobileNav.tsx +42 -2
  93. package/apps/storefront/src/layouts/Layout.astro +65 -7
  94. package/apps/storefront/src/lib/apps/registryAccess.ts +45 -0
  95. package/apps/storefront/src/lib/auth/adminEntry.ts +4 -4
  96. package/apps/storefront/src/lib/chrome/ctaIdentity.ts +21 -0
  97. package/apps/storefront/src/lib/chrome/examples/tokenoftrust.chrome.example.json +22 -22
  98. package/apps/storefront/src/lib/chrome/trackingPlacements.ts +36 -0
  99. package/apps/storefront/src/lib/d1/schema-apps.ts +57 -0
  100. package/apps/storefront/src/lib/rawChrome.ts +73 -14
  101. package/apps/storefront/src/lib/rawMarketingHtml.ts +30 -0
  102. package/apps/storefront/src/lib/shared/capability.ts +68 -0
  103. package/apps/storefront/src/lib/shared/developerInvites.ts +1250 -0
  104. package/apps/storefront/src/lib/shared/invitePreflight.ts +123 -0
  105. package/apps/storefront/src/lib/storyblok/provider.ts +21 -5
  106. package/apps/storefront/src/lib/tracking/collectorAllowlist.ts +40 -0
  107. package/apps/storefront/src/lib/tracking/collectorIngest.ts +188 -0
  108. package/apps/storefront/src/lib/tracking/d1Control.ts +180 -0
  109. package/apps/storefront/src/lib/tracking/interactionCollector.ts +202 -0
  110. package/apps/storefront/src/lib/tracking/interactionHealth.ts +252 -0
  111. package/apps/storefront/src/lib/tracking/interactionSchema.ts +107 -0
  112. package/apps/storefront/src/lib/tracking/interactionStore.ts +0 -0
  113. package/apps/storefront/src/lib/tracking/pruneRollup.ts +260 -0
  114. package/apps/storefront/src/lib/tracking/publishSafetyNet.ts +162 -0
  115. package/apps/storefront/src/lib/tracking/tenantD1Registry.ts +214 -0
  116. package/apps/storefront/src/lib/tracking/tenantReadAccess.ts +138 -0
  117. package/apps/storefront/src/lib/tracking-writeback/rewriteTrackingIds.ts +175 -0
  118. package/apps/storefront/src/lib/tracking-writeback/writebackCandidate.ts +110 -0
  119. package/apps/storefront/src/lib/webhooks/deliveryEngine.ts +38 -4
  120. package/apps/storefront/src/lib/webhooks/emit.ts +1 -1
  121. package/apps/storefront/src/lib/webhooks/getDispatcher.ts +1 -1
  122. package/apps/storefront/src/pages/api/admin/interaction-insights.ts +101 -0
  123. package/apps/storefront/src/pages/api/auth/magic-exchange.ts +1 -1
  124. package/apps/storefront/src/pages/api/tracking/interaction.ts +123 -0
  125. package/apps/storefront/src/pages/index.astro +1 -1
  126. package/apps/storefront/src/pages/products/[handle].astro +1 -1
  127. package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +33 -14
  128. package/apps/storefront/src/workers/pruneRollupWorker.ts +120 -0
  129. package/package.json +1 -1
  130. package/packages/public-runtime/node_modules/.bin/tsc +43 -0
  131. package/packages/public-runtime/node_modules/.bin/tsserver +43 -0
  132. package/packages/public-runtime/node_modules/.bin/vitest +43 -0
  133. package/packages/public-runtime/src/chrome.ts +18 -7
  134. package/packages/public-runtime/src/index.ts +10 -0
  135. package/packages/public-runtime/src/integration/index.ts +1 -0
  136. package/packages/public-runtime/src/integration/interaction-tracking-gate.ts +142 -0
  137. package/packages/public-runtime/src/interaction-tracking.ts +364 -0
  138. package/packages/public-runtime/src/tracking-id-derivation.ts +1008 -0
  139. package/pnpm-workspace.yaml +3 -0
  140. package/scripts/tenant/validate.mjs +306 -1
  141. package/scripts/tot-dev.mjs +59 -3
  142. package/tenants/home/public/js/storefront.js +6 -87
  143. package/apps/storefront/src/lib/apps/adminService.ts +0 -116
  144. package/apps/storefront/src/lib/apps/adminSession.ts +0 -214
  145. package/apps/storefront/src/lib/apps/apiAuth.ts +0 -91
  146. package/apps/storefront/src/lib/apps/apiRoute.ts +0 -35
  147. package/apps/storefront/src/lib/apps/catalogMapper.ts +0 -39
  148. package/apps/storefront/src/lib/apps/consentConfigStore.ts +0 -228
  149. package/apps/storefront/src/lib/apps/gtmMigrationInstall.ts +0 -447
  150. package/apps/storefront/src/lib/apps/gtmMigrationReport.ts +0 -367
  151. package/apps/storefront/src/lib/apps/gtmMigrationTab.ts +0 -315
  152. package/apps/storefront/src/lib/apps/healthAggregate.ts +0 -66
  153. package/apps/storefront/src/lib/apps/integrationLifecycle.ts +0 -614
  154. package/apps/storefront/src/lib/apps/integrationLifecycleController.ts +0 -125
  155. package/apps/storefront/src/lib/apps/integrationReadModel.ts +0 -563
  156. package/apps/storefront/src/lib/apps/integrationsTab.ts +0 -547
  157. package/apps/storefront/src/lib/apps/integrationsView.ts +0 -106
  158. package/apps/storefront/src/lib/apps/managedAnalyticsService.ts +0 -599
  159. package/apps/storefront/src/lib/apps/tenantIntegrationMaterialization.ts +0 -170
  160. package/apps/storefront/src/lib/apps/tokenIssuer.ts +0 -121
  161. package/apps/storefront/src/lib/apps/tokenVerifier.ts +0 -148
  162. package/apps/storefront/src/lib/apps/v84CleanCloneReplayContext.ts +0 -16
  163. package/apps/storefront/src/lib/apps/v84CloneLifecycleCacheEvidence.ts +0 -105
  164. package/apps/storefront/src/lib/apps/v84CloneLifecycleEvidence.ts +0 -177
  165. package/apps/storefront/src/lib/apps/v84CloneLifecycleMaterialization.ts +0 -251
  166. package/apps/storefront/src/lib/apps/v84CloneLifecycleSemantics.ts +0 -241
  167. package/apps/storefront/src/lib/apps/v84CloneMigrationEvidence.ts +0 -211
  168. package/apps/storefront/src/lib/apps/v84CloneNativeInstallEvidence.ts +0 -409
  169. package/apps/storefront/src/lib/apps/v84NonportableCodeCoaching.ts +0 -368
  170. package/apps/storefront/src/lib/apps/v84NonportableRouteContract.ts +0 -188
  171. package/apps/storefront/src/lib/apps/v84NonportableRouteDogfood.ts +0 -291
  172. package/apps/storefront/src/lib/apps/ws1ManagedAnalyticsClient.ts +0 -95
  173. package/apps/storefront/src/lib/content-edit/applyDiff.ts +0 -91
  174. package/apps/storefront/src/lib/content-edit/client.ts +0 -286
  175. package/apps/storefront/src/lib/content-edit/index.ts +0 -14
  176. package/apps/storefront/src/lib/content-edit/route.ts +0 -25
  177. package/apps/storefront/src/lib/content-edit/types.ts +0 -19
  178. package/apps/storefront/src/pages/api/content-edit/ai-edit.ts +0 -164
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Invite preflight — block a developer invite when the target store's repo isn't
3
+ * provisioned on THIS environment, so a broken connection surfaces to the INVITER
4
+ * at invite time instead of dead-ending later in the invitee's terminal on
5
+ * "Repo <owner>-main is not provisioned".
6
+ *
7
+ * The check is the MCP's credential-free resource-registry read — the SAME read
8
+ * `tenant_checkout` gates on — reached the SAME way the app-binding writer +
9
+ * cli-signin-code seam reach the MCP:
10
+ * GET {origin(MCP_APP_BINDING_URL)}/internal/preview-status?tenant=<appDomain>&app=storefront&tag=main
11
+ * header x-mcp-service-secret: <MCP_SERVICE_SHARED_SECRET>
12
+ * → 200 { owner, app, repo, provisioned, previewReleased } | 400 | 502
13
+ * Contract source of truth: tot-mcp src/server/preview-status-routes.ts. We gate on
14
+ * `provisioned` — the exact precondition `tenant_checkout` needs. We do NOT gate on
15
+ * `previewReleased`: it is (provisioned && preview-approved), which is FALSE for every
16
+ * store that hasn't shipped a preview yet — gating on it would block invites for
17
+ * provisioned-but-unreleased stores whose checkout would actually succeed.
18
+ *
19
+ * FAIL-OPEN: we BLOCK only on a DEFINITIVE not-ready answer (a clean 200 with
20
+ * previewReleased:false). An unconfigured seam (no URL/secret), a non-200, a
21
+ * transport/parse fault, or a timeout is INCONCLUSIVE → allow (log a warning),
22
+ * mirroring recordDeveloperAppBinding's "skipped when not-configured" stance — we
23
+ * never break invites just because the MCP is briefly unreachable.
24
+ */
25
+ import { readEnv } from "@/lib/env";
26
+
27
+ /** How long we'll wait on the preview-status read before failing open. */
28
+ const PREFLIGHT_TIMEOUT_MS = 4000;
29
+
30
+ /** GET transport seam (injectable for tests) — mirrors HttpTransport but read-only. */
31
+ export interface PreflightTransport {
32
+ get(
33
+ url: string,
34
+ headers: Record<string, string>,
35
+ ): Promise<{ status: number; json(): Promise<unknown> }>;
36
+ }
37
+
38
+ /** The real transport: a timeout-bounded GET. */
39
+ export class FetchPreflightTransport implements PreflightTransport {
40
+ async get(url: string, headers: Record<string, string>) {
41
+ const ctrl = new AbortController();
42
+ const timer = setTimeout(() => ctrl.abort(), PREFLIGHT_TIMEOUT_MS);
43
+ try {
44
+ const res = await fetch(url, { method: "GET", headers, signal: ctrl.signal });
45
+ return { status: res.status, json: () => res.json() };
46
+ } finally {
47
+ clearTimeout(timer);
48
+ }
49
+ }
50
+ }
51
+
52
+ /** BLOCK carries the inviter-facing message; ALLOW carries a reason (for logging). */
53
+ export type PreflightDecision =
54
+ | { block: true; reason: "not-provisioned"; message: string }
55
+ | { block: false; reason: string };
56
+
57
+ /** The inviter-facing block message, naming the store. */
58
+ function blockMessage(tenant: string): string {
59
+ return (
60
+ `${tenant} isn't set up on this environment yet — its store repo isn't ` +
61
+ `provisioned. Finish provisioning that store before inviting a developer to it.`
62
+ );
63
+ }
64
+
65
+ /**
66
+ * Preflight `tenant` (the target appDomain). Returns a BLOCK only on a definitive
67
+ * not-provisioned answer; every inconclusive condition allows (fail-open). Never
68
+ * throws.
69
+ */
70
+ export async function checkStorePreviewReady(
71
+ tenant: string,
72
+ deps: { transport?: PreflightTransport } = {},
73
+ ): Promise<PreflightDecision> {
74
+ const bindingUrl = await readEnv("MCP_APP_BINDING_URL");
75
+ const secret = await readEnv("MCP_SERVICE_SHARED_SECRET");
76
+ if (!bindingUrl || !secret) {
77
+ // Seam not configured (the MCP route isn't even mounted without the secret) —
78
+ // inconclusive, so allow rather than break every invite.
79
+ return { block: false, reason: "not-configured" };
80
+ }
81
+
82
+ let endpoint: string;
83
+ try {
84
+ const origin = new URL(bindingUrl).origin;
85
+ const q = new URLSearchParams({ tenant: tenant.toLowerCase(), app: "storefront", tag: "main" });
86
+ // Canonical, namespace-neutral path (the read is generic, not dev-role-scoped).
87
+ endpoint = `${origin}/internal/preview-status?${q.toString()}`;
88
+ } catch {
89
+ return { block: false, reason: "bad-mcp-url" };
90
+ }
91
+
92
+ const transport = deps.transport ?? new FetchPreflightTransport();
93
+ let res: { status: number; json(): Promise<unknown> };
94
+ try {
95
+ res = await transport.get(endpoint, { "x-mcp-service-secret": secret });
96
+ } catch (cause) {
97
+ return {
98
+ block: false,
99
+ reason: "preflight request failed: " + (cause instanceof Error ? cause.message : String(cause)),
100
+ };
101
+ }
102
+
103
+ // Only a clean 200 is a conclusive answer; anything else is inconclusive → allow.
104
+ if (res.status < 200 || res.status >= 300) {
105
+ return { block: false, reason: `preflight returned status ${res.status}` };
106
+ }
107
+ let payload: { provisioned?: unknown; previewReleased?: unknown };
108
+ try {
109
+ payload = (await res.json()) as typeof payload;
110
+ } catch {
111
+ return { block: false, reason: "unparseable preflight response" };
112
+ }
113
+ // Gate on `provisioned` — the ONE precondition the invitee's first `tot start`
114
+ // checkout needs (a missing repo is the austin→sample-store failure). Treat only
115
+ // an explicit `false` as a definitive block; a missing/non-boolean field is
116
+ // inconclusive → allow. NOT `previewReleased` (see the file header): that now means
117
+ // provisioned && preview-approved, which is false for every not-yet-shipped store and
118
+ // would block invites whose checkout actually succeeds.
119
+ if (payload.provisioned === false) {
120
+ return { block: true, reason: "not-provisioned", message: blockMessage(tenant) };
121
+ }
122
+ return { block: false, reason: "provisioned" };
123
+ }
@@ -30,6 +30,7 @@ import {
30
30
  import { renderRawChrome } from "../rawChrome.js";
31
31
  import { parseChromeConfig } from "../chrome/parseConfig.js";
32
32
  import { parseChromeAssignments } from "../chrome/model.js";
33
+ import { stampTrackingIdsAtPublish } from "../tracking/publishSafetyNet.js";
33
34
 
34
35
  /** Safe, always-valid fallback when neither the tenant's own chrome.json nor
35
36
  * the default scope's validates — the storefront must always render. */
@@ -599,12 +600,21 @@ export class PublishedContentProvider implements ContentProvider {
599
600
  // Raw whole-page documents (T0 marketing tenants) publish via KV too, so a
600
601
  // vendor edit previews / goes live with NO Worker rebuild. Artifact path mirrors
601
602
  // the source file: content/<tenant>/home.html and .../pages-html/<slug>.html.
603
+ //
604
+ // This is content that bypasses git review entirely (a direct KV publish,
605
+ // never touched by dt-id-writeback's reviewable-diff flow) — so the
606
+ // publish-time HTMLRewriter safety net (publishSafetyNet.ts) stamps
607
+ // `data-tot-el` on it here, at the read boundary, before it ever reaches a
608
+ // route. The base chain's own git-tracked content is NOT re-stamped: that
609
+ // is dt-id-writeback's job.
602
610
  async getHomeHtml(): Promise<string | null> {
603
- return (await this.publishedRaw("home.html")) ?? this.base.getHomeHtml();
611
+ const raw = await this.publishedRaw("home.html");
612
+ return raw !== null ? stampTrackingIdsAtPublish(raw) : this.base.getHomeHtml();
604
613
  }
605
614
  async getPageHtml(slug: string): Promise<string | null> {
606
615
  const rel = `pages-html/${normalizeRawPageSlug(slug)}.html`;
607
- return (await this.publishedRaw(rel)) ?? this.base.getPageHtml(slug);
616
+ const raw = await this.publishedRaw(rel);
617
+ return raw !== null ? stampTrackingIdsAtPublish(raw) : this.base.getPageHtml(slug);
608
618
  }
609
619
  listPageHtmlSlugs(): Promise<string[]> {
610
620
  return this.listArtifactPaths("pages-html/").then((paths) =>
@@ -618,8 +628,9 @@ export class PublishedContentProvider implements ContentProvider {
618
628
  async getChromeHtml(currentPath?: string): Promise<string | null> {
619
629
  // A published raw chrome.html override (parallels the home.html/pages-html
620
630
  // publish path) wins, so a vendor edit previews / goes live with no rebuild.
631
+ // Same bypasses-git-review reasoning as getHomeHtml/getPageHtml above: stamp it.
621
632
  const publishedHtml = await this.publishedRaw("chrome.html");
622
- if (publishedHtml) return publishedHtml;
633
+ if (publishedHtml) return stampTrackingIdsAtPublish(publishedHtml);
623
634
  // Else, if the PUBLISHED chrome.json opts into shared chrome, render
624
635
  // the canonical wrapper from it — so a config edit previews without a rebuild.
625
636
  const config = parseChromeConfig(await this.published<unknown>("chrome.json"));
@@ -696,12 +707,17 @@ export class DevDraftContentProvider implements ContentProvider {
696
707
  return this.get(`dev-draft:${this.tenantId}:${this.draftId}:${path}`);
697
708
  }
698
709
 
710
+ // AI-edit drafts (WS5 G4 `/api/content-edit/ai-edit`) never touch
711
+ // dt-id-writeback's git-reviewed flow either — same publish-time
712
+ // HTMLRewriter safety net as PublishedContentProvider above.
699
713
  async getHomeHtml(): Promise<string | null> {
700
- return (await this.draftRaw("content/home.html")) ?? this.base.getHomeHtml();
714
+ const draft = await this.draftRaw("content/home.html");
715
+ return draft !== null ? stampTrackingIdsAtPublish(draft) : this.base.getHomeHtml();
701
716
  }
702
717
  async getPageHtml(slug: string): Promise<string | null> {
703
718
  const rel = `content/pages-html/${normalizeRawPageSlug(slug)}.html`;
704
- return (await this.draftRaw(rel)) ?? this.base.getPageHtml(slug);
719
+ const draft = await this.draftRaw(rel);
720
+ return draft !== null ? stampTrackingIdsAtPublish(draft) : this.base.getPageHtml(slug);
705
721
  }
706
722
  getHome(): Promise<HomeContent> {
707
723
  return this.base.getHome();
@@ -0,0 +1,40 @@
1
+ /**
2
+ * OWNER: dt-collector-v1. Central allowlist for `POST /api/tracking/interaction`.
3
+ *
4
+ * Default-deny, same posture as `@tot/public-runtime`'s activity redaction
5
+ * contract (`activity/redaction.ts`): the wire payload posted by the browser
6
+ * collector may carry ONLY these keys. Anything else is dropped and counted —
7
+ * never stored, never coerced into a known field. There is no per-action
8
+ * catalog here (unlike the activity spine) because the shape is fixed by the
9
+ * `TotInteraction` contract (`packages/public-runtime/src/interaction-tracking.ts`):
10
+ * one event kind, always the same fields.
11
+ *
12
+ * Every field here is either a `TotInteraction` attribute-contract field (read
13
+ * from `data-tot-*`, never from rendered content) or something this unit's own
14
+ * client-side collector synthesizes (ts / correlationId / seq) — never
15
+ * anything DOM-derived. Extending this allowlist is the ONE place a new field
16
+ * must be declared before the ingest module (`collectorIngest.ts`) will accept
17
+ * it from the wire.
18
+ */
19
+
20
+ /** The exact keys a posted interaction event may carry. */
21
+ export const INTERACTION_WIRE_ALLOWLIST = [
22
+ "type",
23
+ "el",
24
+ "action",
25
+ "placement",
26
+ "scope",
27
+ "index",
28
+ "ts",
29
+ "correlationId",
30
+ "seq",
31
+ ] as const;
32
+
33
+ export type InteractionWireKey = (typeof INTERACTION_WIRE_ALLOWLIST)[number];
34
+
35
+ const ALLOWLIST_SET: ReadonlySet<string> = new Set(INTERACTION_WIRE_ALLOWLIST);
36
+
37
+ /** True when `key` is a declared field. Anything else must be dropped, not stored. */
38
+ export function isAllowedInteractionKey(key: string): key is InteractionWireKey {
39
+ return ALLOWLIST_SET.has(key);
40
+ }
@@ -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
+ }