@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,138 @@
1
+ /**
2
+ * Merchant-facing READ authorization over the per-tenant interaction D1
3
+ * databases `dt-storage-per-tenant-d1` built (`tenantD1Registry.ts`,
4
+ * `interactionStore.ts`). This is `dt-tenant-isolation`: the release-order
5
+ * doc's hard invariant is "no merchant-visible surface ships until this
6
+ * lands", and this module is that surface's data-access layer.
7
+ *
8
+ * THE ISOLATION GUARANTEE THIS MODULE RELIES ON (built by
9
+ * `dt-storage-per-tenant-d1`, not re-implemented here): `resolveTenantStore`
10
+ * is the ONLY way to get a `Queryable`, and it is bound to exactly one
11
+ * tenant's database UUID. There is no handle anywhere that spans two
12
+ * tenants' rows, so every function below is safe to call with an UNSCOPED
13
+ * `SELECT` — the database itself is the isolation boundary, not the `WHERE`
14
+ * clause. A `tenant_id = ?` predicate is still included below (matching the
15
+ * `TenantInteractionStore.tenantId` the caller resolved — NEVER a
16
+ * client-suppliable value) as defence-in-depth and to keep the row's
17
+ * redundant `tenant_id` column meaningful, not because it is what makes the
18
+ * isolation hold.
19
+ *
20
+ * `assertStoreOwnership` is the extra check this unit adds on top: before
21
+ * trusting a resolved store for a read, confirm `interaction_store_meta`
22
+ * (stamped by `dt-storage-per-tenant-d1` at provision time) actually says
23
+ * this database belongs to the tenant we resolved it for. A resolution bug
24
+ * that somehow handed back the wrong store is caught here rather than
25
+ * silently serving the wrong tenant's rows to a merchant.
26
+ */
27
+ import type { TenantInteractionStore } from "./tenantD1Registry.js";
28
+
29
+ /** One row of the merchant-visible recent-events feed. */
30
+ export interface RecentInteractionEvent {
31
+ type: string;
32
+ el: string;
33
+ action: string | null;
34
+ placement: string | null;
35
+ scope: string | null;
36
+ ts: string;
37
+ ingestedAt: string;
38
+ }
39
+
40
+ /** One rolled-up (action, el) count — the merchant-visible summary shape. */
41
+ export interface InteractionActionCount {
42
+ el: string;
43
+ action: string;
44
+ count: number;
45
+ }
46
+
47
+ export interface InteractionInsights {
48
+ totalEvents: number;
49
+ byAction: InteractionActionCount[];
50
+ recent: RecentInteractionEvent[];
51
+ }
52
+
53
+ /** Cap on the recent-events feed — a dashboard preview, not an export. */
54
+ const DEFAULT_RECENT_LIMIT = 50;
55
+ const MAX_RECENT_LIMIT = 200;
56
+
57
+ /**
58
+ * Confirm a resolved store's `interaction_store_meta` row actually names the
59
+ * tenant we resolved it for. Returns `false` (never throws) on any mismatch,
60
+ * a missing meta row, or a query failure — all fail-closed: the caller must
61
+ * treat that as "cannot verify this store, do not read from it."
62
+ */
63
+ export async function assertStoreOwnership(
64
+ store: Pick<TenantInteractionStore, "tenantId" | "db">,
65
+ ): Promise<boolean> {
66
+ try {
67
+ const meta = await store.db.first<{ tenant_id: string }>(
68
+ `SELECT tenant_id FROM interaction_store_meta WHERE id = 1`,
69
+ );
70
+ return !!meta && meta.tenant_id === store.tenantId;
71
+ } catch {
72
+ return false;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Recent raw events for ONE tenant's own store, newest first. Scoped to the
78
+ * resolved store's database (the isolation boundary) AND to its
79
+ * `tenantId` (defence-in-depth) — there is no parameter here through which a
80
+ * caller can ask for a different tenant's rows; the only tenant this can
81
+ * ever return is the one `store` was resolved for.
82
+ */
83
+ export async function readRecentInteractionEvents(
84
+ store: Pick<TenantInteractionStore, "tenantId" | "db">,
85
+ opts?: { limit?: number },
86
+ ): Promise<RecentInteractionEvent[]> {
87
+ const limit = Math.max(1, Math.min(opts?.limit ?? DEFAULT_RECENT_LIMIT, MAX_RECENT_LIMIT));
88
+ return store.db.all<RecentInteractionEvent>(
89
+ `SELECT type, el, action, placement, scope, ts, ingested_at AS ingestedAt
90
+ FROM interaction_events
91
+ WHERE tenant_id = ?
92
+ ORDER BY ingested_at DESC
93
+ LIMIT ?`,
94
+ store.tenantId,
95
+ limit,
96
+ );
97
+ }
98
+
99
+ /**
100
+ * Simple aggregate over ONE tenant's own store: total row count + a rollup by
101
+ * (el, action). Mirrors the AE dimension tuple's spirit but computed directly
102
+ * off the tenant's own raw rows, scoped the same way as
103
+ * {@link readRecentInteractionEvents}.
104
+ */
105
+ export async function readInteractionSummary(
106
+ store: Pick<TenantInteractionStore, "tenantId" | "db">,
107
+ ): Promise<{ totalEvents: number; byAction: InteractionActionCount[] }> {
108
+ const totalRow = await store.db.first<{ n: number }>(
109
+ `SELECT COUNT(*) AS n FROM interaction_events WHERE tenant_id = ?`,
110
+ store.tenantId,
111
+ );
112
+ const byAction = await store.db.all<InteractionActionCount>(
113
+ `SELECT el, COALESCE(action, '') AS action, COUNT(*) AS count
114
+ FROM interaction_events
115
+ WHERE tenant_id = ?
116
+ GROUP BY el, action
117
+ ORDER BY count DESC`,
118
+ store.tenantId,
119
+ );
120
+ return { totalEvents: totalRow?.n ?? 0, byAction };
121
+ }
122
+
123
+ /**
124
+ * The full merchant-visible read: ownership-asserted, then summary + recent
125
+ * feed off the SAME scoped store. Returns `undefined` when the store fails
126
+ * the ownership assertion — fail-closed, never a partial/best-guess result.
127
+ */
128
+ export async function readTenantInteractionInsights(
129
+ store: Pick<TenantInteractionStore, "tenantId" | "db">,
130
+ opts?: { limit?: number },
131
+ ): Promise<InteractionInsights | undefined> {
132
+ if (!(await assertStoreOwnership(store))) return undefined;
133
+ const [summary, recent] = await Promise.all([
134
+ readInteractionSummary(store),
135
+ readRecentInteractionEvents(store, opts),
136
+ ]);
137
+ return { totalEvents: summary.totalEvents, byAction: summary.byAction, recent };
138
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * `data-tot-el` writeback (dt-id-writeback) — the git-time rewriter that turns
3
+ * `tracking-id-derivation.ts`'s PROPOSALS into an actual attribute in a tenant's
4
+ * source HTML, so a human reviews and accepts a real diff (never a silent
5
+ * runtime mutation). Mirrors the pattern `the-build/toc.ts` already uses for
6
+ * heading anchors: parse5 fragment/document tree, mutate in place, re-`serialize()`
7
+ * ONLY when something actually changed (byte-preserving otherwise).
8
+ *
9
+ * Scope: tenant-authored BODY documents only (`content/home.html`,
10
+ * `content/<tenant>/pages-html/<slug>.html`) — never governed chrome, which
11
+ * `trackingAttributes()` / `trackingAttributesHtml()` already stamp via a
12
+ * separate, non-derived path (`dt-stamp-chrome-nav`). This module does not know
13
+ * about chrome and must never be pointed at `chrome.html` / `chrome.json`.
14
+ *
15
+ * Contract: docs/architecture/interaction-tracking-attribute-contract.md
16
+ * ("Deriving `data-tot-el`").
17
+ */
18
+ import { parse, parseFragment, serialize } from "parse5";
19
+ import {
20
+ createTrackingIdDeriver,
21
+ isTrackableNode,
22
+ TRACKING_ATTR_EL,
23
+ type DerivationOptions,
24
+ type DerivationConfidence,
25
+ type StructuralNode,
26
+ } from "@tot/public-runtime";
27
+
28
+ /** Minimal structural view of a parse5 node (duck-typed, as toc.ts does). */
29
+ interface P5Attr {
30
+ name: string;
31
+ value: string;
32
+ }
33
+ interface P5Node {
34
+ nodeName: string;
35
+ tagName?: string;
36
+ attrs?: P5Attr[];
37
+ childNodes?: P5Node[];
38
+ }
39
+
40
+ /** One proposed (or already-declared) `data-tot-el` this pass encountered. */
41
+ export interface TrackingWritebackProposal {
42
+ /** The proposed (or, for a `"declared"` row, the pre-existing) value. */
43
+ el: string;
44
+ confidence: DerivationConfidence;
45
+ /** Most-significant-first, per `DerivedTrackingId.signals` — the diff annotation. */
46
+ signals: readonly string[];
47
+ /** True iff a human should name this element before accepting the diff. */
48
+ needsReview: boolean;
49
+ /** True iff `data-tot-el` was ALREADY present (nothing written; verbatim). */
50
+ alreadyDeclared: boolean;
51
+ /** The element's tag, for the review listing. */
52
+ tag: string;
53
+ /** A short open-tag snippet (no children) so a reviewer can locate the element. */
54
+ snippet: string;
55
+ }
56
+
57
+ export interface TrackingWritebackResult {
58
+ /** The (possibly) rewritten source. Byte-identical to the input when `changed` is false. */
59
+ html: string;
60
+ /** True iff at least one element gained a new `data-tot-el`. */
61
+ changed: boolean;
62
+ /** Every candidate element seen, in document order — declared rows included. */
63
+ proposals: TrackingWritebackProposal[];
64
+ }
65
+
66
+ function attrsToRecord(attrs?: P5Attr[]): Record<string, string> | undefined {
67
+ if (!attrs || attrs.length === 0) return undefined;
68
+ const rec: Record<string, string> = {};
69
+ for (const a of attrs) rec[a.name] = a.value;
70
+ return rec;
71
+ }
72
+
73
+ function toStructuralNode(node: P5Node): StructuralNode {
74
+ return { tag: (node.tagName ?? "").toLowerCase(), attrs: attrsToRecord(node.attrs) };
75
+ }
76
+
77
+ function openTagSnippet(node: StructuralNode): string {
78
+ const attrs = node.attrs ?? {};
79
+ const parts = Object.entries(attrs)
80
+ .filter((entry): entry is [string, string] => typeof entry[1] === "string")
81
+ .map(([k, v]) => `${k}="${v}"`);
82
+ return `<${node.tag}${parts.length ? " " + parts.join(" ") : ""}>`;
83
+ }
84
+
85
+ function isElement(node: P5Node): node is P5Node & { tagName: string } {
86
+ return typeof node.tagName === "string" && node.tagName.length > 0;
87
+ }
88
+
89
+ /** Every `data-tot-el` value anywhere in the tree, in document order — the
90
+ * pre-existing identities a fresh proposal must never collide with, per the
91
+ * derivation engine's own handoff ("reserve() every id already in the source,
92
+ * BEFORE deriving"). Deliberately unfiltered by {@link isTrackableNode}: a
93
+ * hand-authored id on an element this pass wouldn't otherwise touch still
94
+ * occupies the namespace. */
95
+ function* declaredIds(root: P5Node): Generator<string> {
96
+ for (const child of root.childNodes ?? []) {
97
+ if (isElement(child)) {
98
+ const value = child.attrs?.find((a) => a.name === TRACKING_ATTR_EL)?.value?.trim();
99
+ if (value) yield value;
100
+ }
101
+ yield* declaredIds(child);
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Walk the tree in document order, yielding every trackable element with its
107
+ * ancestor stack (OUTERMOST first — the derivation engine's own contract).
108
+ */
109
+ function* candidates(
110
+ root: P5Node,
111
+ ancestors: readonly P5Node[] = [],
112
+ ): Generator<{ node: P5Node; ancestors: readonly P5Node[] }> {
113
+ for (const child of root.childNodes ?? []) {
114
+ if (isElement(child)) {
115
+ if (isTrackableNode(toStructuralNode(child))) {
116
+ yield { node: child, ancestors };
117
+ }
118
+ yield* candidates(child, [...ancestors, child]);
119
+ } else {
120
+ yield* candidates(child, ancestors);
121
+ }
122
+ }
123
+ }
124
+
125
+ /** Whether `html` looks like a full document (vs. a body-only fragment). */
126
+ function isFullDocument(html: string): boolean {
127
+ return /^\s*(<!doctype\s|<html[\s>])/i.test(html);
128
+ }
129
+
130
+ /**
131
+ * Propose (and write) `data-tot-el` for every undeclared trackable element in
132
+ * `html`. Pure: no I/O, no network. `confidence: "declared"` elements are
133
+ * returned in `proposals` for a complete review listing but are NEVER mutated —
134
+ * a declared identity is permanent (per the derivation engine's own contract).
135
+ *
136
+ * `html` may be a full tenant document (`content/home.html`, doctype + `<html>`)
137
+ * or a body-only fragment (`content/<tenant>/pages-html/<slug>.html`) — detected
138
+ * automatically so one function serves both writeback call sites.
139
+ */
140
+ export function deriveTrackingWriteback(
141
+ html: string,
142
+ options?: DerivationOptions,
143
+ ): TrackingWritebackResult {
144
+ const fullDocument = isFullDocument(html);
145
+ const root = (fullDocument ? parse(html) : parseFragment(html)) as unknown as P5Node;
146
+
147
+ const deriver = createTrackingIdDeriver(options);
148
+ for (const id of declaredIds(root)) deriver.reserve(id);
149
+
150
+ const proposals: TrackingWritebackProposal[] = [];
151
+ let changed = false;
152
+
153
+ for (const { node, ancestors } of candidates(root)) {
154
+ const structural = toStructuralNode(node);
155
+ const alreadyDeclared = Boolean(structural.attrs?.[TRACKING_ATTR_EL]?.trim());
156
+ const derived = deriver.derive({
157
+ node: structural,
158
+ ancestors: ancestors.map(toStructuralNode),
159
+ });
160
+ proposals.push({
161
+ el: derived.el,
162
+ confidence: derived.confidence,
163
+ signals: derived.signals,
164
+ needsReview: derived.needsReview,
165
+ alreadyDeclared,
166
+ tag: structural.tag,
167
+ snippet: openTagSnippet(structural),
168
+ });
169
+ if (alreadyDeclared) continue; // declared verbatim — never overwritten
170
+ node.attrs = [...(node.attrs ?? []), { name: TRACKING_ATTR_EL, value: derived.el }];
171
+ changed = true;
172
+ }
173
+
174
+ return { html: changed ? serialize(root as never) : html, changed, proposals };
175
+ }
@@ -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
+ }
@@ -24,8 +24,6 @@
24
24
  */
25
25
  import { WebhookDeliveryStore, type WebhookDelivery, type WebhookEvent, type WebhookTopic } from "./deliveryStore.js";
26
26
  import type { AppInstall } from "../apps/registryService.js";
27
- import { loadWebhookSigningConfig, type WebhookSigningConfig } from "./webhookSigningKey.js";
28
- import { signWebhookRequest, type SignWebhookRequestResult } from "./signing.js";
29
27
  import { buildCloudEventEnvelope } from "./events.js";
30
28
  import { computeBackoffDelaySeconds, MAX_DELIVERY_ATTEMPTS, type AttemptOutcome } from "./dispatcher.js";
31
29
  import { validateWebhookEndpoint } from "./endpointGuard.js";
@@ -33,6 +31,28 @@ import { validateWebhookEndpoint } from "./endpointGuard.js";
33
31
  /** Bounded per-attempt timeout — a hung receiver must not hold a dispatcher slot open indefinitely. */
34
32
  const DEFAULT_TIMEOUT_MS = 10_000;
35
33
 
34
+ // Both signing.js and webhookSigningKey.js ultimately re-export from
35
+ // @tokenoftrust/private-apps-devkit, a workspace-only package never vendored
36
+ // into the public storefront-runner — a static import of either (type-only
37
+ // included: `tsc` resolves a module for `import type` too) would be a
38
+ // dangling import in that build. The VALUES are loaded via dynamic import at
39
+ // their call sites below (see attemptDelivery/deliverSigned); these two
40
+ // shapes are duplicated here, type-only, so this module's own types don't
41
+ // need those files to exist in the runner. Keep in sync with
42
+ // webhookSigningKey.ts's WebhookSigningConfig and
43
+ // private-apps-devkit/src/signing.ts's SignWebhookRequestResult.
44
+ interface WebhookSigningConfig {
45
+ key: CryptoKey;
46
+ kid: string;
47
+ }
48
+ interface SignWebhookRequestResult {
49
+ headers: {
50
+ "content-digest": string;
51
+ "signature-input": string;
52
+ signature: string;
53
+ };
54
+ }
55
+
36
56
  /** The minimal registry seam the engine needs: resolving the target install by id. Satisfied by `AppRegistryService`. */
37
57
  export interface InstallResolver {
38
58
  get(installId: string): Promise<AppInstall | null>;
@@ -83,7 +103,17 @@ export async function attemptDelivery(deliveryId: string, deps: AttemptDeliveryD
83
103
  const now = deps.now ?? (() => new Date());
84
104
  const timeoutMs = deps.timeoutMs ?? DEFAULT_TIMEOUT_MS;
85
105
  const fetchImpl = deps.fetchImpl ?? fetch;
86
- const loadSigningConfig = deps.loadSigningConfig ?? loadWebhookSigningConfig;
106
+ const loadSigningConfig =
107
+ deps.loadSigningConfig ??
108
+ (async () => {
109
+ // Absent from the public storefront-runner (dropped; see WebhookSigningConfig above).
110
+ // ts-ignore, not ts-expect-error: this module DOES exist in the full repo's own
111
+ // typecheck (only missing from the runner-extracted tree), so there's no error to
112
+ // "expect" here in that context — ts-expect-error would itself error as unused.
113
+ // @ts-ignore
114
+ const mod = await import("./webhookSigningKey.js");
115
+ return mod.loadWebhookSigningConfig() as Promise<WebhookSigningConfig>;
116
+ });
87
117
 
88
118
  const delivery = await store.getDelivery(deliveryId);
89
119
  if (!delivery) {
@@ -172,7 +202,11 @@ async function deliverSigned(
172
202
  now: ctx.now,
173
203
  });
174
204
  const body = JSON.stringify(envelope);
175
- const { headers } = await signWebhookRequest({
205
+ // Absent from the public storefront-runner (dropped; see SignWebhookRequestResult above);
206
+ // ts-ignore not ts-expect-error, same reasoning as loadSigningConfig above.
207
+ // @ts-ignore
208
+ const { signWebhookRequest } = await import("./signing.js");
209
+ const { headers }: SignWebhookRequestResult = await signWebhookRequest({
176
210
  method: "POST",
177
211
  url: install.webhookEndpoint!,
178
212
  body,
@@ -48,7 +48,7 @@
48
48
  import { WebhookDeliveryStore, type WebhookTopic } from "./deliveryStore.js";
49
49
  import { TOPIC_REQUIRED_SCOPE } from "./events.js";
50
50
  import { getDispatcher } from "./getDispatcher.js";
51
- import { getRegistryService } from "../apps/adminService.js";
51
+ import { getRegistryService } from "../apps/registryAccess.js";
52
52
  import type { AppInstall } from "../apps/registryService.js";
53
53
  import { fromD1 } from "../d1/catalog.js";
54
54
  import { readD1 } from "../env.js";
@@ -21,7 +21,7 @@ import { CloudflareQueueDispatcher, type CloudflareQueueSender } from "./cloudfl
21
21
  import { AppRegistryService } from "../apps/registryService.js";
22
22
  import { D1CredentialStore } from "../apps/credentials.js";
23
23
  import { DEFAULT_APP_GATEWAY_KID, APP_GATEWAY_KID_ENV } from "../apps/gatewayKeys.js";
24
- import { json } from "../apps/adminService.js";
24
+ import { json } from "../apps/registryAccess.js";
25
25
  import { fromD1 } from "../d1/catalog.js";
26
26
  import { readD1, readEnv, readQueue } from "../env.js";
27
27
 
@@ -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
+ }
@@ -25,7 +25,7 @@ import {
25
25
  } from "@/lib/auth/identityToken";
26
26
  import { createSession, DEFAULT_SESSION_TTL_SECONDS } from "@/lib/auth/session";
27
27
  import { normalizeEmailHint } from "@/lib/privacy/emailHint";
28
- import { acceptDeveloperInvite } from "@/lib/grants/developerInvites";
28
+ import { acceptDeveloperInvite } from "@/lib/shared/developerInvites";
29
29
 
30
30
  export const prerender = false;
31
31