@tokenoftrust/storefront-runner 2.0.17 → 2.0.19

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 (98) hide show
  1. package/apps/storefront/env.d.ts +21 -0
  2. package/apps/storefront/migrations-apps/0012_test_destinations.sql +29 -0
  3. package/apps/storefront/src/components/blog/ReactionBar.astro +1 -1
  4. package/apps/storefront/src/components/islands/CheckoutComplianceGate.tsx +365 -49
  5. package/apps/storefront/src/components/islands/checkoutVerificationFlow.ts +251 -0
  6. package/apps/storefront/src/config/compliance/rulesets.ts +18 -7
  7. package/apps/storefront/src/config/devTenantSeed.ts +24 -1
  8. package/apps/storefront/src/config/evidenceTenantRoster.ts +25 -0
  9. package/apps/storefront/src/config/publicationSpaceConfig.ts +25 -0
  10. package/apps/storefront/src/config/resolver.ts +5 -0
  11. package/apps/storefront/src/config/tenantAnalyticsFiles.ts +43 -0
  12. package/apps/storefront/src/config/tenants.ts +11 -0
  13. package/apps/storefront/src/layouts/Layout.astro +5 -1
  14. package/apps/storefront/src/lib/activity/killSwitch.ts +1 -1
  15. package/apps/storefront/src/lib/admin/consentSettingsClient.ts +113 -0
  16. package/apps/storefront/src/lib/apps/consentConfigRead.ts +24 -0
  17. package/apps/storefront/src/lib/apps/gtmMigrationDraft.ts +103 -0
  18. package/apps/storefront/src/lib/auth/brokerAssertion.ts +18 -0
  19. package/apps/storefront/src/lib/auth/identityToken.ts +11 -1
  20. package/apps/storefront/src/lib/auth/internalContentAuthority.ts +153 -0
  21. package/apps/storefront/src/lib/auth/loginGate.ts +25 -0
  22. package/apps/storefront/src/lib/auth/session.ts +3 -0
  23. package/apps/storefront/src/lib/checkoutCommerce.ts +1 -1
  24. package/apps/storefront/src/lib/compliance/enforcement.ts +25 -3
  25. package/apps/storefront/src/lib/d1/schema-apps.ts +44 -0
  26. package/apps/storefront/src/lib/edgeCache.ts +17 -1
  27. package/apps/storefront/src/lib/env.ts +13 -111
  28. package/apps/storefront/src/lib/integration/tenantAnalyticsDeclarations.ts +198 -0
  29. package/apps/storefront/src/lib/monitoring/manifest.ts +1 -1
  30. package/apps/storefront/src/lib/publication/contentProvider.ts +159 -0
  31. package/apps/storefront/src/lib/publication/runtimeSource.ts +97 -0
  32. package/apps/storefront/src/lib/rawMarketingHtml.ts +9 -1
  33. package/apps/storefront/src/lib/runtimeEnv.ts +124 -0
  34. package/apps/storefront/src/lib/verification/totVerificationClient.ts +485 -0
  35. package/apps/storefront/src/middleware/candidatePin.ts +35 -0
  36. package/apps/storefront/src/middleware/controlPlane.ts +291 -0
  37. package/apps/storefront/src/middleware/index.ts +200 -793
  38. package/apps/storefront/src/middleware/publicRender.ts +43 -0
  39. package/apps/storefront/src/middleware/renderChain.ts +730 -0
  40. package/apps/storefront/src/middleware/routeBranch.ts +192 -0
  41. package/apps/storefront/src/middleware/staticBundle.ts +109 -0
  42. package/apps/storefront/src/pages/[...slug].astro +1 -0
  43. package/apps/storefront/src/pages/api/auth/magic-exchange.ts +1 -0
  44. package/apps/storefront/src/pages/api/auth/step-up-verify.ts +1 -0
  45. package/apps/storefront/src/pages/api/auth/verify.ts +1 -0
  46. package/apps/storefront/src/pages/api/compliance/preflight.ts +179 -2
  47. package/apps/storefront/src/pages/index.astro +1 -0
  48. package/apps/storefront/vitest.config.ts +5 -0
  49. package/package.json +1 -1
  50. package/packages/public-runtime/package.json +1 -0
  51. package/packages/public-runtime/schemas/managed-analytics/v1/deliver-test-event-request.schema.json +122 -0
  52. package/packages/public-runtime/schemas/managed-analytics/v1/get-test-destination-delivery-request.schema.json +79 -0
  53. package/packages/public-runtime/schemas/managed-analytics/v1/register-test-destination-request.schema.json +93 -0
  54. package/packages/public-runtime/schemas/managed-analytics/v1/test-destination-delivery.schema.json +106 -0
  55. package/packages/public-runtime/schemas/managed-analytics/v1/test-destination.schema.json +77 -0
  56. package/packages/public-runtime/schemas/publication-space/v1/manifest.schema.json +195 -0
  57. package/packages/public-runtime/schemas/publication-space/v1/normalized-mount.schema.json +80 -0
  58. package/packages/public-runtime/schemas/publication-space/v1/ref.schema.json +20 -0
  59. package/packages/public-runtime/schemas/publication-version/v1/pointer.schema.json +28 -0
  60. package/packages/public-runtime/schemas/publication-version/v1/transition-request.schema.json +44 -0
  61. package/packages/public-runtime/schemas/publication-version/v1/transition.schema.json +47 -0
  62. package/packages/public-runtime/schemas/publication-version/v1/version.schema.json +58 -0
  63. package/packages/public-runtime/src/activity/catalog.ts +1 -1
  64. package/packages/public-runtime/src/candidate-index.ts +6 -0
  65. package/packages/public-runtime/src/customization-reconcile.ts +8 -0
  66. package/packages/public-runtime/src/html-cache-policy.ts +57 -0
  67. package/packages/public-runtime/src/index.ts +9 -0
  68. package/packages/public-runtime/src/integration/consent-broker.ts +115 -0
  69. package/packages/public-runtime/src/integration/interim-bridge.ts +111 -27
  70. package/packages/public-runtime/src/integration/managed-analytics-api-v1.schemas.ts +65 -0
  71. package/packages/public-runtime/src/integration/managed-analytics-api-v1.ts +81 -0
  72. package/packages/public-runtime/src/integration/render-phases.ts +8 -3
  73. package/packages/public-runtime/src/publication-artifact.ts +220 -0
  74. package/packages/public-runtime/src/publication-reader.ts +187 -0
  75. package/packages/public-runtime/src/publication-routing.ts +246 -0
  76. package/packages/public-runtime/src/publication-space.ts +853 -0
  77. package/packages/public-runtime/src/publication-version.ts +602 -0
  78. package/packages/public-runtime/src/route-tier.ts +257 -0
  79. package/packages/public-runtime/src/static-bundle.ts +18 -5
  80. package/packages/public-runtime/src/tenant-assets.ts +85 -17
  81. package/packages/public-runtime/src/tenant.ts +70 -0
  82. package/packages/public-runtime/tests/fixtures/publication-spaces/internal.json +32 -0
  83. package/packages/public-runtime/tests/fixtures/publication-spaces/mounted-public.json +38 -0
  84. package/packages/public-runtime/tests/fixtures/publication-spaces/normalized-mounts.json +39 -0
  85. package/packages/public-runtime/tests/fixtures/publication-spaces/ref.json +5 -0
  86. package/packages/public-runtime/tests/fixtures/publication-spaces/root-public.json +31 -0
  87. package/packages/public-runtime/tests/fixtures/publication-versions/articles-v1.json +20 -0
  88. package/packages/public-runtime/tests/fixtures/publication-versions/articles-v2.json +20 -0
  89. package/packages/public-runtime/tests/fixtures/publication-versions/internal-v1.json +20 -0
  90. package/packages/public-runtime/tests/fixtures/publication-versions/live-pointer.json +11 -0
  91. package/packages/public-runtime/tests/fixtures/publication-versions/live-transition-request.json +14 -0
  92. package/packages/public-runtime/tests/fixtures/publication-versions/live-transition.json +17 -0
  93. package/packages/public-runtime/tests/fixtures/publication-versions/root-v1.json +20 -0
  94. package/packages/public-runtime/tests/fixtures/publication-versions/root-v2.json +20 -0
  95. package/scripts/dev/ai-edit.mjs +16 -3
  96. package/scripts/dev/port-check.mjs +18 -3
  97. package/scripts/dev/publish.mjs +26 -3
  98. package/scripts/tenant/validate.mjs +14 -1
