@tokenoftrust/storefront-runner 2.0.16 → 2.0.18

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 (90) hide show
  1. package/apps/storefront/env.d.ts +18 -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/apps/routeParams.ts +45 -0
  19. package/apps/storefront/src/lib/auth/loginGate.ts +25 -0
  20. package/apps/storefront/src/lib/checkoutCommerce.ts +1 -1
  21. package/apps/storefront/src/lib/compliance/enforcement.ts +25 -3
  22. package/apps/storefront/src/lib/d1/schema-apps.ts +44 -0
  23. package/apps/storefront/src/lib/env.ts +13 -111
  24. package/apps/storefront/src/lib/fixtures/regulated-commerce-truth/index.ts +540 -0
  25. package/apps/storefront/src/lib/fixtures/ws3-readonly-clone-preflight/index.ts +495 -0
  26. package/apps/storefront/src/lib/integration/tenantAnalyticsDeclarations.ts +198 -0
  27. package/apps/storefront/src/lib/monitoring/manifest.ts +1 -1
  28. package/apps/storefront/src/lib/rawMarketingHtml.ts +9 -1
  29. package/apps/storefront/src/lib/runtimeEnv.ts +124 -0
  30. package/apps/storefront/src/lib/verification/totVerificationClient.ts +485 -0
  31. package/apps/storefront/src/middleware/candidatePin.ts +35 -0
  32. package/apps/storefront/src/middleware/controlPlane.ts +270 -0
  33. package/apps/storefront/src/middleware/index.ts +100 -796
  34. package/apps/storefront/src/middleware/publicRender.ts +43 -0
  35. package/apps/storefront/src/middleware/renderChain.ts +685 -0
  36. package/apps/storefront/src/middleware/routeBranch.ts +160 -0
  37. package/apps/storefront/src/middleware/staticBundle.ts +109 -0
  38. package/apps/storefront/src/pages/[...slug].astro +1 -0
  39. package/apps/storefront/src/pages/api/compliance/preflight.ts +179 -2
  40. package/apps/storefront/src/pages/index.astro +1 -0
  41. package/apps/storefront/vitest.config.ts +5 -0
  42. package/package.json +1 -1
  43. package/packages/public-runtime/package.json +1 -0
  44. package/packages/public-runtime/schemas/managed-analytics/v1/deliver-test-event-request.schema.json +122 -0
  45. package/packages/public-runtime/schemas/managed-analytics/v1/get-test-destination-delivery-request.schema.json +79 -0
  46. package/packages/public-runtime/schemas/managed-analytics/v1/register-test-destination-request.schema.json +93 -0
  47. package/packages/public-runtime/schemas/managed-analytics/v1/test-destination-delivery.schema.json +106 -0
  48. package/packages/public-runtime/schemas/managed-analytics/v1/test-destination.schema.json +77 -0
  49. package/packages/public-runtime/schemas/publication-space/v1/manifest.schema.json +195 -0
  50. package/packages/public-runtime/schemas/publication-space/v1/normalized-mount.schema.json +80 -0
  51. package/packages/public-runtime/schemas/publication-space/v1/ref.schema.json +20 -0
  52. package/packages/public-runtime/schemas/publication-version/v1/pointer.schema.json +28 -0
  53. package/packages/public-runtime/schemas/publication-version/v1/transition-request.schema.json +44 -0
  54. package/packages/public-runtime/schemas/publication-version/v1/transition.schema.json +47 -0
  55. package/packages/public-runtime/schemas/publication-version/v1/version.schema.json +58 -0
  56. package/packages/public-runtime/src/activity/catalog.ts +1 -1
  57. package/packages/public-runtime/src/candidate-index.ts +6 -0
  58. package/packages/public-runtime/src/customization-reconcile.ts +8 -0
  59. package/packages/public-runtime/src/html-cache-policy.ts +57 -0
  60. package/packages/public-runtime/src/index.ts +7 -0
  61. package/packages/public-runtime/src/integration/consent-broker.ts +115 -0
  62. package/packages/public-runtime/src/integration/interim-bridge.ts +111 -27
  63. package/packages/public-runtime/src/integration/managed-analytics-api-v1.schemas.ts +65 -0
  64. package/packages/public-runtime/src/integration/managed-analytics-api-v1.ts +81 -0
  65. package/packages/public-runtime/src/integration/render-phases.ts +8 -3
  66. package/packages/public-runtime/src/publication-artifact.ts +203 -0
  67. package/packages/public-runtime/src/publication-space.ts +853 -0
  68. package/packages/public-runtime/src/publication-version.ts +587 -0
  69. package/packages/public-runtime/src/route-tier.ts +257 -0
  70. package/packages/public-runtime/src/static-bundle.ts +18 -5
  71. package/packages/public-runtime/src/tenant-assets.ts +85 -17
  72. package/packages/public-runtime/src/tenant.ts +70 -0
  73. package/packages/public-runtime/tests/conformance/fake-destination.ts +402 -0
  74. package/packages/public-runtime/tests/fixtures/publication-spaces/internal.json +32 -0
  75. package/packages/public-runtime/tests/fixtures/publication-spaces/mounted-public.json +38 -0
  76. package/packages/public-runtime/tests/fixtures/publication-spaces/normalized-mounts.json +39 -0
  77. package/packages/public-runtime/tests/fixtures/publication-spaces/ref.json +5 -0
  78. package/packages/public-runtime/tests/fixtures/publication-spaces/root-public.json +31 -0
  79. package/packages/public-runtime/tests/fixtures/publication-versions/articles-v1.json +20 -0
  80. package/packages/public-runtime/tests/fixtures/publication-versions/articles-v2.json +20 -0
  81. package/packages/public-runtime/tests/fixtures/publication-versions/internal-v1.json +20 -0
  82. package/packages/public-runtime/tests/fixtures/publication-versions/live-pointer.json +11 -0
  83. package/packages/public-runtime/tests/fixtures/publication-versions/live-transition-request.json +14 -0
  84. package/packages/public-runtime/tests/fixtures/publication-versions/live-transition.json +17 -0
  85. package/packages/public-runtime/tests/fixtures/publication-versions/root-v1.json +20 -0
  86. package/packages/public-runtime/tests/fixtures/publication-versions/root-v2.json +20 -0
  87. package/scripts/dev/ai-edit.mjs +16 -3
  88. package/scripts/dev/port-check.mjs +18 -3
  89. package/scripts/dev/publish.mjs +26 -3
  90. package/scripts/tenant/validate.mjs +14 -1
