@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.
- package/apps/storefront/env.d.ts +21 -0
- package/apps/storefront/migrations-apps/0012_test_destinations.sql +29 -0
- package/apps/storefront/src/components/blog/ReactionBar.astro +1 -1
- package/apps/storefront/src/components/islands/CheckoutComplianceGate.tsx +365 -49
- package/apps/storefront/src/components/islands/checkoutVerificationFlow.ts +251 -0
- package/apps/storefront/src/config/compliance/rulesets.ts +18 -7
- package/apps/storefront/src/config/devTenantSeed.ts +24 -1
- package/apps/storefront/src/config/evidenceTenantRoster.ts +25 -0
- package/apps/storefront/src/config/publicationSpaceConfig.ts +25 -0
- package/apps/storefront/src/config/resolver.ts +5 -0
- package/apps/storefront/src/config/tenantAnalyticsFiles.ts +43 -0
- package/apps/storefront/src/config/tenants.ts +11 -0
- package/apps/storefront/src/layouts/Layout.astro +5 -1
- package/apps/storefront/src/lib/activity/killSwitch.ts +1 -1
- package/apps/storefront/src/lib/admin/consentSettingsClient.ts +113 -0
- package/apps/storefront/src/lib/apps/consentConfigRead.ts +24 -0
- package/apps/storefront/src/lib/apps/gtmMigrationDraft.ts +103 -0
- package/apps/storefront/src/lib/auth/brokerAssertion.ts +18 -0
- package/apps/storefront/src/lib/auth/identityToken.ts +11 -1
- package/apps/storefront/src/lib/auth/internalContentAuthority.ts +153 -0
- package/apps/storefront/src/lib/auth/loginGate.ts +25 -0
- package/apps/storefront/src/lib/auth/session.ts +3 -0
- package/apps/storefront/src/lib/checkoutCommerce.ts +1 -1
- package/apps/storefront/src/lib/compliance/enforcement.ts +25 -3
- package/apps/storefront/src/lib/d1/schema-apps.ts +44 -0
- package/apps/storefront/src/lib/edgeCache.ts +17 -1
- package/apps/storefront/src/lib/env.ts +13 -111
- package/apps/storefront/src/lib/integration/tenantAnalyticsDeclarations.ts +198 -0
- package/apps/storefront/src/lib/monitoring/manifest.ts +1 -1
- package/apps/storefront/src/lib/publication/contentProvider.ts +159 -0
- package/apps/storefront/src/lib/publication/runtimeSource.ts +97 -0
- package/apps/storefront/src/lib/rawMarketingHtml.ts +9 -1
- package/apps/storefront/src/lib/runtimeEnv.ts +124 -0
- package/apps/storefront/src/lib/verification/totVerificationClient.ts +485 -0
- package/apps/storefront/src/middleware/candidatePin.ts +35 -0
- package/apps/storefront/src/middleware/controlPlane.ts +291 -0
- package/apps/storefront/src/middleware/index.ts +200 -793
- package/apps/storefront/src/middleware/publicRender.ts +43 -0
- package/apps/storefront/src/middleware/renderChain.ts +730 -0
- package/apps/storefront/src/middleware/routeBranch.ts +192 -0
- package/apps/storefront/src/middleware/staticBundle.ts +109 -0
- package/apps/storefront/src/pages/[...slug].astro +1 -0
- package/apps/storefront/src/pages/api/auth/magic-exchange.ts +1 -0
- package/apps/storefront/src/pages/api/auth/step-up-verify.ts +1 -0
- package/apps/storefront/src/pages/api/auth/verify.ts +1 -0
- package/apps/storefront/src/pages/api/compliance/preflight.ts +179 -2
- package/apps/storefront/src/pages/index.astro +1 -0
- package/apps/storefront/vitest.config.ts +5 -0
- package/package.json +1 -1
- package/packages/public-runtime/package.json +1 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/deliver-test-event-request.schema.json +122 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/get-test-destination-delivery-request.schema.json +79 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/register-test-destination-request.schema.json +93 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/test-destination-delivery.schema.json +106 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/test-destination.schema.json +77 -0
- package/packages/public-runtime/schemas/publication-space/v1/manifest.schema.json +195 -0
- package/packages/public-runtime/schemas/publication-space/v1/normalized-mount.schema.json +80 -0
- package/packages/public-runtime/schemas/publication-space/v1/ref.schema.json +20 -0
- package/packages/public-runtime/schemas/publication-version/v1/pointer.schema.json +28 -0
- package/packages/public-runtime/schemas/publication-version/v1/transition-request.schema.json +44 -0
- package/packages/public-runtime/schemas/publication-version/v1/transition.schema.json +47 -0
- package/packages/public-runtime/schemas/publication-version/v1/version.schema.json +58 -0
- package/packages/public-runtime/src/activity/catalog.ts +1 -1
- package/packages/public-runtime/src/candidate-index.ts +6 -0
- package/packages/public-runtime/src/customization-reconcile.ts +8 -0
- package/packages/public-runtime/src/html-cache-policy.ts +57 -0
- package/packages/public-runtime/src/index.ts +9 -0
- package/packages/public-runtime/src/integration/consent-broker.ts +115 -0
- package/packages/public-runtime/src/integration/interim-bridge.ts +111 -27
- package/packages/public-runtime/src/integration/managed-analytics-api-v1.schemas.ts +65 -0
- package/packages/public-runtime/src/integration/managed-analytics-api-v1.ts +81 -0
- package/packages/public-runtime/src/integration/render-phases.ts +8 -3
- package/packages/public-runtime/src/publication-artifact.ts +220 -0
- package/packages/public-runtime/src/publication-reader.ts +187 -0
- package/packages/public-runtime/src/publication-routing.ts +246 -0
- package/packages/public-runtime/src/publication-space.ts +853 -0
- package/packages/public-runtime/src/publication-version.ts +602 -0
- package/packages/public-runtime/src/route-tier.ts +257 -0
- package/packages/public-runtime/src/static-bundle.ts +18 -5
- package/packages/public-runtime/src/tenant-assets.ts +85 -17
- package/packages/public-runtime/src/tenant.ts +70 -0
- package/packages/public-runtime/tests/fixtures/publication-spaces/internal.json +32 -0
- package/packages/public-runtime/tests/fixtures/publication-spaces/mounted-public.json +38 -0
- package/packages/public-runtime/tests/fixtures/publication-spaces/normalized-mounts.json +39 -0
- package/packages/public-runtime/tests/fixtures/publication-spaces/ref.json +5 -0
- package/packages/public-runtime/tests/fixtures/publication-spaces/root-public.json +31 -0
- package/packages/public-runtime/tests/fixtures/publication-versions/articles-v1.json +20 -0
- package/packages/public-runtime/tests/fixtures/publication-versions/articles-v2.json +20 -0
- package/packages/public-runtime/tests/fixtures/publication-versions/internal-v1.json +20 -0
- package/packages/public-runtime/tests/fixtures/publication-versions/live-pointer.json +11 -0
- package/packages/public-runtime/tests/fixtures/publication-versions/live-transition-request.json +14 -0
- package/packages/public-runtime/tests/fixtures/publication-versions/live-transition.json +17 -0
- package/packages/public-runtime/tests/fixtures/publication-versions/root-v1.json +20 -0
- package/packages/public-runtime/tests/fixtures/publication-versions/root-v2.json +20 -0
- package/scripts/dev/ai-edit.mjs +16 -3
- package/scripts/dev/port-check.mjs +18 -3
- package/scripts/dev/publish.mjs +26 -3
- package/scripts/tenant/validate.mjs +14 -1
|
@@ -1,87 +1,67 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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 the
|
|
9
|
+
* publication route planner for its route-tier answer and returns the branch,
|
|
10
|
+
* 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 {
|
|
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, readR2 } from "@/lib/runtimeEnv";
|
|
15
25
|
import { resolveTenantRouting } from "@/lib/tenantRouting";
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
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 { PublicationArtifactBucketV1, TenantConfig } from "@tot/public-runtime";
|
|
28
|
+
import { DEV_DRAFT_COOKIE } from "@/lib/dev/devDraft";
|
|
27
29
|
import { applySecurityHeaders } from "@/lib/securityHeaders";
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
30
|
-
|
|
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
|
|
38
|
+
import {
|
|
39
|
+
publicationRedirectLocationV1,
|
|
40
|
+
resolveMiddlewareBranchV1,
|
|
41
|
+
} from "./routeBranch.js";
|
|
42
|
+
import { servePublicRender } from "./publicRender.js";
|
|
43
|
+
import { serveControlPlane } from "./controlPlane.js";
|
|
44
|
+
import { KvSessionStore, SESSION_COOKIE } from "@/lib/auth/session";
|
|
45
|
+
import { mintOpaqueBrokerAssertion } from "@/lib/auth/brokerAssertion";
|
|
46
|
+
import {
|
|
47
|
+
applyInternalContentResponsePolicy,
|
|
48
|
+
authorizeInternalContentRequest,
|
|
49
|
+
type InternalContentGrant,
|
|
50
|
+
} from "@/lib/auth/internalContentAuthority";
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Compiled once per tenant-config object, as the resolver's contract asks —
|
|
54
|
+
* validation is pure and its inputs only change when the config object does.
|
|
55
|
+
*/
|
|
56
|
+
const routeTierIndexes = new WeakMap<TenantConfig, RouteTierIndexV1>();
|
|
57
|
+
|
|
58
|
+
function routeTierIndexFor(tenant: TenantConfig): RouteTierIndexV1 {
|
|
59
|
+
const cached = routeTierIndexes.get(tenant);
|
|
60
|
+
if (cached) return cached;
|
|
61
|
+
const compiled = compileRouteTierIndexV1(tenant);
|
|
62
|
+
routeTierIndexes.set(tenant, compiled);
|
|
63
|
+
return compiled;
|
|
64
|
+
}
|
|
85
65
|
|
|
86
66
|
export const onRequest = defineMiddleware(async (context, next) => {
|
|
87
67
|
const { request, locals } = context;
|
|
@@ -126,9 +106,8 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
126
106
|
// "password" (default) — interim Basic-Auth gate, enforced HERE (host-level,
|
|
127
107
|
// no tenant/session needed) so a blocked host never reaches tenant
|
|
128
108
|
// resolution, the edge cache, or any render.
|
|
129
|
-
// "login" — "Sign in with ToT" gate, enforced
|
|
130
|
-
// tenant as the grant `resource` + the SESSION KV)
|
|
131
|
-
// /auth/login unless a valid, resource-bound tot_session cookie is present.
|
|
109
|
+
// "login" — "Sign in with ToT" gate, enforced on the control-plane branch
|
|
110
|
+
// (it needs the resolved tenant as the grant `resource` + the SESSION KV).
|
|
132
111
|
const gateMode = parseGateMode(await readEnv("DEMO_GATE_MODE"));
|
|
133
112
|
const demoGateHosts = await readEnv("DEMO_GATE_HOSTS");
|
|
134
113
|
if (gateMode === "password") {
|
|
@@ -153,741 +132,169 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
153
132
|
return new Response("Unknown host", { status: 404 });
|
|
154
133
|
}
|
|
155
134
|
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
// a
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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,
|
|
135
|
+
// ---- The one route classification -----------------------------------------
|
|
136
|
+
const hostLoginGated = gateMode === "login" && hostInGateList(host, demoGateHosts);
|
|
137
|
+
const { branch, route, publication } = resolveMiddlewareBranchV1({
|
|
138
|
+
index: routeTierIndexFor(tenant),
|
|
139
|
+
renderPath,
|
|
140
|
+
hostLoginGated,
|
|
141
|
+
});
|
|
142
|
+
locals.routeTier = route;
|
|
143
|
+
locals.publicationRoute = publication;
|
|
144
|
+
|
|
145
|
+
// Public aliases are an edge decision from the same normalized publication
|
|
146
|
+
// plan. Preserve the path-routing tenant prefix and request query while the
|
|
147
|
+
// canonical target itself remains tenant-relative. Authorization-gated alias
|
|
148
|
+
// redirects stay on the control-plane branch for S1 to admit before revealing
|
|
149
|
+
// a private canonical route.
|
|
150
|
+
if (branch === "public-render" && publication.action === "redirect-308") {
|
|
151
|
+
const response = new Response(null, {
|
|
152
|
+
status: 308,
|
|
153
|
+
headers: {
|
|
154
|
+
location: publicationRedirectLocationV1({
|
|
155
|
+
origin: url.origin,
|
|
156
|
+
basePath,
|
|
157
|
+
plan: publication,
|
|
158
|
+
search: url.search,
|
|
159
|
+
}),
|
|
160
|
+
},
|
|
240
161
|
});
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
}
|
|
162
|
+
applySecurityHeaders(response.headers);
|
|
163
|
+
return response;
|
|
286
164
|
}
|
|
287
165
|
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
}
|
|
166
|
+
// Internal publication admission is bound to the exact F2 route before any
|
|
167
|
+
// artifact, public-bundle, shared-cache, render, or redirect operation. The
|
|
168
|
+
// session contributes only its verified opaque subject; current tenant scope
|
|
169
|
+
// comes from the live broker response on every request.
|
|
170
|
+
let internalContentGrant: InternalContentGrant | undefined;
|
|
171
|
+
if (publication.action !== "fail-closed" && route.requiresAuthorization) {
|
|
172
|
+
const sessionKv = await readKv("SESSION");
|
|
173
|
+
const decision = await authorizeInternalContentRequest({
|
|
174
|
+
route,
|
|
175
|
+
sessionStore: sessionKv ? new KvSessionStore(sessionKv) : undefined,
|
|
176
|
+
sessionId: context.cookies.get(SESSION_COOKIE)?.value ?? null,
|
|
177
|
+
coreUrl: await readEnv("TOT_CORE_URL"),
|
|
178
|
+
mintAssertion: mintOpaqueBrokerAssertion,
|
|
179
|
+
});
|
|
180
|
+
if (decision.allowed) internalContentGrant = decision;
|
|
181
|
+
if (!internalContentGrant) {
|
|
182
|
+
const response = new Response("Not Found", { status: 404 });
|
|
183
|
+
applyInternalContentResponsePolicy(response.headers);
|
|
184
|
+
applySecurityHeaders(response.headers);
|
|
185
|
+
return response;
|
|
319
186
|
}
|
|
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
187
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
188
|
+
// An internal alias reveals its target only after the same live decision as
|
|
189
|
+
// HTML/data/assets. It never becomes a public edge redirect.
|
|
190
|
+
if (publication.action === "redirect-308") {
|
|
191
|
+
const response = new Response(null, {
|
|
192
|
+
status: 308,
|
|
193
|
+
headers: {
|
|
194
|
+
location: publicationRedirectLocationV1({
|
|
195
|
+
origin: url.origin,
|
|
196
|
+
basePath,
|
|
197
|
+
plan: publication,
|
|
198
|
+
search: url.search,
|
|
199
|
+
}),
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
applyInternalContentResponsePolicy(response.headers);
|
|
203
|
+
applySecurityHeaders(response.headers);
|
|
204
|
+
return response;
|
|
338
205
|
}
|
|
339
206
|
}
|
|
340
207
|
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
}
|
|
208
|
+
// A render alias dispatches the same existing Astro route as its canonical
|
|
209
|
+
// mount. Content lookup keeps its separate mount-relative `contentPath` on the
|
|
210
|
+
// plan; no downstream layer decodes or strips the request again.
|
|
211
|
+
const publicationRenderPath = publication.action === "render"
|
|
212
|
+
? publication.canonicalPath
|
|
213
|
+
: renderPath;
|
|
415
214
|
|
|
416
|
-
//
|
|
417
|
-
//
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
//
|
|
428
|
-
//
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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,
|
|
215
|
+
// Workers Cache API: backs the full-page edge cache in the render chain, and
|
|
216
|
+
// the bundle read below. Undefined in `astro dev` (Node) — a no-op there.
|
|
217
|
+
const cache =
|
|
218
|
+
typeof caches !== "undefined" && "default" in caches
|
|
219
|
+
? (caches as unknown as { default: Cache }).default
|
|
220
|
+
: undefined;
|
|
221
|
+
const kv = await readKv("TENANT_CACHE");
|
|
222
|
+
const publicationBucket = publication.action === "render"
|
|
223
|
+
? await readR2("TENANT_ASSETS") as unknown as PublicationArtifactBucketV1 | undefined
|
|
224
|
+
: undefined;
|
|
225
|
+
|
|
226
|
+
// Platform bytes: the tenant's own bundle owns them when it has one — a
|
|
227
|
+
// published `/_astro/<hash>.css` exists ONLY in R2, never in this Worker's build
|
|
228
|
+
// output — and otherwise `next()` hands back the built asset. Either way nothing
|
|
229
|
+
// tenant-shaped renders over it, which is the origin compute ADR 0013 exists to
|
|
230
|
+
// keep off the public path. Managed installs are not consulted: they inject
|
|
231
|
+
// document markup, never asset bytes, so this stays a D1-free path.
|
|
232
|
+
if (branch === "static-passthrough") {
|
|
233
|
+
const bundled = await tryServeStaticBundle({
|
|
234
|
+
request,
|
|
235
|
+
url,
|
|
454
236
|
tenant,
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
bridge.plan,
|
|
465
|
-
collectDeclaredCspProfiles(integrationDeclarations),
|
|
466
|
-
);
|
|
467
|
-
if (compiled.ok) locals.integrationCsp = compiled.value;
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
|
|
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,
|
|
237
|
+
basePath,
|
|
238
|
+
cache,
|
|
239
|
+
kv,
|
|
240
|
+
isPreviewHost: host.toLowerCase().startsWith("preview."),
|
|
241
|
+
hasPreviewToken:
|
|
242
|
+
url.searchParams.has("__preview") || !!context.cookies.get("tot_preview")?.value,
|
|
243
|
+
hasCandidatePin: !!(await resolveCandidateContentPin(url, tenant.tenant_id)),
|
|
244
|
+
hasDraftCookie: !!context.cookies.get(DEV_DRAFT_COOKIE)?.value,
|
|
245
|
+
hasManagedInstalls: false,
|
|
549
246
|
});
|
|
247
|
+
if (bundled) return bundled;
|
|
248
|
+
const asset = basePath ? await next(`${renderPath}${url.search}`) : await next();
|
|
249
|
+
applySecurityHeaders(asset.headers);
|
|
250
|
+
return asset;
|
|
550
251
|
}
|
|
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
252
|
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
payload: { args: { changeId: versionId } },
|
|
589
|
-
});
|
|
590
|
-
}).catch(() => {
|
|
591
|
-
/* fail-open: self-heal detection never breaks the request */
|
|
253
|
+
if (branch === "public-render") {
|
|
254
|
+
return await servePublicRender({
|
|
255
|
+
context,
|
|
256
|
+
next,
|
|
257
|
+
host,
|
|
258
|
+
url,
|
|
259
|
+
tenant,
|
|
260
|
+
basePath,
|
|
261
|
+
renderPath: publicationRenderPath,
|
|
262
|
+
cache,
|
|
263
|
+
kv,
|
|
264
|
+
routeTierIndex: routeTierIndexFor(tenant),
|
|
265
|
+
publicationRequestPath: renderPath,
|
|
266
|
+
publicationRoute: publication,
|
|
267
|
+
publicationBucket,
|
|
592
268
|
});
|
|
593
269
|
}
|
|
594
270
|
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
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({
|
|
271
|
+
try {
|
|
272
|
+
const response = await serveControlPlane({
|
|
273
|
+
context,
|
|
274
|
+
next,
|
|
741
275
|
host,
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
276
|
+
url,
|
|
277
|
+
tenant,
|
|
278
|
+
basePath,
|
|
279
|
+
renderPath: publicationRenderPath,
|
|
280
|
+
cache,
|
|
281
|
+
kv,
|
|
282
|
+
routeTierIndex: routeTierIndexFor(tenant),
|
|
283
|
+
publicationRequestPath: renderPath,
|
|
284
|
+
publicationRoute: publication,
|
|
285
|
+
publicationBucket,
|
|
286
|
+
gateMode,
|
|
287
|
+
demoGateHosts,
|
|
288
|
+
hostLoginGated,
|
|
289
|
+
internalContentGrant,
|
|
746
290
|
});
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
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
|
-
}
|
|
764
|
-
|
|
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,
|
|
780
|
-
basePath,
|
|
781
|
-
nonce: cspNonce,
|
|
782
|
-
});
|
|
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);
|
|
291
|
+
if (internalContentGrant) applyInternalContentResponsePolicy(response.headers);
|
|
292
|
+
return response;
|
|
293
|
+
} catch (error) {
|
|
294
|
+
if (!internalContentGrant) throw error;
|
|
295
|
+
const response = new Response("Not Found", { status: 404 });
|
|
296
|
+
applyInternalContentResponsePolicy(response.headers);
|
|
297
|
+
applySecurityHeaders(response.headers);
|
|
298
|
+
return response;
|
|
888
299
|
}
|
|
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
300
|
});
|