@@ -0,0 +1,730 @@
1
+ /**
2
+ * The render chain both serving branches share: static-bundle serving, the
3
+ * integration plane, the edge page cache, theme/content/capability resolution,
4
+ * the render itself, and the response headers stamped over it.
5
+ *
6
+ * Everything that needs D1 arrives as an already-resolved INPUT. That is what
7
+ * keeps the public branch free of a request-time D1 read: `publicRender` passes
8
+ * the empty/absent forms, `controlPlane` passes what it read. This module
9
+ * imports no D1 reader of its own.
10
+ */
11
+ import type { APIContext, MiddlewareNext } from "astro";
12
+ import { referenceTheme } from "@/themes/reference";
13
+ import { mergeTheme, themeToCssVars } from "@/themes/schema";
14
+ import { createToTClient } from "@/lib/tot/ToTClient";
15
+ import { readEnv } from "@/lib/runtimeEnv";
16
+ import { tryServeStaticBundle } from "./staticBundle.js";
17
+ import { resolveCandidateContentPin } from "./candidatePin.js";
18
+ import { createContentProvider, DevDraftContentProvider } from "@/lib/storyblok/provider";
19
+ import { bindPublicationSpaceContentProviderV1 } from "@/lib/publication/contentProvider.js";
20
+ import { resolvePublicationRuntimeSourceV1 } from "@/lib/publication/runtimeSource.js";
21
+ import { DEV_DRAFT_COOKIE, devDraftKey, verifyDevDraftToken } from "@/lib/dev/devDraft";
22
+ import {
23
+ isCacheablePath,
24
+ pageCacheKey,
25
+ readCacheVersion,
26
+ getCachedPage,
27
+ putCachedPage,
28
+ } from "@/lib/edgeCache";
29
+ import { STATIC_SCRIPT_HASHES, STATIC_STYLE_HASHES } from "@/lib/cspStaticHashes";
30
+ import { applySecurityHeaders } from "@/lib/securityHeaders";
31
+ import { maybeInjectPreviewReload } from "@/lib/previewReload";
32
+ import {
33
+ resolveCustomizationEnv,
34
+ buildCsp,
35
+ STOREFRONT_CSP_ALLOWLIST,
36
+ checkoutCspAdditions,
37
+ appWidgetCspAdditions,
38
+ embedCspAdditions,
39
+ foldIntegrationCsp,
40
+ embedsArtifactPath,
41
+ resolveEffectiveEmbeds,
42
+ CSP_REPORT_GROUP,
43
+ CSP_REPORT_PATH,
44
+ readPublishedArtifact,
45
+ themeArtifactPath,
46
+ resolveTenantFeatures,
47
+ resolveCapabilities,
48
+ capabilitiesArtifactPath,
49
+ resolveServableChannelVersion,
50
+ channelFromEnv,
51
+ resolveEnvironmentOnlyPlan,
52
+ resolveInterimBridgePlan,
53
+ compileIntegrationCspFromPlan,
54
+ collectDeclaredCspProfiles,
55
+ INTEGRATION_NONCE_PLACEHOLDER,
56
+ defaultConsentSnapshot,
57
+ parseConsentSnapshot,
58
+ CONSENT_SNAPSHOT_INPUT_HEADER,
59
+ CONSENT_SNAPSHOT_SIG_HEADER,
60
+ CONSENT_SNAPSHOT_HMAC_SECRET_BINDING,
61
+ MATERIALIZE_MODE_BINDING,
62
+ verifyConsentSnapshotSig,
63
+ } from "@tot/public-runtime";
64
+ import type {
65
+ CapabilityConfig,
66
+ ConsentSnapshot,
67
+ DeepPartial,
68
+ IntegrationDeclarationInput,
69
+ TenantConfig,
70
+ ThemeTokens,
71
+ BrowserEnvironment,
72
+ PublicationArtifactBucketV1,
73
+ PublicationRoutePlanV1,
74
+ RouteTierIndexV1,
75
+ } from "@tot/public-runtime";
76
+ import {
77
+ formatHtmlCacheControl,
78
+ HTML_CACHE_POLICY_CAPTURE_HEADER,
79
+ HTML_CACHE_POLICY_CAPTURE_PROBE,
80
+ } from "@tot/public-runtime";
81
+ import { loadTenantIntegrationDeclarations } from "@/lib/integration/tenantDeclarations";
82
+ import { resolveTenantCommerce } from "@/lib/checkoutCommerce";
83
+ import { requestBrowserEnvironment, deriveSiteSurface, cspHeaderName } from "@/lib/integration/browserEnvironment";
84
+ import { freshNonce, stampResponseNonce } from "@/lib/integration/nonceStamp";
85
+ import { recordActivity } from "@/lib/activity/recordActivity";
86
+ import { middlewareRewriteTargetV1 } from "./routeBranch.js";
87
+ import {
88
+ applyInternalContentResponsePolicy,
89
+ internalContentGrantMatchesPlan,
90
+ type InternalContentGrant,
91
+ } from "@/lib/auth/internalContentAuthority";
92
+
93
+ export interface RenderChainInput {
94
+ context: APIContext;
95
+ next: MiddlewareNext;
96
+ host: string;
97
+ url: URL;
98
+ tenant: TenantConfig;
99
+ basePath: string;
100
+ /** Tenant-relative path (selection prefix stripped). */
101
+ renderPath: string;
102
+ /** Workers Cache API default cache; undefined under `astro dev`. */
103
+ cache: Cache | undefined;
104
+ kv: KVNamespace | undefined;
105
+ routeTierIndex: RouteTierIndexV1;
106
+ publicationRequestPath: string;
107
+ publicationRoute: PublicationRoutePlanV1;
108
+ publicationBucket: PublicationArtifactBucketV1 | undefined;
109
+ /**
110
+ * Managed-install declarations, read from D1 by the caller. Empty on the public
111
+ * branch: a managed install is admin-installed state, and ADR 0013 puts that
112
+ * behind a publish-time bake, never a per-request read on the public path.
113
+ */
114
+ managedIntegrationDeclarations: readonly IntegrationDeclarationInput[];
115
+ /**
116
+ * Reads the tenant's saved consent revision (D1). `null` on the public branch,
117
+ * which falls back to the fail-closed essential-only default — the posture the
118
+ * baked-snapshot path already documents for the public/materialize render.
119
+ */
120
+ readTenantConsentSnapshot: (() => Promise<ConsentSnapshot | null>) | null;
121
+ /**
122
+ * Resolves the D1 catalog read replica. `null` on the public branch. Called at
123
+ * the point the catalog is actually built — after the static bundle and the
124
+ * edge cache have each had their chance to answer — so a request served from R2
125
+ * or a warm cache entry costs no D1 read. Typed off `createToTClient`'s own
126
+ * parameter rather than the D1 module, so this file names no D1 type at all.
127
+ */
128
+ readCatalogD1: (() => Promise<Parameters<typeof createToTClient>[1]>) | null;
129
+ /** Exact per-request grant for an F2-selected internal publication space. */
130
+ internalContentGrant?: InternalContentGrant;
131
+ }
132
+
133
+ export async function runRenderChain(input: RenderChainInput): Promise<Response> {
134
+ const { context, next, host, url, tenant, basePath, renderPath, cache, kv } = input;
135
+ const { request, locals } = context;
136
+ const isInternalPublication = input.publicationRoute.action !== "fail-closed" &&
137
+ input.publicationRoute.manifest.visibility === "internal";
138
+ if (isInternalPublication &&
139
+ !internalContentGrantMatchesPlan(input.internalContentGrant, input.publicationRoute)) {
140
+ const response = new Response("Not Found", { status: 404 });
141
+ applyInternalContentResponsePolicy(response.headers);
142
+ applySecurityHeaders(response.headers);
143
+ return response;
144
+ }
145
+
146
+ // Preview/test detection (also reused for the customization env below). A
147
+ // production read is one with no preview host, query token, or preview cookie.
148
+ const isPreviewHost = host.toLowerCase().startsWith("preview.");
149
+ const queryToken = url.searchParams.get("__preview");
150
+ const cookieToken = context.cookies.get("tot_preview")?.value ?? null;
151
+
152
+ // Candidate-render CONTENT PIN (`__tot_candidate_pin`): a signed, tenant- and
153
+ // expiry-bound token minted ONLY by the control-plane candidate/revision
154
+ // renderers (`src/lib/preview/pinnedRouteRender.ts`, never client-forgeable)
155
+ // that pins THIS one render's content resolution to an exact immutable
156
+ // `versionId`, bypassing the shared `{live,test}` channel entirely. It rides
157
+ // an in-process `context.rewrite()` from those renderers so a real Astro
158
+ // route (blog, editorial pages, …) — not just the raw `home.html`/
159
+ // `pages-html/*` documents they serve directly — renders pinned to the
160
+ // candidate under review (fb-1788536381607). Verified against this tenant so a
161
+ // pin can only ever name its own version; a mismatched/expired/absent token
162
+ // falls through to the normal channel-resolved read below, same fail-closed
163
+ // posture as the `__preview` token.
164
+ const candidatePin = await resolveCandidateContentPin(url, tenant.tenant_id);
165
+ // Per-developer AI-edit draft: the signed
166
+ // tot_dev_draft cookie's mere PRESENCE opts the request out of the shared page
167
+ // cache below (a draft view must never be cached publicly); it's verified —
168
+ // signature, expiry, tenant binding — before the overlay is applied further down.
169
+ const draftCookie = context.cookies.get(DEV_DRAFT_COOKIE)?.value ?? null;
170
+
171
+ // Resolve preview/live once before any publication source, static-bundle, or
172
+ // page-cache read. An exact publication selection is therefore authoritative
173
+ // for the whole request and can scope every downstream cache decision.
174
+ const previewToken = queryToken ?? cookieToken;
175
+ const previewSecret = (await readEnv("CUSTOMIZATION_PREVIEW_SECRET")) ?? null;
176
+ const nowSeconds = Math.floor(Date.now() / 1000);
177
+ const customization = await resolveCustomizationEnv({
178
+ isPreviewHost,
179
+ tenantId: tenant.tenant_id,
180
+ token: previewToken,
181
+ secret: previewSecret,
182
+ nowSeconds,
183
+ pathname: url.pathname,
184
+ });
185
+ if (customization.reject) {
186
+ return new Response(customization.reject.message, {
187
+ status: customization.reject.status,
188
+ });
189
+ }
190
+ if (customization.env === "test" && queryToken) {
191
+ context.cookies.set("tot_preview", queryToken, {
192
+ httpOnly: true,
193
+ secure: true,
194
+ sameSite: "lax",
195
+ path: "/",
196
+ maxAge: 3600,
197
+ });
198
+ }
199
+
200
+ const publicationSource = await resolvePublicationRuntimeSourceV1({
201
+ index: input.routeTierIndex,
202
+ requestPath: input.publicationRequestPath,
203
+ plan: input.publicationRoute,
204
+ channel: channelFromEnv(customization.env),
205
+ store: kv ? { get: (key) => kv.get(key) } : undefined,
206
+ bucket: input.publicationBucket,
207
+ });
208
+ if (publicationSource.status === "unavailable") {
209
+ const response = new Response("Not Found", {
210
+ status: 404,
211
+ headers: { "Cache-Control": "private, no-store" },
212
+ });
213
+ applySecurityHeaders(response.headers);
214
+ if (isInternalPublication) applyInternalContentResponsePolicy(response.headers);
215
+ return response;
216
+ }
217
+
218
+ const managedIntegrationDeclarations = input.managedIntegrationDeclarations;
219
+
220
+ // Digest-addressed static-bundle serving. Deliberately before the edge page
221
+ // cache/catalog/theme machinery. A selected publication bypasses this legacy
222
+ // whole-site bundle until B1 can compose mounted spaces into it reproducibly.
223
+ const bundled = publicationSource.status === "legacy-root" ? await tryServeStaticBundle({
224
+ request,
225
+ url,
226
+ tenant,
227
+ basePath,
228
+ cache,
229
+ kv,
230
+ isPreviewHost,
231
+ hasPreviewToken: !!queryToken || !!cookieToken,
232
+ hasCandidatePin: !!candidatePin,
233
+ hasDraftCookie: !!draftCookie,
234
+ hasManagedInstalls: managedIntegrationDeclarations.length > 0,
235
+ }) : null;
236
+ if (bundled) return bundled;
237
+
238
+ // ---- Tenant-local integration materialization (ext-kernel-09) --------------
239
+ // Resolve a tenant's OWN declared, exact-version integrations into the effective
240
+ // plan the render engine serves, THROUGH the interim marketing bridge (which
241
+ // refuses a regulated tenant and fail-closes an invalid declaration set). Done
242
+ // HERE, before the edge-cache read, so a declared tenant's `planRevision` folds
243
+ // into the page-cache key below (a rollback/uninstall resolves a new revision →
244
+ // the old warm entry is unreachable → a clean re-render without the removed
245
+ // origins, never a TTL wait) AND its reviewed CSP origins reach the strict header.
246
+ //
247
+ // An UNDECLARED tenant does NONE of this: `loadTenantIntegrationDeclarations`
248
+ // returns the empty set, so `integrationCsp` stays undefined (the page-cache key
249
+ // is byte-identical to the non-integration form) and the environment-only plan is
250
+ // resolved below exactly as before — zero cost.
251
+ let resolvedBrowserEnvironment: BrowserEnvironment | null = null;
252
+ const integrationDeclarations = [
253
+ ...loadTenantIntegrationDeclarations(tenant),
254
+ ...managedIntegrationDeclarations,
255
+ ];
256
+ if (integrationDeclarations.length > 0) {
257
+ const declFeatures = resolveTenantFeatures(tenant);
258
+ resolvedBrowserEnvironment = requestBrowserEnvironment({
259
+ host,
260
+ isPreviewHost,
261
+ customizationEnv: customization.env,
262
+ canonicalHost: tenant.canonicalDomain || host,
263
+ siteSurface: deriveSiteSurface({ catalog: declFeatures.catalog, pathname: renderPath }),
264
+ });
265
+ const bridge = resolveInterimBridgePlan({
266
+ tenantPublicId: tenant.tenant_id,
267
+ tenant,
268
+ environment: resolvedBrowserEnvironment,
269
+ declarations: integrationDeclarations,
270
+ });
271
+ if (bridge.ok) {
272
+ // A resolved plan serves; its compiled CSP additions + `planRevision` reach the
273
+ // cache key and the header. A refusal (regulated) or an invalid set leaves the
274
+ // plan unset here → the environment-only fallback below fills it (nothing loads).
275
+ locals.integrationPlan = bridge.plan;
276
+ const compiled = compileIntegrationCspFromPlan(
277
+ bridge.plan,
278
+ collectDeclaredCspProfiles(integrationDeclarations),
279
+ );
280
+ if (compiled.ok) locals.integrationCsp = compiled.value;
281
+ }
282
+ }
283
+
284
+ // ---- Consent snapshot: the tenant's ACTUAL initial consent state, not a
285
+ // hardcoded default. Both document mounts (Layout.astro, rawHtmlResponse) inline
286
+ // THIS value; an unresolved tenant stays on the fail-closed essential-only default.
287
+ locals.consentSnapshot = defaultConsentSnapshot();
288
+ // BAKED-SNAPSHOT INPUT (ADR 0013), honored ONLY on the materialize render, ONLY
289
+ // under a valid tenant-bound signature. The go-live bake pipeline resolves the
290
+ // tenant's REAL snapshot from D1 ONCE on the Worker, signs it, and provides
291
+ // {snapshot, sig} to the materialize render via these materialize-only request
292
+ // headers — so the static bundle inlines the real snapshot with NO consent D1
293
+ // read on the public/materialize path. TWO gates make the header un-forgeable:
294
+ // 1. MATERIALIZE-MODE binding: honored only when `TOT_MATERIALIZE_MODE` is set — a
295
+ // binding the materializer's wrangler dev injects and the DEPLOYED Worker never
296
+ // has. So a client cannot forge consent on the live Worker (which serves
297
+ // preview/admin/gated + the public static bundle), and no cacheable response ever
298
+ // varies on the header (it is never read there, so it cannot enter a cache key).
299
+ // 2. HMAC signature over a length-prefixed framing of (tenant_id, snapshot),
300
+ // verified constant-time against the go-live secret and THIS tenant's id — so a
301
+ // caller who forges the dispatch payload (no secret) or replays another tenant's
302
+ // signed snapshot is rejected.
303
+ // Either gate failing ⇒ fall through to the caller-supplied reader (control plane
304
+ // only), never a caller-supplied snapshot.
305
+ let bakedConsent: ReturnType<typeof parseConsentSnapshot> = null;
306
+ if ((await readEnv(MATERIALIZE_MODE_BINDING)) === "1") {
307
+ const consentInputHeader = request.headers.get(CONSENT_SNAPSHOT_INPUT_HEADER);
308
+ const consentSig = request.headers.get(CONSENT_SNAPSHOT_SIG_HEADER);
309
+ const hmacSecret = await readEnv(CONSENT_SNAPSHOT_HMAC_SECRET_BINDING);
310
+ if (
311
+ consentInputHeader &&
312
+ hmacSecret &&
313
+ (await verifyConsentSnapshotSig(hmacSecret, tenant.tenant_id, consentInputHeader, consentSig))
314
+ ) {
315
+ bakedConsent = parseConsentSnapshot(consentInputHeader);
316
+ }
317
+ }
318
+ if (bakedConsent) {
319
+ locals.consentSnapshot = bakedConsent;
320
+ } else if (input.readTenantConsentSnapshot) {
321
+ const saved = await input.readTenantConsentSnapshot();
322
+ if (saved) locals.consentSnapshot = saved;
323
+ }
324
+
325
+ // ---- Edge page cache: short-circuit production browse reads on a hit,
326
+ // BEFORE building the client / theme / rendering. Misses store the final
327
+ // response at the end of this middleware.
328
+ //
329
+ // NEVER in dev (`astro dev`): the workerd dev runtime persists the Workers Cache
330
+ // API in .wrangler/state, so a cached page would be served stale across edits AND
331
+ // restarts — silently defeating hot-reload (edits look "lost"). Dev must always
332
+ // re-render. import.meta.env.DEV statically drops this guard from the prod build.
333
+ const pageCacheEligible =
334
+ !import.meta.env.DEV &&
335
+ request.method.toUpperCase() === "GET" &&
336
+ !isPreviewHost &&
337
+ !queryToken &&
338
+ !cookieToken &&
339
+ !candidatePin &&
340
+ !draftCookie &&
341
+ !isInternalPublication &&
342
+ isCacheablePath(url.pathname, url.searchParams);
343
+ let pageKey: Request | null = null;
344
+ if (pageCacheEligible && cache && kv) {
345
+ const version = await readCacheVersion(kv, tenant.tenant_id);
346
+ // Fold the resolved integration plan revision into the key so a warm entry is
347
+ // version-scoped: a rollback/uninstall resolves a new revision → old entry
348
+ // unreachable → clean re-render without the removed origins (never a TTL wait).
349
+ // Undefined until the integration plane is resolved for this tenant — the key
350
+ // is then byte-identical to the non-integration form. The store below reuses
351
+ // this same `pageKey`, so read and write stay consistent.
352
+ pageKey = pageCacheKey(
353
+ tenant.tenant_id,
354
+ url.pathname,
355
+ url.search,
356
+ version,
357
+ locals.integrationCsp?.planRevision,
358
+ publicationSource.status === "selected" ? publicationSource.cacheScope : undefined,
359
+ );
360
+ const hit = await getCachedPage(cache, pageKey);
361
+ // The cached artifact is nonce-free (carries the placeholder). Stamp a fresh
362
+ // per-response nonce into its body + CSP header so a warm hit is served with a
363
+ // brand-new nonce, never the one an earlier response rendered with.
364
+ if (hit) return await stampResponseNonce(hit, freshNonce());
365
+ }
366
+
367
+ // Catalog source selection (docs/architecture/product-catalog.md).
368
+ // A store's product source graduates over its life: JSON fixtures (dev default) ->
369
+ // D1 edge replica (staging) -> hosted ToT catalog (go-live). The read backend is
370
+ // chosen by the CALLER: the control plane resolves the D1 edge replica when
371
+ // USE_D1_CATALOG=1; the public branch never does, so `createToTClient` falls back
372
+ // to the bundled JSON fixture snapshot. The hosted catalog graduates the SAME
373
+ // canonical records into the D1 read model (emitCatalogSql), so JSON/D1/hosted
374
+ // render identically — pinned by lib/tot/sourceSelection.test.ts.
375
+ const tot = await createToTClient(tenant, await input.readCatalogD1?.());
376
+
377
+ const proto = url.protocol === "http:" ? "http" : "https";
378
+ // Canonical base uses the tenant's canonical domain in prod; for local dev we
379
+ // keep the actual host (plus any /<domain> base path) so links resolve.
380
+ const isLocal = host.startsWith("localhost") || host.startsWith("127.0.0.1");
381
+ const canonicalBase = isLocal
382
+ ? `${proto}://${host}${basePath}`
383
+ : `https://${tenant.canonicalDomain}`;
384
+
385
+ // Apply a published theme override for the resolved environment (store->app).
386
+ // The live domain serves `live`; a gated preview serves `test`. When nothing
387
+ // is published the build-time tenant theme is used unchanged.
388
+ //
389
+ // Resolution is versioned-aware inside readPublishedArtifact: env maps to a
390
+ // publish channel (live->live, test->preview); if the tenant has a channel
391
+ // pointer, the artifact is resolved through the immutable site version
392
+ // (channel -> versionId -> manifest -> content). Tenants with no channel
393
+ // pointer fall back to the legacy per-path selection — no behavior change.
394
+ const kvGet = kv ? (key: string) => kv.get(key) : async () => null;
395
+
396
+ // Self-heal a DANGLING channel pointer: the pointer for this env's channel may
397
+ // name a version that was never built (or whose manifest was evicted).
398
+ // `readVersionedArtifact` already serves last-built content instead of emptying
399
+ // the surface; here we do the once-per-request detection to ENQUEUE a rebuild of
400
+ // the dangling version. Fail-open: a KV hiccup never blocks the render (the reads
401
+ // below still run).
402
+ if (kv) {
403
+ const channel = channelFromEnv(customization.env);
404
+ await resolveServableChannelVersion(kvGet, tenant.tenant_id, channel, ({ versionId }) => {
405
+ recordActivity({
406
+ action: "preview.build.requested",
407
+ actor: { kind: "system", id: "storefront" },
408
+ source: "server",
409
+ outcome: { status: "succeeded" },
410
+ scope: { tenantId: tenant.tenant_id },
411
+ payload: { args: { changeId: versionId } },
412
+ });
413
+ }).catch(() => {
414
+ /* fail-open: self-heal detection never breaks the request */
415
+ });
416
+ }
417
+
418
+ // Verify the developer-draft cookie (signature + expiry + tenant binding) — the
419
+ // overlay below only engages for the developer who minted the draft, on THIS
420
+ // tenant. Same secret + trust model as the preview token; invalid/expired/foreign
421
+ // tokens degrade silently to the base chain (never an error page).
422
+ const draftClaims =
423
+ draftCookie && previewSecret && kv
424
+ ? await verifyDevDraftToken(previewSecret, draftCookie, nowSeconds)
425
+ : null;
426
+ const draft =
427
+ draftClaims && draftClaims.tenantId === tenant.tenant_id ? draftClaims : null;
428
+
429
+ let theme = mergeTheme(referenceTheme, tenant.theme_tokens);
430
+ const publishedTheme = await readPublishedArtifact(
431
+ kvGet,
432
+ tenant.tenant_id,
433
+ customization.env,
434
+ themeArtifactPath(tenant.tenant_id),
435
+ );
436
+ if (publishedTheme) {
437
+ try {
438
+ theme = mergeTheme(theme, JSON.parse(publishedTheme) as DeepPartial<ThemeTokens>);
439
+ } catch {
440
+ /* malformed published artifact — keep the build-time theme */
441
+ }
442
+ }
443
+ // Developer-draft theme overlay (on top of published) — only for the draft owner.
444
+ if (draft) {
445
+ const draftTheme = await kvGet(devDraftKey(tenant.tenant_id, draft.draftId, "theme.json"));
446
+ if (draftTheme) {
447
+ try {
448
+ theme = mergeTheme(theme, JSON.parse(draftTheme) as DeepPartial<ThemeTokens>);
449
+ } catch {
450
+ /* malformed draft artifact — keep the resolved theme */
451
+ }
452
+ }
453
+ }
454
+
455
+ // Per-environment capabilities (Track A/B). Config-as-code: the build-time
456
+ // TenantConfig default is overridden by the published capabilities artifact for
457
+ // THIS environment — materialized from the tenant repo's .tot/config.json per
458
+ // commit (localhost=working tree, preview=PR head, live=promoted). So a
459
+ // capability change is PR-gated and separable per environment.
460
+ let publishedCaps: Partial<CapabilityConfig> | null = null;
461
+ const publishedCapsRaw = await readPublishedArtifact(
462
+ kvGet,
463
+ tenant.tenant_id,
464
+ customization.env,
465
+ capabilitiesArtifactPath(tenant.tenant_id),
466
+ );
467
+ if (publishedCapsRaw) {
468
+ try {
469
+ publishedCaps = JSON.parse(publishedCapsRaw) as Partial<CapabilityConfig>;
470
+ } catch {
471
+ /* malformed published artifact — keep the build-time default */
472
+ }
473
+ }
474
+ const capabilities = resolveCapabilities(tenant, publishedCaps);
475
+
476
+ // Embed opt-in (self-serve, config-as-code on the same spine as capabilities):
477
+ // the build-time TenantConfig.embeds default is overlaid with the tenant's
478
+ // published embeds.json for THIS env, then filtered to the vetted catalog. A
479
+ // tenant enables a catalogued embed by committing embeds.json — no platform
480
+ // change — but can never introduce a non-catalog origin.
481
+ let publishedEmbeds: string[] | null = null;
482
+ const publishedEmbedsRaw = await readPublishedArtifact(
483
+ kvGet,
484
+ tenant.tenant_id,
485
+ customization.env,
486
+ embedsArtifactPath(tenant.tenant_id),
487
+ );
488
+ if (publishedEmbedsRaw) {
489
+ try {
490
+ const parsed = JSON.parse(publishedEmbedsRaw);
491
+ if (Array.isArray(parsed)) publishedEmbeds = parsed.filter((s) => typeof s === "string");
492
+ } catch {
493
+ /* malformed published artifact — keep the build-time default */
494
+ }
495
+ }
496
+ const effectiveEmbeds = resolveEffectiveEmbeds(tenant.embeds, publishedEmbeds);
497
+
498
+ // Nonce placeholder: the platform's inline tags AND the CSP header are rendered
499
+ // with this sentinel, never a live nonce (ADR 0018). A fresh per-response nonce
500
+ // is stamped in at serve time (stampResponseNonce below), so the cached artifact
501
+ // is nonce-free and every response — cold render or warm cache hit — gets a fresh
502
+ // matching nonce/CSP pair.
503
+ const cspNonce = INTEGRATION_NONCE_PLACEHOLDER;
504
+
505
+ locals.tenant = tenant;
506
+ // Effective capability flags for this tenant (commerce defaults unless the
507
+ // tenant declares siteType:"marketing" / explicit features). Pages and chrome
508
+ // read these instead of hard-coding tenant identity checks.
509
+ locals.features = resolveTenantFeatures(tenant);
510
+ locals.capabilities = capabilities;
511
+ // Cart/checkout facts (Track A) — only when the tenant has a catalog
512
+ // AND the cartCheckout capability is enabled for THIS environment. Drives the
513
+ // loader + signed add-to-cart, and the CSP additions below.
514
+ const commerce =
515
+ locals.features.catalog && capabilities.cartCheckout.enabled
516
+ ? await resolveTenantCommerce(tenant)
517
+ : null;
518
+ locals.commerce = commerce ?? undefined;
519
+ locals.tot = tot;
520
+ // Resolve the shared root adapter first. An opened publication version then
521
+ // becomes authoritative for selected content while a mounted space may still
522
+ // reuse root-owned chrome. Neither source falls through to another space.
523
+ const sharedRootContent = candidatePin
524
+ ? createContentProvider(tenant, undefined, { get: kvGet, versionId: candidatePin.versionId })
525
+ : createContentProvider(tenant, {
526
+ get: kvGet,
527
+ env: customization.env,
528
+ });
529
+ locals.content = bindPublicationSpaceContentProviderV1(
530
+ sharedRootContent,
531
+ publicationSource.status === "selected" ? publicationSource.artifact : undefined,
532
+ );
533
+ // Developer-draft content overlay — serves the AI-edited artifacts to the draft
534
+ // owner only; every other viewer keeps the published/base chain above.
535
+ if (draft) {
536
+ locals.content = new DevDraftContentProvider(
537
+ locals.content,
538
+ tenant.tenant_id,
539
+ draft.draftId,
540
+ kvGet,
541
+ );
542
+ }
543
+ locals.themeStyle = themeToCssVars(theme);
544
+ locals.canonicalBase = canonicalBase;
545
+ locals.customizationEnv = customization.env;
546
+ locals.cspNonce = cspNonce;
547
+ // Seeded here, filled in by whichever page/layout renders a widget slot
548
+ // (renderSlot.ts) as the page renders inside next() below — read back out
549
+ // after next() resolves so the CSP frame-src reflects exactly what rendered.
550
+ locals.activeWidgetPlacements = [];
551
+ // Prefix that path-routed pages live under ("" for host-routed). Pages stay
552
+ // tenant-agnostic; the Layout uses this to keep root-absolute links inside
553
+ // the tenant prefix on the client. A candidate content pin overrides this to
554
+ // the PINNING render's own prefix (`/preview/rev/<sha>` or
555
+ // `/preview/render/<tenant>/<changeId>`) so a pinned page's OWN generated
556
+ // links stay inside that candidate instead of escaping to the live host.
557
+ locals.basePath = candidatePin ? candidatePin.basePath : basePath;
558
+ // Renderer-owned four-stage browser environment (ADR 0014 E1 / 0017), computed
559
+ // ONCE here from authoritative request state and serialized before app code by
560
+ // both document mounts (Layout.astro + rawHtmlResponse). The candidate render
561
+ // paths build their own `candidate`-stage object instead of this one.
562
+ // Reuse the environment resolved for a declared tenant's integration plane above
563
+ // (identical inputs → identical object) so the plan's environment and this object
564
+ // never diverge; an undeclared tenant computes it here for the first time.
565
+ locals.browserEnvironment =
566
+ resolvedBrowserEnvironment ??
567
+ requestBrowserEnvironment({
568
+ host,
569
+ isPreviewHost,
570
+ customizationEnv: customization.env,
571
+ canonicalHost: tenant.canonicalDomain || host,
572
+ siteSurface: deriveSiteSurface({ catalog: locals.features.catalog, pathname: renderPath }),
573
+ });
574
+ // Resolved effective plan (ext-kernel-03) for this request — the fixed-phase
575
+ // render engine (ext-kernel-04) consumes it in BOTH document mounts (Layout.astro
576
+ // + rawHtmlResponse). A declared tenant's plan is already resolved above (through
577
+ // the interim bridge); an undeclared tenant — or one the bridge refused / failed
578
+ // closed — resolves the environment-only plan here, so a page with no serving
579
+ // integration emits no vendor runtime/markup.
580
+ locals.integrationPlan =
581
+ locals.integrationPlan ??
582
+ resolveEnvironmentOnlyPlan(locals.browserEnvironment, tenant.tenant_id);
583
+
584
+ // Rewrite when a /<domain>/ prefix was stripped OR a publication render alias
585
+ // resolved to its canonical mount. next(path) does not re-run middleware, so
586
+ // the one route plan and locals above remain authoritative.
587
+ const rewriteTarget = middlewareRewriteTargetV1({
588
+ requestPath: url.pathname,
589
+ basePath,
590
+ renderPath,
591
+ search: url.search,
592
+ });
593
+ let response = rewriteTarget !== undefined
594
+ ? await next(rewriteTarget)
595
+ : await next();
596
+
597
+ // On-publish auto-refresh: inject the live-reload client into SHARED preview
598
+ // (`test`) HTML pages ONLY — never `live` (keeps live clean + edge-cacheable).
599
+ // It polls /__preview/version and reloads when this tenant's cache-version
600
+ // bumps on publish/reconcile. Injection happens before the CSP header is set so
601
+ // the script's per-request nonce (cspNonce) matches the enforced policy.
602
+ response = await maybeInjectPreviewReload(response, {
603
+ env: customization.env,
604
+ tenantId: tenant.tenant_id,
605
+ basePath,
606
+ nonce: cspNonce,
607
+ });
608
+
609
+ // Baseline security headers on every response — set before the page-cache
610
+ // store below so cached copies carry them too.
611
+ applySecurityHeaders(response.headers);
612
+
613
+ // Preview (test) output must never be indexed.
614
+ if (customization.noindex) {
615
+ response.headers.set("X-Robots-Tag", "noindex, nofollow");
616
+ }
617
+
618
+ // Strict CSP — now ENFORCED in every environment (Epic S2). The report-only
619
+ // rollout is complete: platform output is CSP-clean — inline styles are
620
+ // externalized (`inlineStylesheets: "never"`), Astro's static inline scripts +
621
+ // island style are hash-pinned (cspStaticHashes), our dynamic inline tags carry
622
+ // the per-request nonce, and the last inline event handler (SortSelect's
623
+ // `onchange`) is now a bundled 'self' script. Reporting endpoints stay attached
624
+ // (report-to/report-uri work in enforce mode), so we keep telemetry as tenant
625
+ // client JS arrives — those per-version tenant hashes extend `scriptSrc` per
626
+ // request (the mechanism below already supports it).
627
+ //
628
+ // DEV EXCEPTION: `astro dev` serves CSS/HMR as UN-nonced inline <style>/<script>
629
+ // (Vite injects them); the strict nonce policy blocks those, leaving the dev
630
+ // site unstyled. The BUILD is CSP-clean (styles are externalized via
631
+ // `inlineStylesheets:"never"`), so we enforce there and only REPORT in dev —
632
+ // violations are still surfaced, but styling/HMR work locally.
633
+ const cspHeader = cspHeaderName(import.meta.env.DEV);
634
+ response.headers.set(
635
+ "Reporting-Endpoints",
636
+ `${CSP_REPORT_GROUP}="${CSP_REPORT_PATH}"`,
637
+ );
638
+ // Checkout origins (loader CDN + the store's cart/checkout host) added to the
639
+ // strict policy only when this tenant has a store — so add-to-cart, the
640
+ // sidecart iframe/XHR, and the cross-origin form POST all pass.
641
+ const checkoutCsp = commerce ? checkoutCspAdditions(commerce.origin) : null;
642
+ // Private-app widget origins — read back AFTER next() so this reflects
643
+ // exactly the placements that rendered on THIS page, never a wildcard.
644
+ const widgetCsp = appWidgetCspAdditions(locals.activeWidgetPlacements ?? []);
645
+ // Third-party marketing embeds (e.g. a Pipedrive form) the tenant opted into
646
+ // via .tot/config.json `embeds:`. Only scan the HTML body when the tenant
647
+ // declared at least one embed — zero cost for everyone else — and add each
648
+ // provider's origins ONLY if its marker is actually on THIS page.
649
+ // Prefer an opt-in the render handler resolved for a tenant the URL router
650
+ // couldn't (the candidate renderer sets locals.embedOptIn — see candidateRender);
651
+ // otherwise use the router-resolved tenant's effective embeds.
652
+ const embedsForScan = locals.embedOptIn ?? effectiveEmbeds;
653
+ let embedCsp = { scriptSrc: [] as string[], connectSrc: [] as string[], frameSrc: [] as string[] };
654
+ if (
655
+ embedsForScan.length > 0 &&
656
+ (response.headers.get("content-type") ?? "").includes("text/html")
657
+ ) {
658
+ embedCsp = embedCspAdditions(embedsForScan, await response.clone().text());
659
+ }
660
+ const cspOptions = {
661
+ nonce: cspNonce,
662
+ ...STOREFRONT_CSP_ALLOWLIST,
663
+ // Pin Astro's static inline hydration scripts + island style by hash
664
+ // (they're identical every request); my dynamic inline tags use the nonce.
665
+ scriptSrc: [
666
+ ...STOREFRONT_CSP_ALLOWLIST.scriptSrc,
667
+ ...STATIC_SCRIPT_HASHES,
668
+ ...(checkoutCsp?.scriptSrc ?? []),
669
+ ...embedCsp.scriptSrc,
670
+ ],
671
+ styleSrc: [
672
+ ...STOREFRONT_CSP_ALLOWLIST.styleSrc,
673
+ ...STATIC_STYLE_HASHES,
674
+ ...(checkoutCsp?.styleSrc ?? []),
675
+ ],
676
+ fontSrc: [...STOREFRONT_CSP_ALLOWLIST.fontSrc, ...(checkoutCsp?.fontSrc ?? [])],
677
+ connectSrc: [
678
+ ...STOREFRONT_CSP_ALLOWLIST.connectSrc,
679
+ ...(checkoutCsp?.connectSrc ?? []),
680
+ ...embedCsp.connectSrc,
681
+ ],
682
+ imgSrc: [...STOREFRONT_CSP_ALLOWLIST.imgSrc, ...(checkoutCsp?.imgSrc ?? [])],
683
+ frameSrc: [
684
+ ...(checkoutCsp?.frameSrc ?? []),
685
+ ...widgetCsp.frameSrc,
686
+ ...embedCsp.frameSrc,
687
+ ],
688
+ formAction: checkoutCsp?.formAction ?? [],
689
+ reportGroup: CSP_REPORT_GROUP,
690
+ reportUri: CSP_REPORT_PATH,
691
+ };
692
+ // Version-scoped integration plane: APPEND the resolved plan's compiled origins
693
+ // (curated adapter profiles + advanced exact origins) to the six contributable
694
+ // directives only — never the base. Absent (no integration) this is a no-op, so
695
+ // the header is byte-identical to a non-integration tenant's.
696
+ response.headers.set(
697
+ cspHeader,
698
+ buildCsp(foldIntegrationCsp(cspOptions, locals.integrationCsp?.additions ?? {})),
699
+ );
700
+
701
+ // Store the fully-rendered, CSP-stamped response for the next reader. Only
702
+ // production (no preview token reached here) cacheable GETs have a pageKey;
703
+ // putCachedPage additionally skips non-200, non-HTML, and cookie-bearing
704
+ // responses. Also expose Cache-Control to any fronting CDN.
705
+ if (pageKey && cache && customization.env === "live") {
706
+ response.headers.set(
707
+ "Cache-Control",
708
+ "public, s-maxage=300, stale-while-revalidate=86400",
709
+ );
710
+ // Store the nonce-free artifact (the placeholder is still in the body + CSP
711
+ // header) BEFORE stamping — so the cache never holds a live/reusable nonce.
712
+ await putCachedPage(cache, pageKey, response);
713
+ }
714
+ // MATERIALIZE-ONLY capture (ADR 0013): expose the tenant's resolved route-document
715
+ // Cache-Control so the publish/materialize plane can bake it into the bundle
716
+ // manifest (the same capture pattern as CSP). Gated on the materialize probe
717
+ // request header, so it NEVER reaches a public client — the LIVE served header
718
+ // comes from the manifest via `static-bundle`, not from this signal.
719
+ if (request.headers.get(HTML_CACHE_POLICY_CAPTURE_PROBE)) {
720
+ response.headers.set(
721
+ HTML_CACHE_POLICY_CAPTURE_HEADER,
722
+ formatHtmlCacheControl(locals.tenant?.serving?.htmlCachePolicy),
723
+ );
724
+ }
725
+ if (isInternalPublication) applyInternalContentResponsePolicy(response.headers);
726
+ // Stamp a fresh per-response nonce into this cold response's body + CSP header
727
+ // (the warm-hit path above stamps its own). Both paths therefore serve a fresh,
728
+ // matching nonce/CSP pair, and no nonce is reused across responses (ADR 0018).
729
+ return await stampResponseNonce(response, freshNonce());
730
+ }