@@ -1,87 +1,57 @@
1
1
  /**
2
- * Tenant-resolution middleware (brief §7).
2
+ * The storefront front door: resolve the tenant, resolve the route ONCE through
3
+ * the route-tier authority, and dispatch to the one branch that serves it.
3
4
  *
4
- * Per request: Host -> TenantConfig (via TenantResolver) -> resolve theme ->
5
- * bind a ToTClient to the tenant's data scope -> stash everything on
6
- * Astro.locals so pages/components stay tenant-agnostic. The serialized theme
7
- * CSS-var block is injected into <head> by Layout.astro from locals.themeStyle.
5
+ * ADR 0013 splits a tenant's surface in two — a public path that is CDN →
6
+ * content-addressed storage with no origin compute, and a control plane the
7
+ * Worker keeps (preview, draft overlay, admin, the dev loop, gated member areas).
8
+ * This file is where that split is taken: `resolveMiddlewareBranchV1` asks
9
+ * `resolveRouteTierV1` (packages/public-runtime) for the tier and returns the
10
+ * branch, and nothing downstream re-derives it.
11
+ *
12
+ * static-passthrough → platform bytes; `next()` plus baseline headers.
13
+ * public-render → ./publicRender — anonymous output, no request-time D1.
14
+ * control-plane → ./controlPlane — the gate, the D1 reads, then the render.
15
+ *
16
+ * The resolver is fed `renderPath`, the TENANT-RELATIVE path. `url.pathname` is
17
+ * not tenant-relative on a path-routed deployment, and feeding it in would let
18
+ * `/<tenant>/admin` miss the reserved-mount check and resolve as public Tier 0.
19
+ *
20
+ * Contract: docs/architecture/decisions/0013-public-serving-contract.md
8
21
  */
9
22
  import { defineMiddleware } from "astro:middleware";
10
23
  import { getTenantResolver } from "@/config/resolver";
11
- import { referenceTheme } from "@/themes/reference";
12
- import { mergeTheme, themeToCssVars } from "@/themes/schema";
13
- import { createToTClient } from "@/lib/tot/ToTClient";
14
- import { readEnv, readKv, readD1, readR2 } from "@/lib/env";
24
+ import { readEnv, readKv } from "@/lib/runtimeEnv";
15
25
  import { resolveTenantRouting } from "@/lib/tenantRouting";
16
- import { createContentProvider, DevDraftContentProvider } from "@/lib/storyblok/provider";
17
- import { DEV_DRAFT_COOKIE, devDraftKey, verifyDevDraftToken } from "@/lib/dev/devDraft";
18
- import {
19
- isCacheablePath,
20
- pageCacheKey,
21
- readCacheVersion,
22
- getCachedPage,
23
- putCachedPage,
24
- staticBundleCacheKey,
25
- } from "@/lib/edgeCache";
26
- import { STATIC_SCRIPT_HASHES, STATIC_STYLE_HASHES } from "@/lib/cspStaticHashes";
26
+ import { compileRouteTierIndexV1, type RouteTierIndexV1 } from "@tot/public-runtime";
27
+ import type { TenantConfig } from "@tot/public-runtime";
28
+ import { DEV_DRAFT_COOKIE } from "@/lib/dev/devDraft";
27
29
  import { applySecurityHeaders } from "@/lib/securityHeaders";
28
- import { maybeInjectPreviewReload } from "@/lib/previewReload";
29
- import {
30
- CANDIDATE_CONTENT_PIN_PARAM,
31
- verifyContentPinToken,
32
- resolveCustomizationEnv,
33
- buildCsp,
34
- STOREFRONT_CSP_ALLOWLIST,
35
- checkoutCspAdditions,
36
- appWidgetCspAdditions,
37
- embedCspAdditions,
38
- foldIntegrationCsp,
39
- embedsArtifactPath,
40
- resolveEffectiveEmbeds,
41
- CSP_REPORT_GROUP,
42
- CSP_REPORT_PATH,
43
- readPublishedArtifact,
44
- themeArtifactPath,
45
- resolveTenantFeatures,
46
- resolveCapabilities,
47
- capabilitiesArtifactPath,
48
- resolveChannelDigest,
49
- resolveServableChannelVersion,
50
- channelFromEnv,
51
- serveStaticBundle,
52
- resolveEnvironmentOnlyPlan,
53
- resolveInterimBridgePlan,
54
- compileIntegrationCspFromPlan,
55
- collectDeclaredCspProfiles,
56
- INTEGRATION_NONCE_PLACEHOLDER,
57
- } from "@tot/public-runtime";
58
- import type { CapabilityConfig, StaticBundleR2BucketLike, BrowserEnvironment } from "@tot/public-runtime";
59
- import { loadTenantIntegrationDeclarations } from "@/lib/integration/tenantDeclarations";
60
- import { loadActiveManagedInstallDeclarations } from "@/lib/integration/managedInstallDeclarations";
61
- import { fromD1 } from "@/lib/d1/catalog";
62
- import { resolveTenantCommerce } from "@/lib/checkoutCommerce";
30
+ import { tryServeStaticBundle } from "./staticBundle.js";
31
+ import { resolveCandidateContentPin } from "./candidatePin.js";
32
+ import { parseGateMode } from "@/lib/auth/loginGate";
63
33
  import { evaluateDemoGate, hostInGateList } from "@/lib/demoGate";
64
- import {
65
- evaluateLoginGate,
66
- parseGateMode,
67
- isAuthPath,
68
- isAdminPath,
69
- sessionAdmitsHost,
70
- sessionHostCapability,
71
- PUBLIC_HOME_TENANT_ID,
72
- } from "@/lib/auth/loginGate";
73
- import { resolveAdminLanding, isAdminSelectPath } from "@/lib/auth/adminLanding";
74
- import { resolveAdminTenantMode } from "@/lib/adminTenantEnvelope";
75
- import { gatedHoldingResponse } from "@/lib/auth/gatePage";
76
- import { readSession, KvSessionStore, SESSION_COOKIE } from "@/lib/auth/session";
77
34
  import { isMaintenanceOn, maintenanceResponse } from "@/lib/maintenance";
78
35
  import { HEALTH_PATH, healthResponse } from "@/lib/health";
79
36
  import { observeDeployVersion } from "@/lib/activity/deployVersion";
80
- import { requestBrowserEnvironment, deriveSiteSurface, cspHeaderName } from "@/lib/integration/browserEnvironment";
81
- import { freshNonce, stampResponseNonce } from "@/lib/integration/nonceStamp";
82
- import { recordActivity } from "@/lib/activity/recordActivity";
83
37
  import { primeActivityKillSwitch } from "@/lib/activity/killSwitch";
84
- import type { DeepPartial, ThemeTokens } from "@tot/public-runtime";
38
+ import { resolveMiddlewareBranchV1 } from "./routeBranch.js";
39
+ import { servePublicRender } from "./publicRender.js";
40
+ import { serveControlPlane } from "./controlPlane.js";
41
+
42
+ /**
43
+ * Compiled once per tenant-config object, as the resolver's contract asks —
44
+ * validation is pure and its inputs only change when the config object does.
45
+ */
46
+ const routeTierIndexes = new WeakMap<TenantConfig, RouteTierIndexV1>();
47
+
48
+ function routeTierIndexFor(tenant: TenantConfig): RouteTierIndexV1 {
49
+ const cached = routeTierIndexes.get(tenant);
50
+ if (cached) return cached;
51
+ const compiled = compileRouteTierIndexV1(tenant);
52
+ routeTierIndexes.set(tenant, compiled);
53
+ return compiled;
54
+ }
85
55
 
