@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,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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|