@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.
- package/apps/storefront/.astro/content.d.ts +159 -0
- package/apps/storefront/.astro/integrations/_astrojs_cloudflare/cloudflare.d.ts +1 -0
- package/apps/storefront/.astro/types.d.ts +3 -0
- package/apps/storefront/migrations-apps/0011_dt_tracking_tenant_databases.sql +23 -0
- package/apps/storefront/node_modules/.bin/acorn +43 -0
- package/apps/storefront/node_modules/.bin/astro +43 -0
- package/apps/storefront/node_modules/.bin/astro-check +43 -0
- package/apps/storefront/node_modules/.bin/browserslist +43 -0
- package/apps/storefront/node_modules/.bin/cf-wrangler +43 -0
- package/apps/storefront/node_modules/.bin/depcruise +43 -0
- package/apps/storefront/node_modules/.bin/depcruise-baseline +43 -0
- package/apps/storefront/node_modules/.bin/depcruise-fmt +43 -0
- package/apps/storefront/node_modules/.bin/depcruise-wrap-stream-in-html +43 -0
- package/apps/storefront/node_modules/.bin/dependency-cruise +43 -0
- package/apps/storefront/node_modules/.bin/dependency-cruiser +43 -0
- package/apps/storefront/node_modules/.bin/drizzle-kit +43 -0
- package/apps/storefront/node_modules/.bin/esbuild +43 -0
- package/apps/storefront/node_modules/.bin/jiti +43 -0
- package/apps/storefront/node_modules/.bin/playwright +43 -0
- package/apps/storefront/node_modules/.bin/playwright-core +43 -0
- package/apps/storefront/node_modules/.bin/prettier +43 -0
- package/apps/storefront/node_modules/.bin/rollup +43 -0
- package/apps/storefront/node_modules/.bin/tsc +43 -0
- package/apps/storefront/node_modules/.bin/tsserver +43 -0
- package/apps/storefront/node_modules/.bin/tsx +43 -0
- package/apps/storefront/node_modules/.bin/vite +43 -0
- package/apps/storefront/node_modules/.bin/vitest +43 -0
- package/apps/storefront/node_modules/.bin/workerd +43 -0
- package/apps/storefront/node_modules/.bin/wrangler +43 -0
- package/apps/storefront/node_modules/.bin/wrangler2 +43 -0
- package/apps/storefront/node_modules/.bin/yaml +43 -0
- package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_client__js.js +58 -0
- package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_client__js.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_n_@preact_signals.js +741 -0
- package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_n_@preact_signals.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_server__js.js +2549 -0
- package/apps/storefront/node_modules/.vite/deps/@astrojs_preact_server__js.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/_metadata.json +150 -0
- package/apps/storefront/node_modules/.vite/deps/astro-BIHuWFxm.js +434 -0
- package/apps/storefront/node_modules/.vite/deps/astro-BIHuWFxm.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/astro_n_aria-query.js +6390 -0
- package/apps/storefront/node_modules/.vite/deps/astro_n_aria-query.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/astro_n_axobject-query.js +2705 -0
- package/apps/storefront/node_modules/.vite/deps/astro_n_axobject-query.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/astro_n_html-escaper.js +65 -0
- package/apps/storefront/node_modules/.vite/deps/astro_n_html-escaper.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js +455 -0
- package/apps/storefront/node_modules/.vite/deps/astro_runtime_client_dev-toolbar_entrypoint__js.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/audit-DKDpcdEI.js +1535 -0
- package/apps/storefront/node_modules/.vite/deps/audit-DKDpcdEI.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/highlight-B9UoX1wa.js +48 -0
- package/apps/storefront/node_modules/.vite/deps/highlight-B9UoX1wa.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/icons-BWi2wI9n.js +42 -0
- package/apps/storefront/node_modules/.vite/deps/icons-BWi2wI9n.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/package.json +3 -0
- package/apps/storefront/node_modules/.vite/deps/preact.js +310 -0
- package/apps/storefront/node_modules/.vite/deps/preact.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/preact_compat.js +420 -0
- package/apps/storefront/node_modules/.vite/deps/preact_compat.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/preact_compat_jsx-runtime.js +4 -0
- package/apps/storefront/node_modules/.vite/deps/preact_debug.js +236 -0
- package/apps/storefront/node_modules/.vite/deps/preact_debug.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/preact_devtools.js +12 -0
- package/apps/storefront/node_modules/.vite/deps/preact_devtools.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/preact_hooks.js +192 -0
- package/apps/storefront/node_modules/.vite/deps/preact_hooks.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/preact_jsx-dev-runtime.js +3 -0
- package/apps/storefront/node_modules/.vite/deps/preact_jsx-runtime.js +92 -0
- package/apps/storefront/node_modules/.vite/deps/preact_jsx-runtime.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/preact_test-utils.js +59 -0
- package/apps/storefront/node_modules/.vite/deps/preact_test-utils.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/rolldown-runtime-BPOCksWG.js +24 -0
- package/apps/storefront/node_modules/.vite/deps/settings-BVDV0Roj.js +189 -0
- package/apps/storefront/node_modules/.vite/deps/settings-BVDV0Roj.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/static-html-DveYqwMo.js +13 -0
- package/apps/storefront/node_modules/.vite/deps/static-html-DveYqwMo.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/toolbar-B3VIHCXg.js +458 -0
- package/apps/storefront/node_modules/.vite/deps/toolbar-B3VIHCXg.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/ui-library-BK6ricUd.js +1016 -0
- package/apps/storefront/node_modules/.vite/deps/ui-library-BK6ricUd.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/window-BvxDenKN.js +32 -0
- package/apps/storefront/node_modules/.vite/deps/window-BvxDenKN.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps/xray-YFiRUU0Z.js +113 -0
- package/apps/storefront/node_modules/.vite/deps/xray-YFiRUU0Z.js.map +1 -0
- package/apps/storefront/node_modules/.vite/deps_ssr/_metadata.json +8 -0
- package/apps/storefront/node_modules/.vite/deps_ssr/package.json +3 -0
- package/apps/storefront/src/components/chrome/MegaMenu.astro +11 -1
- package/apps/storefront/src/components/chrome/NavDropdown.astro +49 -7
- package/apps/storefront/src/components/chrome/SiteFooter.astro +29 -4
- package/apps/storefront/src/components/chrome/SiteHeader.astro +39 -6
- package/apps/storefront/src/components/chrome/UtilityNav.astro +13 -1
- package/apps/storefront/src/components/islands/MobileNav.tsx +42 -2
- package/apps/storefront/src/layouts/Layout.astro +65 -7
- package/apps/storefront/src/lib/apps/registryAccess.ts +45 -0
- package/apps/storefront/src/lib/auth/adminEntry.ts +4 -4
- package/apps/storefront/src/lib/chrome/ctaIdentity.ts +21 -0
- package/apps/storefront/src/lib/chrome/examples/tokenoftrust.chrome.example.json +22 -22
- package/apps/storefront/src/lib/chrome/trackingPlacements.ts +36 -0
- package/apps/storefront/src/lib/d1/schema-apps.ts +57 -0
- package/apps/storefront/src/lib/rawChrome.ts +73 -14
- package/apps/storefront/src/lib/rawMarketingHtml.ts +30 -0
- package/apps/storefront/src/lib/shared/capability.ts +68 -0
- package/apps/storefront/src/lib/shared/developerInvites.ts +1250 -0
- package/apps/storefront/src/lib/shared/invitePreflight.ts +123 -0
- package/apps/storefront/src/lib/storyblok/provider.ts +21 -5
- package/apps/storefront/src/lib/tracking/collectorAllowlist.ts +40 -0
- package/apps/storefront/src/lib/tracking/collectorIngest.ts +188 -0
- package/apps/storefront/src/lib/tracking/d1Control.ts +180 -0
- package/apps/storefront/src/lib/tracking/interactionCollector.ts +202 -0
- package/apps/storefront/src/lib/tracking/interactionHealth.ts +252 -0
- package/apps/storefront/src/lib/tracking/interactionSchema.ts +107 -0
- package/apps/storefront/src/lib/tracking/interactionStore.ts +0 -0
- package/apps/storefront/src/lib/tracking/pruneRollup.ts +260 -0
- package/apps/storefront/src/lib/tracking/publishSafetyNet.ts +162 -0
- package/apps/storefront/src/lib/tracking/tenantD1Registry.ts +214 -0
- package/apps/storefront/src/lib/tracking/tenantReadAccess.ts +138 -0
- package/apps/storefront/src/lib/tracking-writeback/rewriteTrackingIds.ts +175 -0
- package/apps/storefront/src/lib/tracking-writeback/writebackCandidate.ts +110 -0
- package/apps/storefront/src/lib/webhooks/deliveryEngine.ts +38 -4
- package/apps/storefront/src/lib/webhooks/emit.ts +1 -1
- package/apps/storefront/src/lib/webhooks/getDispatcher.ts +1 -1
- package/apps/storefront/src/pages/api/admin/interaction-insights.ts +101 -0
- package/apps/storefront/src/pages/api/auth/magic-exchange.ts +1 -1
- package/apps/storefront/src/pages/api/tracking/interaction.ts +123 -0
- package/apps/storefront/src/pages/index.astro +1 -1
- package/apps/storefront/src/pages/products/[handle].astro +1 -1
- package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +33 -14
- package/apps/storefront/src/workers/pruneRollupWorker.ts +120 -0
- package/package.json +1 -1
- package/packages/public-runtime/node_modules/.bin/tsc +43 -0
- package/packages/public-runtime/node_modules/.bin/tsserver +43 -0
- package/packages/public-runtime/node_modules/.bin/vitest +43 -0
- package/packages/public-runtime/src/chrome.ts +18 -7
- package/packages/public-runtime/src/index.ts +10 -0
- package/packages/public-runtime/src/integration/index.ts +1 -0
- package/packages/public-runtime/src/integration/interaction-tracking-gate.ts +142 -0
- package/packages/public-runtime/src/interaction-tracking.ts +364 -0
- package/packages/public-runtime/src/tracking-id-derivation.ts +1008 -0
- package/pnpm-workspace.yaml +3 -0
- package/scripts/tenant/validate.mjs +306 -1
- package/scripts/tot-dev.mjs +59 -3
- package/tenants/home/public/js/storefront.js +6 -87
- package/apps/storefront/src/lib/apps/adminService.ts +0 -116
- package/apps/storefront/src/lib/apps/adminSession.ts +0 -214
- package/apps/storefront/src/lib/apps/apiAuth.ts +0 -91
- package/apps/storefront/src/lib/apps/apiRoute.ts +0 -35
- package/apps/storefront/src/lib/apps/catalogMapper.ts +0 -39
- package/apps/storefront/src/lib/apps/consentConfigStore.ts +0 -228
- package/apps/storefront/src/lib/apps/gtmMigrationInstall.ts +0 -447
- package/apps/storefront/src/lib/apps/gtmMigrationReport.ts +0 -367
- package/apps/storefront/src/lib/apps/gtmMigrationTab.ts +0 -315
- package/apps/storefront/src/lib/apps/healthAggregate.ts +0 -66
- package/apps/storefront/src/lib/apps/integrationLifecycle.ts +0 -614
- package/apps/storefront/src/lib/apps/integrationLifecycleController.ts +0 -125
- package/apps/storefront/src/lib/apps/integrationReadModel.ts +0 -563
- package/apps/storefront/src/lib/apps/integrationsTab.ts +0 -547
- package/apps/storefront/src/lib/apps/integrationsView.ts +0 -106
- package/apps/storefront/src/lib/apps/managedAnalyticsService.ts +0 -599
- package/apps/storefront/src/lib/apps/tenantIntegrationMaterialization.ts +0 -170
- package/apps/storefront/src/lib/apps/tokenIssuer.ts +0 -121
- package/apps/storefront/src/lib/apps/tokenVerifier.ts +0 -148
- package/apps/storefront/src/lib/apps/v84CleanCloneReplayContext.ts +0 -16
- package/apps/storefront/src/lib/apps/v84CloneLifecycleCacheEvidence.ts +0 -105
- package/apps/storefront/src/lib/apps/v84CloneLifecycleEvidence.ts +0 -177
- package/apps/storefront/src/lib/apps/v84CloneLifecycleMaterialization.ts +0 -251
- package/apps/storefront/src/lib/apps/v84CloneLifecycleSemantics.ts +0 -241
- package/apps/storefront/src/lib/apps/v84CloneMigrationEvidence.ts +0 -211
- package/apps/storefront/src/lib/apps/v84CloneNativeInstallEvidence.ts +0 -409
- package/apps/storefront/src/lib/apps/v84NonportableCodeCoaching.ts +0 -368
- package/apps/storefront/src/lib/apps/v84NonportableRouteContract.ts +0 -188
- package/apps/storefront/src/lib/apps/v84NonportableRouteDogfood.ts +0 -291
- package/apps/storefront/src/lib/apps/ws1ManagedAnalyticsClient.ts +0 -95
- package/apps/storefront/src/lib/content-edit/applyDiff.ts +0 -91
- package/apps/storefront/src/lib/content-edit/client.ts +0 -286
- package/apps/storefront/src/lib/content-edit/index.ts +0 -14
- package/apps/storefront/src/lib/content-edit/route.ts +0 -25
- package/apps/storefront/src/lib/content-edit/types.ts +0 -19
- 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 =
|
|
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
|
-
|
|
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/
|
|
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/
|
|
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/
|
|
28
|
+
import { acceptDeveloperInvite } from "@/lib/shared/developerInvites";
|
|
29
29
|
|
|
30
30
|
export const prerender = false;
|
|
31
31
|
|