86
56
  export const onRequest = defineMiddleware(async (context, next) => {
87
57
  const { request, locals } = context;
@@ -126,9 +96,8 @@ export const onRequest = defineMiddleware(async (context, next) => {
126
96
  // "password" (default) — interim Basic-Auth gate, enforced HERE (host-level,
127
97
  // no tenant/session needed) so a blocked host never reaches tenant
128
98
  // resolution, the edge cache, or any render.
129
- // "login" — "Sign in with ToT" gate, enforced just below (needs the resolved
130
- // tenant as the grant `resource` + the SESSION KV), redirecting to
131
- // /auth/login unless a valid, resource-bound tot_session cookie is present.
99
+ // "login" — "Sign in with ToT" gate, enforced on the control-plane branch
100
+ // (it needs the resolved tenant as the grant `resource` + the SESSION KV).
132
101
  const gateMode = parseGateMode(await readEnv("DEMO_GATE_MODE"));
133
102
  const demoGateHosts = await readEnv("DEMO_GATE_HOSTS");
134
103
  if (gateMode === "password") {
@@ -153,741 +122,76 @@ export const onRequest = defineMiddleware(async (context, next) => {
153
122
  return new Response("Unknown host", { status: 404 });
154
123
  }
155
124
 
156
- // Workers Cache API: backs the full-page edge cache below. Undefined in
157
- // `astro dev` (Node) — every use is a no-op there.
125
+ // ---- The one route classification -----------------------------------------
126
+ const hostLoginGated = gateMode === "login" && hostInGateList(host, demoGateHosts);
127
+ const { branch, route } = resolveMiddlewareBranchV1({
128
+ index: routeTierIndexFor(tenant),
129
+ renderPath,
130
+ hostLoginGated,
131
+ });
132
+ locals.routeTier = route;
133
+
134
+ // Workers Cache API: backs the full-page edge cache in the render chain, and
135
+ // the bundle read below. Undefined in `astro dev` (Node) — a no-op there.
158
136
  const cache =
159
137
  typeof caches !== "undefined" && "default" in caches
160
138
  ? (caches as unknown as { default: Cache }).default
161
139
  : undefined;
162
140
  const kv = await readKv("TENANT_CACHE");
163
141
 
164
- // Login-gate enforcement (DEMO_GATE_MODE="login"): runs before the edge cache
165
- // so a gated page is never served to an unauthenticated viewer. The platform's
166
- // own "self" home tenant is EXEMPT — it renders publicly (browse + sign up);
167
- // every other tenant on a gated host is withheld behind sign-in. A valid,
168
- // resource-bound tot_session sets locals.viewer (email + roles) for chrome;
169
- // otherwise an unauthenticated visitor gets a "Coming soon" holding page with
170
- // a Sign-in button (auth paths are exempt so sign-in itself is reachable). The
171
- // grant `resource` is the tenant's appDomain — never input.
172
- const tenantExempt = tenant.tenant_id === PUBLIC_HOME_TENANT_ID;
173
- if (gateMode === "login" && hostInGateList(host, demoGateHosts)) {
174
- // UNIFIED on the federated session: read the FULL session (no resource filter —
175
- // federated sign-in's aud is the storefront app, not this tenant) and admit the
176
- // viewer iff they hold a MEMBERSHIP for the host's tenant. One session model for
177
- // the console + the preview gate. Deny-closed: no session / no membership → gate.
178
- let viewerRecord = null;
179
- if (!isAuthPath(url.pathname)) {
180
- const sessionKv = await readKv("SESSION");
181
- if (sessionKv) {
182
- const sid = context.cookies.get(SESSION_COOKIE)?.value ?? null;
183
- viewerRecord = await readSession(
184
- new KvSessionStore(sessionKv),
185
- sid,
186
- Math.floor(Date.now() / 1000),
187
- );
188
- }
189
- }
190
- const nowSeconds = Math.floor(Date.now() / 1000);
191
- const admitted = sessionAdmitsHost(viewerRecord, tenant.appDomain, nowSeconds);
192
- // QA content-bypass: only when the server has configured DEMO_GATE_BYPASS_TOKEN
193
- // (never in a prod slot) AND the request presents a matching X-Demo-Gate-Bypass
194
- // header. Unset token ⇒ false ⇒ fully inert; a client cannot self-grant. The
195
- // public-content-only restriction (admin/cockpit/api excluded) is enforced
196
- // inside evaluateLoginGate via isBypassPrivilegedPath.
197
- const bypassToken = await readEnv("DEMO_GATE_BYPASS_TOKEN");
198
- const qaBypass =
199
- !!bypassToken && request.headers.get("x-demo-gate-bypass") === bypassToken;
200
-
201
- // Canonical admin tenant landing: an AUTHENTICATED viewer who is not a
202
- // member/staff of THIS host's tenant must be routed to their OWN tenant
203
- // (auto when sole) or the tenant picker (when several / none) — never bounced
204
- // into a sign-in loop and never silently shown the wrong tenant. Only for
205
- // admin paths, only for an authed non-member, and never on the /admin/select
206
- // picker itself (whose whole purpose is to be reachable by such a viewer).
207
- if (
208
- isAdminPath(url.pathname) &&
209
- !isAdminSelectPath(url.pathname) &&
210
- !admitted &&
211
- viewerRecord &&
212
- !qaBypass
213
- ) {
214
- const landing = resolveAdminLanding({
215
- record: viewerRecord,
216
- routedTenant: tenant.appDomain,
217
- basePath,
218
- intended: url.pathname + url.search,
219
- nowSeconds,
220
- });
221
- if (landing.action === "auto" || landing.action === "select") {
222
- return context.redirect(landing.location, 303);
223
- }
224
- }
225
-
226
- // The /admin/select picker requires only a VALID SESSION (any authed viewer),
227
- // not membership of the routed tenant — it lists the viewer's OWN tenants. So
228
- // it passes the gate on the strength of the session alone.
229
- const hasValidSession =
230
- admitted || (isAdminSelectPath(url.pathname) && !!viewerRecord);
231
- const decision = evaluateLoginGate({
232
- host,
233
- pathname: url.pathname,
234
- search: url.search,
235
- mode: gateMode,
236
- gatedHosts: demoGateHosts,
237
- hasValidSession,
238
- tenantExempt,
239
- qaBypass,
240
- });
241
- if (decision.action === "redirect") {
242
- // ADMIN (and any privileged surface) hard-redirects to sign-in rather than
243
- // rendering the merchant-facing "Coming soon" holding page: an unauthenticated
244
- // admin visitor must land on the sign-in prompt, never an ambiguous 200 that
245
- // reads as "this store isn't ready." The holding page's whole audience is a
246
- // public browser of a gated STOREFRONT; it is the wrong signal for /admin. The
247
- // gate already decided to withhold — this only changes HOW (303 vs holding
248
- // page); it never weakens the gate. `decision.location` already carries
249
- // `?next=` so the admin returns to exactly where they were headed.
250
- if (isAdminPath(url.pathname)) {
251
- return context.redirect(decision.location, 303);
252
- }
253
- // Withhold content but offer sign-in (carrying the intended destination).
254
- return gatedHoldingResponse(decision.location);
255
- }
256
- if (admitted && viewerRecord) {
257
- // Surface the viewer's standing FOR THIS host's tenant as chrome, BOUND to
258
- // the host so a capability the session holds for another tenant never leaks
259
- // in: a member sees their tenants[] role; the owner of their own tenant sees
260
- // the resource-scoped session's capability; a ToT-staff viewer admitted via
261
- // an explicit vendor selection sees the staffRoles-scoped capability. The
262
- // ship gates (decideIsOwner/resolveShipPrincipal) read this capability,
263
- // so binding it here keeps owner-resolution server-side + tenant-correct.
264
- //
265
- // On an admin path, "the host" a staff viewer is really acting on is the
266
- // ?asTenant= impersonation target (mirrors admin.astro/AdminPublishTab.astro's
267
- // own resolveAdminTenantMode call) — resolving capability against the routed
268
- // host instead left a staff viewer who selected another tenant reading as
269
- // "not an owner" of it even while holding a real grant, because their
270
- // staffSelection is bound to the SELECTED tenant, never the host app.
271
- const capabilityHost = isAdminPath(url.pathname)
272
- ? resolveAdminTenantMode({
273
- session: viewerRecord,
274
- adminAppTenant: tenant.appDomain,
275
- requestedTarget: url.searchParams.get("asTenant"),
276
- nowSeconds,
277
- }).envelope.targetTenant
278
- : tenant.appDomain;
279
- const hostCapability = sessionHostCapability(viewerRecord, capabilityHost, nowSeconds);
280
- locals.viewer = {
281
- email: viewerRecord.email,
282
- roles: hostCapability ? [hostCapability] : viewerRecord.roles,
283
- capability: hostCapability ?? viewerRecord.capability,
284
- };
285
- }
286
- }
287
-
288
- // Preview/test detection (also reused for the customization env below). A
289
- // production read is one with no preview host, query token, or preview cookie.
290
- const isPreviewHost = host.toLowerCase().startsWith("preview.");
291
- const queryToken = url.searchParams.get("__preview");
292
- const cookieToken = context.cookies.get("tot_preview")?.value ?? null;
293
-
294
- // Candidate-render CONTENT PIN (`__tot_candidate_pin`): a signed, tenant- and
295
- // expiry-bound token minted ONLY by the control-plane candidate/revision
296
- // renderers (`src/lib/preview/pinnedRouteRender.ts`, never client-forgeable)
297
- // that pins THIS one render's content resolution to an exact immutable
298
- // `versionId`, bypassing the shared `{live,test}` channel entirely. It rides
299
- // an in-process `context.rewrite()` from those renderers so a real Astro
300
- // route (blog, editorial pages, …) — not just the raw `home.html`/
301
- // `pages-html/*` documents they serve directly — renders pinned to the
302
- // candidate under review (fb-1788536381607). Verified here so it can only
303
- // ever name the resolved tenant's own version; a mismatched/expired/absent
304
- // token falls through to the normal channel-resolved read below, same
305
- // fail-closed posture as the `__preview` token.
306
- const candidatePinParam = url.searchParams.get(CANDIDATE_CONTENT_PIN_PARAM);
307
- let candidatePin: { versionId: string; basePath: string } | null = null;
308
- if (candidatePinParam) {
309
- const pinSecret = (await readEnv("CUSTOMIZATION_PREVIEW_SECRET")) ?? null;
310
- if (pinSecret) {
311
- const verified = await verifyContentPinToken(
312
- pinSecret,
313
- candidatePinParam,
314
- Math.floor(Date.now() / 1000),
315
- );
316
- if (verified.ok && verified.claims.tenantId === tenant.tenant_id) {
317
- candidatePin = { versionId: verified.claims.versionId, basePath: verified.claims.basePath };
318
- }
319
- }
320
- }
321
- // Per-developer AI-edit draft: the signed
322
- // tot_dev_draft cookie's mere PRESENCE opts the request out of the shared page
323
- // cache below (a draft view must never be cached publicly); it's verified —
324
- // signature, expiry, tenant binding — before the overlay is applied further down.
325
- const draftCookie = context.cookies.get(DEV_DRAFT_COOKIE)?.value ?? null;
326
-
327
- // Active managed installs share the same declaration/resolver/CSP/broker/cache
328
- // plane as tenant-config integrations. Read only canonical active rows and their
329
- // current immutable snapshot; candidates, suspended rows, and drafts produce no
330
- // declaration. A missing/unmigrated binding fails closed to no managed effects.
331
- const appsD1 = await readD1("STOREFRONT_APPS_DB");
332
- let managedIntegrationDeclarations = [] as Awaited<ReturnType<typeof loadActiveManagedInstallDeclarations>>;
333
- if (appsD1) {
334
- try {
335
- managedIntegrationDeclarations = await loadActiveManagedInstallDeclarations(fromD1(appsD1), tenant.tenant_id);
336
- } catch {
337
- managedIntegrationDeclarations = [];
338
- }
339
- }
340
-
341
- // ---- Digest-addressed static-bundle serving (ADR 0012) --------------------
342
- // For a static-publish tenant's PRODUCTION reads only (never preview/test — the
343
- // "Worker serves interactive preview, R2 serves prod" split), read the live
344
- // bundle straight out of R2 instead of falling through to SSR. Deliberately
345
- // BEFORE the edge page cache / catalog / theme / customization machinery below:
346
- // none of that applies to bundle bytes served verbatim, and this path must
347
- // never SSR-fallthrough once it's eligible (a missing digest/object fails
348
- // closed, see `serveStaticBundle`), so it has to fully own the response, not
349
- // degrade into the normal render.
350
- // Host-resolved reads only (`basePath === ""`): bundle bytes are served
351
- // VERBATIM with root-absolute links, so a path-prefixed view
352
- // (/<domain>/… on the shared .store host) would escape its prefix on every
353
- // href — those views keep SSR, which rewrites hrefs through basePath.
354
- const staticBundleEligible =
355
- tenant.staticPublish != null &&
356
- tenant.staticPublish.enabled !== false &&
357
- basePath === "" &&
358
- request.method.toUpperCase() === "GET" &&
359
- !isPreviewHost &&
360
- !queryToken &&
361
- !cookieToken &&
362
- !candidatePin &&
363
- !draftCookie &&
364
- managedIntegrationDeclarations.length === 0 &&
365
- !url.pathname.startsWith("/api/");
366
- if (staticBundleEligible) {
367
- const bundleBucket = await readR2("STATIC_BUNDLES");
368
- if (bundleBucket) {
369
- const kvGet = kv ? (key: string) => kv.get(key) : async () => null;
370
- // The R2 bundle key is the `treeDigest`, NOT the `live` channel versionId
371
- // (the Gitea sha). Read the reconciled digest companion (`channelDigests.live`)
372
- // — `gatedPromote`/`rollbackCandidateAtomic` write it in the same CAS flip as
373
- // the versionId. No digest = this tenant has never staged a bundle — SSR
374
- // serves it (a static-publish tenant must be able to bootstrap, and its
375
- // app surfaces must never be downed by an unstaged static plane).
376
- const digest = await resolveChannelDigest(kvGet, tenant.tenant_id, "live");
377
- if (digest) {
378
- const bundleCacheKey = cache
379
- ? staticBundleCacheKey(tenant.tenant_id, digest, url.pathname, url.search)
380
- : null;
381
- if (bundleCacheKey && cache) {
382
- const hit = await getCachedPage(cache, bundleCacheKey);
383
- if (hit) return hit;
384
- }
385
- const bundleResponse = await serveStaticBundle(
386
- {
387
- bucket: bundleBucket as unknown as StaticBundleR2BucketLike,
388
- digest,
389
- onFailClosed: (detail) => {
390
- console.error(
391
- `[static-bundle] fail-closed for ${tenant.tenant_id} ${url.pathname}: ${detail}`,
392
- );
393
- },
394
- },
395
- url.pathname,
396
- );
397
- // `null` = the manifest proves this path is outside the bundle's static
398
- // surface (an app surface like /admin) — SSR owns it below.
399
- if (bundleResponse) {
400
- if (bundleCacheKey && cache) {
401
- await putCachedPage(cache, bundleCacheKey, bundleResponse);
402
- }
403
- return bundleResponse;
404
- }
405
- } else {
406
- console.error(
407
- `[static-bundle] no live digest for ${tenant.tenant_id} — SSR until a bundle is staged`,
408
- );
409
- }
410
- }
411
- // No STATIC_BUNDLES binding (plain `astro dev`, or unconfigured) —
412
- // degrading to SSR here rather than a hard failure keeps `astro dev`/tests
413
- // usable without provisioning R2.
414
- }
415
-
416
- // ---- Tenant-local integration materialization (ext-kernel-09) --------------
417
- // Resolve a tenant's OWN declared, exact-version integrations into the effective
418
- // plan the render engine serves, THROUGH the interim marketing bridge (which
419
- // refuses a regulated tenant and fail-closes an invalid declaration set). Done
420
- // HERE, before the edge-cache read, so a declared tenant's `planRevision` folds
421
- // into the page-cache key below (a rollback/uninstall resolves a new revision →
422
- // the old warm entry is unreachable → a clean re-render without the removed
423
- // origins, never a TTL wait) AND its reviewed CSP origins reach the strict header.
424
- //
425
- // An UNDECLARED tenant does NONE of this: `loadTenantIntegrationDeclarations`
426
- // returns the empty set, so `integrationCsp` stays undefined (the page-cache key
427
- // is byte-identical to the non-integration form) and the environment-only plan is
428
- // resolved below exactly as before — zero cost.
429
- let resolvedBrowserEnvironment: BrowserEnvironment | null = null;
430
- const integrationDeclarations = [
431
- ...loadTenantIntegrationDeclarations(tenant),
432
- ...managedIntegrationDeclarations,
433
- ];
434
- if (integrationDeclarations.length > 0) {
435
- const declPreviewSecret = (await readEnv("CUSTOMIZATION_PREVIEW_SECRET")) ?? null;
436
- const declEnv = await resolveCustomizationEnv({
437
- isPreviewHost,
438
- tenantId: tenant.tenant_id,
439
- token: queryToken ?? cookieToken,
440
- secret: declPreviewSecret,
441
- nowSeconds: Math.floor(Date.now() / 1000),
442
- pathname: url.pathname,
443
- });
444
- const declFeatures = resolveTenantFeatures(tenant);
445
- resolvedBrowserEnvironment = requestBrowserEnvironment({
446
- host,
447
- isPreviewHost,
448
- customizationEnv: declEnv.env,
449
- canonicalHost: tenant.canonicalDomain || host,
450
- siteSurface: deriveSiteSurface({ catalog: declFeatures.catalog, pathname: renderPath }),
451
- });
452
- const bridge = resolveInterimBridgePlan({
453
- tenantPublicId: tenant.tenant_id,
142
+ // Platform bytes: the tenant's own bundle owns them when it has one — a
143
+ // published `/_astro/<hash>.css` exists ONLY in R2, never in this Worker's build
144
+ // output — and otherwise `next()` hands back the built asset. Either way nothing
145
+ // tenant-shaped renders over it, which is the origin compute ADR 0013 exists to
146
+ // keep off the public path. Managed installs are not consulted: they inject
147
+ // document markup, never asset bytes, so this stays a D1-free path.
148
+ if (branch === "static-passthrough") {
149
+ const bundled = await tryServeStaticBundle({
150
+ request,
151
+ url,
454
152
  tenant,
455
- environment: resolvedBrowserEnvironment,
456
- declarations: integrationDeclarations,
153
+ basePath,
154
+ cache,
155
+ kv,
156
+ isPreviewHost: host.toLowerCase().startsWith("preview."),
157
+ hasPreviewToken:
158
+ url.searchParams.has("__preview") || !!context.cookies.get("tot_preview")?.value,
159
+ hasCandidatePin: !!(await resolveCandidateContentPin(url, tenant.tenant_id)),
160
+ hasDraftCookie: !!context.cookies.get(DEV_DRAFT_COOKIE)?.value,
161
+ hasManagedInstalls: false,
457
162
  });
458
- if (bridge.ok) {
459
- // A resolved plan serves; its compiled CSP additions + `planRevision` reach the
460
- // cache key and the header. A refusal (regulated) or an invalid set leaves the
461
- // plan unset here → the environment-only fallback below fills it (nothing loads).
462
- locals.integrationPlan = bridge.plan;
463
- const compiled = compileIntegrationCspFromPlan(
464
- bridge.plan,
465
- collectDeclaredCspProfiles(integrationDeclarations),
466
- );
467
- if (compiled.ok) locals.integrationCsp = compiled.value;
468
- }
163
+ if (bundled) return bundled;
164
+ const asset = basePath ? await next(`${renderPath}${url.search}`) : await next();
165
+ applySecurityHeaders(asset.headers);
166
+ return asset;
469
167
  }
470
168
 
471
- // ---- Edge page cache: short-circuit production browse reads on a hit,
472
- // BEFORE building the client / theme / rendering. Misses store the final
473
- // response at the end of this middleware.
474
- //
475
- // NEVER in dev (`astro dev`): the workerd dev runtime persists the Workers Cache
476
- // API in .wrangler/state, so a cached page would be served stale across edits AND
477
- // restarts — silently defeating hot-reload (edits look "lost"). Dev must always
478
- // re-render. import.meta.env.DEV statically drops this guard from the prod build.
479
- const pageCacheEligible =
480
- !import.meta.env.DEV &&
481
- request.method.toUpperCase() === "GET" &&
482
- !isPreviewHost &&
483
- !queryToken &&
484
- !cookieToken &&
485
- !candidatePin &&
486
- !draftCookie &&
487
- isCacheablePath(url.pathname, url.searchParams);
488
- let pageKey: Request | null = null;
489
- if (pageCacheEligible && cache && kv) {
490
- const version = await readCacheVersion(kv, tenant.tenant_id);
491
- // Fold the resolved integration plan revision into the key so a warm entry is
492
- // version-scoped: a rollback/uninstall resolves a new revision → old entry
493
- // unreachable → clean re-render without the removed origins (never a TTL wait).
494
- // Undefined until the integration plane is resolved for this tenant — the key
495
- // is then byte-identical to the non-integration form. The store below reuses
496
- // this same `pageKey`, so read and write stay consistent.
497
- pageKey = pageCacheKey(
498
- tenant.tenant_id,
499
- url.pathname,
500
- url.search,
501
- version,
502
- locals.integrationCsp?.planRevision,
503
- );
504
- const hit = await getCachedPage(cache, pageKey);
505
- // The cached artifact is nonce-free (carries the placeholder). Stamp a fresh
506
- // per-response nonce into its body + CSP header so a warm hit is served with a
507
- // brand-new nonce, never the one an earlier response rendered with.
508
- if (hit) return await stampResponseNonce(hit, freshNonce());
509
- }
510
-
511
- // Catalog source selection (docs/architecture/product-catalog.md).
512
- // A store's product source graduates over its life: JSON fixtures (dev default) ->
513
- // D1 edge replica (staging) -> hosted ToT catalog (go-live). Here we pick the read
514
- // backend: USE_D1_CATALOG=1 (+ a CATALOG_DB binding) reads the D1 edge replica;
515
- // otherwise createToTClient falls back to the bundled JSON fixture snapshot. The
516
- // hosted catalog graduates the SAME canonical records into the D1 read model
517
- // (emitCatalogSql), so JSON/D1/hosted render identically — pinned by
518
- // lib/tot/sourceSelection.test.ts.
519
- const d1 =
520
- (await readEnv("USE_D1_CATALOG")) === "1" ? await readD1("CATALOG_DB") : undefined;
521
- const tot = await createToTClient(tenant, d1);
522
-
523
- const proto = url.protocol === "http:" ? "http" : "https";
524
- // Canonical base uses the tenant's canonical domain in prod; for local dev we
525
- // keep the actual host (plus any /<domain> base path) so links resolve.
526
- const isLocal = host.startsWith("localhost") || host.startsWith("127.0.0.1");
527
- const canonicalBase = isLocal
528
- ? `${proto}://${host}${basePath}`
529
- : `https://${tenant.canonicalDomain}`;
530
-
531
- // Live vs test customization. Production hosts always serve "live" and
532
- // ignore any token; a "preview." host is auth-gated by a signed, tenant-bound,
533
- // short-TTL preview token (?__preview= on first hit, then the tot_preview
534
- // cookie). Test is never indexable (X-Robots-Tag below).
535
- const previewToken = queryToken ?? cookieToken;
536
- const previewSecret = (await readEnv("CUSTOMIZATION_PREVIEW_SECRET")) ?? null;
537
- const nowSeconds = Math.floor(Date.now() / 1000);
538
- const customization = await resolveCustomizationEnv({
539
- isPreviewHost,
540
- tenantId: tenant.tenant_id,
541
- token: previewToken,
542
- secret: previewSecret,
543
- nowSeconds,
544
- pathname: url.pathname,
545
- });
546
- if (customization.reject) {
547
- return new Response(customization.reject.message, {
548
- status: customization.reject.status,
549
- });
550
- }
551
- // Persist a query-supplied token as a cookie so later navigations stay in test.
552
- if (customization.env === "test" && queryToken) {
553
- context.cookies.set("tot_preview", queryToken, {
554
- httpOnly: true,
555
- secure: true,
556
- sameSite: "lax",
557
- path: "/",
558
- maxAge: 3600,
559
- });
560
- }
561
-
562
- // Apply a published theme override for the resolved environment (store->app).
563
- // The live domain serves `live`; a gated preview serves `test`. When nothing
564
- // is published the build-time tenant theme is used unchanged.
565
- //
566
- // Resolution is versioned-aware inside readPublishedArtifact: env maps to a
567
- // publish channel (live->live, test->preview); if the tenant has a channel
568
- // pointer, the artifact is resolved through the immutable site version
569
- // (channel -> versionId -> manifest -> content). Tenants with no channel
570
- // pointer fall back to the legacy per-path selection — no behavior change.
571
- const kvGet = kv ? (key: string) => kv.get(key) : async () => null;
572
-
573
- // Self-heal a DANGLING channel pointer: the pointer for this env's channel may
574
- // name a version that was never built (or whose manifest was evicted).
575
- // `readVersionedArtifact` already serves last-built content instead of emptying
576
- // the surface; here we do the once-per-request detection to ENQUEUE a rebuild of
577
- // the dangling version. Fail-open: a KV hiccup never blocks the render (the reads
578
- // below still run).
579
- if (kv) {
580
- const channel = channelFromEnv(customization.env);
581
- await resolveServableChannelVersion(kvGet, tenant.tenant_id, channel, ({ versionId }) => {
582
- recordActivity({
583
- action: "preview.build.requested",
584
- actor: { kind: "system", id: "storefront" },
585
- source: "server",
586
- outcome: { status: "succeeded" },
587
- scope: { tenantId: tenant.tenant_id },
588
- payload: { args: { changeId: versionId } },
589
- });
590
- }).catch(() => {
591
- /* fail-open: self-heal detection never breaks the request */
592
- });
593
- }
594
-
595
- // Verify the developer-draft cookie (signature + expiry + tenant binding) — the
596
- // overlay below only engages for the developer who minted the draft, on THIS
597
- // tenant. Same secret + trust model as the preview token; invalid/expired/foreign
598
- // tokens degrade silently to the base chain (never an error page).
599
- const draftClaims =
600
- draftCookie && previewSecret && kv
601
- ? await verifyDevDraftToken(previewSecret, draftCookie, nowSeconds)
602
- : null;
603
- const draft =
604
- draftClaims && draftClaims.tenantId === tenant.tenant_id ? draftClaims : null;
605
-
606
- let theme = mergeTheme(referenceTheme, tenant.theme_tokens);
607
- const publishedTheme = await readPublishedArtifact(
608
- kvGet,
609
- tenant.tenant_id,
610
- customization.env,
611
- themeArtifactPath(tenant.tenant_id),
612
- );
613
- if (publishedTheme) {
614
- try {
615
- theme = mergeTheme(theme, JSON.parse(publishedTheme) as DeepPartial<ThemeTokens>);
616
- } catch {
617
- /* malformed published artifact — keep the build-time theme */
618
- }
619
- }
620
- // Developer-draft theme overlay (on top of published) — only for the draft owner.
621
- if (draft) {
622
- const draftTheme = await kvGet(devDraftKey(tenant.tenant_id, draft.draftId, "theme.json"));
623
- if (draftTheme) {
624
- try {
625
- theme = mergeTheme(theme, JSON.parse(draftTheme) as DeepPartial<ThemeTokens>);
626
- } catch {
627
- /* malformed draft artifact — keep the resolved theme */
628
- }
629
- }
630
- }
631
-
632
- // Per-environment capabilities (Track A/B). Config-as-code: the build-time
633
- // TenantConfig default is overridden by the published capabilities artifact for
634
- // THIS environment — materialized from the tenant repo's .tot/config.json per
635
- // commit (localhost=working tree, preview=PR head, live=promoted). So a
636
- // capability change is PR-gated and separable per environment.
637
- let publishedCaps: Partial<CapabilityConfig> | null = null;
638
- const publishedCapsRaw = await readPublishedArtifact(
639
- kvGet,
640
- tenant.tenant_id,
641
- customization.env,
642
- capabilitiesArtifactPath(tenant.tenant_id),
643
- );
644
- if (publishedCapsRaw) {
645
- try {
646
- publishedCaps = JSON.parse(publishedCapsRaw) as Partial<CapabilityConfig>;
647
- } catch {
648
- /* malformed published artifact — keep the build-time default */
649
- }
650
- }
651
- const capabilities = resolveCapabilities(tenant, publishedCaps);
652
-
653
- // Embed opt-in (self-serve, config-as-code on the same spine as capabilities):
654
- // the build-time TenantConfig.embeds default is overlaid with the tenant's
655
- // published embeds.json for THIS env, then filtered to the vetted catalog. A
656
- // tenant enables a catalogued embed by committing embeds.json — no platform
657
- // change — but can never introduce a non-catalog origin.
658
- let publishedEmbeds: string[] | null = null;
659
- const publishedEmbedsRaw = await readPublishedArtifact(
660
- kvGet,
661
- tenant.tenant_id,
662
- customization.env,
663
- embedsArtifactPath(tenant.tenant_id),
664
- );
665
- if (publishedEmbedsRaw) {
666
- try {
667
- const parsed = JSON.parse(publishedEmbedsRaw);
668
- if (Array.isArray(parsed)) publishedEmbeds = parsed.filter((s) => typeof s === "string");
669
- } catch {
670
- /* malformed published artifact — keep the build-time default */
671
- }
672
- }
673
- const effectiveEmbeds = resolveEffectiveEmbeds(tenant.embeds, publishedEmbeds);
674
-
675
- // Nonce placeholder: the platform's inline tags AND the CSP header are rendered
676
- // with this sentinel, never a live nonce (ADR 0018). A fresh per-response nonce
677
- // is stamped in at serve time (stampResponseNonce below), so the cached artifact
678
- // is nonce-free and every response — cold render or warm cache hit — gets a fresh
679
- // matching nonce/CSP pair.
680
- const cspNonce = INTEGRATION_NONCE_PLACEHOLDER;
681
-
682
- locals.tenant = tenant;
683
- // Effective capability flags for this tenant (commerce defaults unless the
684
- // tenant declares siteType:"marketing" / explicit features). Pages and chrome
685
- // read these instead of hard-coding tenant identity checks.
686
- locals.features = resolveTenantFeatures(tenant);
687
- locals.capabilities = capabilities;
688
- // Cart/checkout facts (Track A) — only when the tenant has a catalog
689
- // AND the cartCheckout capability is enabled for THIS environment. Drives the
690
- // loader + signed add-to-cart, and the CSP additions below.
691
- const commerce =
692
- locals.features.catalog && capabilities.cartCheckout.enabled
693
- ? await resolveTenantCommerce(tenant)
694
- : null;
695
- locals.commerce = commerce ?? undefined;
696
- locals.tot = tot;
697
- // A verified candidate content pin takes priority over the channel-resolved
698
- // read — a pinned candidate/revision render must never fall through to the
699
- // shared `{live,test}` content (see `pinnedRouteRender.ts`).
700
- locals.content = candidatePin
701
- ? createContentProvider(tenant, undefined, { get: kvGet, versionId: candidatePin.versionId })
702
- : createContentProvider(tenant, {
703
- get: kvGet,
704
- env: customization.env,
705
- });
706
- // Developer-draft content overlay — serves the AI-edited artifacts to the draft
707
- // owner only; every other viewer keeps the published/base chain above.
708
- if (draft) {
709
- locals.content = new DevDraftContentProvider(
710
- locals.content,
711
- tenant.tenant_id,
712
- draft.draftId,
713
- kvGet,
714
- );
715
- }
716
- locals.themeStyle = themeToCssVars(theme);
717
- locals.canonicalBase = canonicalBase;
718
- locals.customizationEnv = customization.env;
719
- locals.cspNonce = cspNonce;
720
- // Seeded here, filled in by whichever page/layout renders a widget slot
721
- // (renderSlot.ts) as the page renders inside next() below — read back out
722
- // after next() resolves so the CSP frame-src reflects exactly what rendered.
723
- locals.activeWidgetPlacements = [];
724
- // Prefix that path-routed pages live under ("" for host-routed). Pages stay
725
- // tenant-agnostic; the Layout uses this to keep root-absolute links inside
726
- // the tenant prefix on the client. A candidate content pin overrides this to
727
- // the PINNING render's own prefix (`/preview/rev/<sha>` or
728
- // `/preview/render/<tenant>/<changeId>`) so a pinned page's OWN generated
729
- // links stay inside that candidate instead of escaping to the live host.
730
- locals.basePath = candidatePin ? candidatePin.basePath : basePath;
731
- // Renderer-owned four-stage browser environment (ADR 0014 E1 / 0017), computed
732
- // ONCE here from authoritative request state and serialized before app code by
733
- // both document mounts (Layout.astro + rawHtmlResponse). The candidate render
734
- // paths build their own `candidate`-stage object instead of this one.
735
- // Reuse the environment resolved for a declared tenant's integration plane above
736
- // (identical inputs → identical object) so the plan's environment and this object
737
- // never diverge; an undeclared tenant computes it here for the first time.
738
- locals.browserEnvironment =
739
- resolvedBrowserEnvironment ??
740
- requestBrowserEnvironment({
169
+ if (branch === "public-render") {
170
+ return await servePublicRender({
171
+ context,
172
+ next,
741
173
  host,
742
- isPreviewHost,
743
- customizationEnv: customization.env,
744
- canonicalHost: tenant.canonicalDomain || host,
745
- siteSurface: deriveSiteSurface({ catalog: locals.features.catalog, pathname: renderPath }),
174
+ url,
175
+ tenant,
176
+ basePath,
177
+ renderPath,
178
+ cache,
179
+ kv,
746
180
  });
747
- // Resolved effective plan (ext-kernel-03) for this request — the fixed-phase
748
- // render engine (ext-kernel-04) consumes it in BOTH document mounts (Layout.astro
749
- // + rawHtmlResponse). A declared tenant's plan is already resolved above (through
750
- // the interim bridge); an undeclared tenant — or one the bridge refused / failed
751
- // closed — resolves the environment-only plan here, so a page with no serving
752
- // integration emits no vendor runtime/markup.
753
- locals.integrationPlan =
754
- locals.integrationPlan ??
755
- resolveEnvironmentOnlyPlan(locals.browserEnvironment, tenant.tenant_id);
756
-
757
- // /dev was renamed to /cockpit (2026-07-20). 302-redirect any old /dev link —
758
- // host-routed (/dev) or path-routed (/<vendor>/dev) — to the new path, preserving
759
- // the tenant prefix + query. Temporary (302) while the cockpit UX is still evolving.
760
- if (renderPath === "/dev" || renderPath.startsWith("/dev/")) {
761
- const rest = renderPath.slice("/dev".length); // "" or "/sub…"
762
- return Response.redirect(`${url.origin}${basePath}/cockpit${rest}${url.search}`, 302);
763
181
  }
764
182
 
765
- // When a /<domain>/ prefix selected the tenant, rewrite to the stripped route
766
- // so the existing file-based routes match. next(path) rewrites WITHOUT
767
- // re-running middleware, so the locals set above are preserved.
768
- let response = basePath
769
- ? await next(`${renderPath}${url.search}`)
770
- : await next();
771
-
772
- // On-publish auto-refresh: inject the live-reload client into SHARED preview
773
- // (`test`) HTML pages ONLY — never `live` (keeps live clean + edge-cacheable).
774
- // It polls /__preview/version and reloads when this tenant's cache-version
775
- // bumps on publish/reconcile. Injection happens before the CSP header is set so
776
- // the script's per-request nonce (cspNonce) matches the enforced policy.
777
- response = await maybeInjectPreviewReload(response, {
778
- env: customization.env,
779
- tenantId: tenant.tenant_id,
183
+ return await serveControlPlane({
184
+ context,
185
+ next,
186
+ host,
187
+ url,
188
+ tenant,
780
189
  basePath,
781
- nonce: cspNonce,
190
+ renderPath,
191
+ cache,
192
+ kv,
193
+ gateMode,
194
+ demoGateHosts,
195
+ hostLoginGated,
782
196
  });
783
-
784
- // Baseline security headers on every response — set before the page-cache
785
- // store below so cached copies carry them too.
786
- applySecurityHeaders(response.headers);
787
-
788
- // Preview (test) output must never be indexed.
789
- if (customization.noindex) {
790
- response.headers.set("X-Robots-Tag", "noindex, nofollow");
791
- }
792
-
793
- // Strict CSP — now ENFORCED in every environment (Epic S2). The report-only
794
- // rollout is complete: platform output is CSP-clean — inline styles are
795
- // externalized (`inlineStylesheets: "never"`), Astro's static inline scripts +
796
- // island style are hash-pinned (cspStaticHashes), our dynamic inline tags carry
797
- // the per-request nonce, and the last inline event handler (SortSelect's
798
- // `onchange`) is now a bundled 'self' script. Reporting endpoints stay attached
799
- // (report-to/report-uri work in enforce mode), so we keep telemetry as tenant
800
- // client JS arrives — those per-version tenant hashes extend `scriptSrc` per
801
- // request (the mechanism below already supports it).
802
- //
803
- // DEV EXCEPTION: `astro dev` serves CSS/HMR as UN-nonced inline <style>/<script>
804
- // (Vite injects them); the strict nonce policy blocks those, leaving the dev
805
- // site unstyled. The BUILD is CSP-clean (styles are externalized via
806
- // `inlineStylesheets:"never"`), so we enforce there and only REPORT in dev —
807
- // violations are still surfaced, but styling/HMR work locally.
808
- const cspHeader = cspHeaderName(import.meta.env.DEV);
809
- response.headers.set(
810
- "Reporting-Endpoints",
811
- `${CSP_REPORT_GROUP}="${CSP_REPORT_PATH}"`,
812
- );
813
- // Checkout origins (loader CDN + the store's cart/checkout host) added to the
814
- // strict policy only when this tenant has a store — so add-to-cart, the
815
- // sidecart iframe/XHR, and the cross-origin form POST all pass.
816
- const checkoutCsp = commerce ? checkoutCspAdditions(commerce.origin) : null;
817
- // Private-app widget origins — read back AFTER next() so this reflects
818
- // exactly the placements that rendered on THIS page, never a wildcard.
819
- const widgetCsp = appWidgetCspAdditions(locals.activeWidgetPlacements ?? []);
820
- // Third-party marketing embeds (e.g. a Pipedrive form) the tenant opted into
821
- // via .tot/config.json `embeds:`. Only scan the HTML body when the tenant
822
- // declared at least one embed — zero cost for everyone else — and add each
823
- // provider's origins ONLY if its marker is actually on THIS page.
824
- // Prefer an opt-in the render handler resolved for a tenant the URL router
825
- // couldn't (the candidate renderer sets locals.embedOptIn — see candidateRender);
826
- // otherwise use the router-resolved tenant's effective embeds.
827
- const embedsForScan = locals.embedOptIn ?? effectiveEmbeds;
828
- let embedCsp = { scriptSrc: [] as string[], connectSrc: [] as string[], frameSrc: [] as string[] };
829
- if (
830
- embedsForScan.length > 0 &&
831
- (response.headers.get("content-type") ?? "").includes("text/html")
832
- ) {
833
- embedCsp = embedCspAdditions(embedsForScan, await response.clone().text());
834
- }
835
- const cspOptions = {
836
- nonce: cspNonce,
837
- ...STOREFRONT_CSP_ALLOWLIST,
838
- // Pin Astro's static inline hydration scripts + island style by hash
839
- // (they're identical every request); my dynamic inline tags use the nonce.
840
- scriptSrc: [
841
- ...STOREFRONT_CSP_ALLOWLIST.scriptSrc,
842
- ...STATIC_SCRIPT_HASHES,
843
- ...(checkoutCsp?.scriptSrc ?? []),
844
- ...embedCsp.scriptSrc,
845
- ],
846
- styleSrc: [
847
- ...STOREFRONT_CSP_ALLOWLIST.styleSrc,
848
- ...STATIC_STYLE_HASHES,
849
- ...(checkoutCsp?.styleSrc ?? []),
850
- ],
851
- fontSrc: [...STOREFRONT_CSP_ALLOWLIST.fontSrc, ...(checkoutCsp?.fontSrc ?? [])],
852
- connectSrc: [
853
- ...STOREFRONT_CSP_ALLOWLIST.connectSrc,
854
- ...(checkoutCsp?.connectSrc ?? []),
855
- ...embedCsp.connectSrc,
856
- ],
857
- imgSrc: [...STOREFRONT_CSP_ALLOWLIST.imgSrc, ...(checkoutCsp?.imgSrc ?? [])],
858
- frameSrc: [
859
- ...(checkoutCsp?.frameSrc ?? []),
860
- ...widgetCsp.frameSrc,
861
- ...embedCsp.frameSrc,
862
- ],
863
- formAction: checkoutCsp?.formAction ?? [],
864
- reportGroup: CSP_REPORT_GROUP,
865
- reportUri: CSP_REPORT_PATH,
866
- };
867
- // Version-scoped integration plane: APPEND the resolved plan's compiled origins
868
- // (curated adapter profiles + advanced exact origins) to the six contributable
869
- // directives only — never the base. Absent (no integration) this is a no-op, so
870
- // the header is byte-identical to a non-integration tenant's.
871
- response.headers.set(
872
- cspHeader,
873
- buildCsp(foldIntegrationCsp(cspOptions, locals.integrationCsp?.additions ?? {})),
874
- );
875
-
876
- // Store the fully-rendered, CSP-stamped response for the next reader. Only
877
- // production (no preview token reached here) cacheable GETs have a pageKey;
878
- // putCachedPage additionally skips non-200, non-HTML, and cookie-bearing
879
- // responses. Also expose Cache-Control to any fronting CDN.
880
- if (pageKey && cache && customization.env === "live") {
881
- response.headers.set(
882
- "Cache-Control",
883
- "public, s-maxage=300, stale-while-revalidate=86400",
884
- );
885
- // Store the nonce-free artifact (the placeholder is still in the body + CSP
886
- // header) BEFORE stamping — so the cache never holds a live/reusable nonce.
887
- await putCachedPage(cache, pageKey, response);
888
- }
889
- // Stamp a fresh per-response nonce into this cold response's body + CSP header
890
- // (the warm-hit path above stamps its own). Both paths therefore serve a fresh,
891
- // matching nonce/CSP pair, and no nonce is reused across responses (ADR 0018).
892
- return await stampResponseNonce(response, freshNonce());
893
197
  });