@tokenoftrust/storefront-runner 2.0.18 → 2.0.20
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 +5 -0
- package/apps/storefront/migrations-apps/0013_publication_space_editorial.sql +64 -0
- package/apps/storefront/src/config/publicationSpaceRegistry.ts +19 -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/session.ts +3 -0
- package/apps/storefront/src/lib/blog/collection.ts +24 -18
- package/apps/storefront/src/lib/blog/provider.ts +28 -5
- package/apps/storefront/src/lib/blog/reactions.ts +4 -2
- package/apps/storefront/src/lib/blog/types.ts +1 -1
- package/apps/storefront/src/lib/blog/viewCounts.ts +3 -1
- package/apps/storefront/src/lib/d1/catalog.ts +18 -1
- package/apps/storefront/src/lib/d1/schema-apps.ts +93 -0
- package/apps/storefront/src/lib/edgeCache.ts +19 -1
- package/apps/storefront/src/lib/publication/contentProvider.ts +159 -0
- package/apps/storefront/src/lib/publication/runtimeSource.ts +194 -0
- package/apps/storefront/src/lib/the-build/provider.ts +2 -2
- package/apps/storefront/src/middleware/controlPlane.ts +24 -3
- package/apps/storefront/src/middleware/index.ts +125 -22
- package/apps/storefront/src/middleware/renderChain.ts +109 -56
- package/apps/storefront/src/middleware/routeBranch.ts +39 -7
- 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/blog/[collection]/[slug]/react.ts +4 -1
- package/apps/storefront/src/pages/api/rum/vitals.ts +4 -1
- package/apps/storefront/src/pages/blog/[slug].astro +5 -4
- package/apps/storefront/src/pages/blog/author/[author].astro +2 -2
- package/apps/storefront/src/pages/blog/category/[category].astro +2 -2
- package/apps/storefront/src/pages/blog/index.astro +2 -2
- package/apps/storefront/src/pages/blog/tag/[tag].astro +2 -2
- package/apps/storefront/src/pages/llms.txt.ts +2 -2
- package/apps/storefront/src/pages/sitemap.xml.ts +8 -4
- package/apps/storefront/src/pages/the-build/[slug].astro +6 -3
- package/apps/storefront/src/pages/the-build/index.astro +2 -2
- package/apps/storefront/src/pages/the-build/rss.xml.ts +2 -2
- package/package.json +1 -1
- package/packages/public-runtime/package.json +2 -1
- package/packages/public-runtime/src/blog-visibility.d.mts +8 -0
- package/packages/public-runtime/src/blog-visibility.mjs +32 -0
- package/packages/public-runtime/src/index.ts +3 -0
- package/packages/public-runtime/src/publication-artifact.ts +20 -3
- 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-version.ts +15 -0
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
* ADR 0013 splits a tenant's surface in two — a public path that is CDN →
|
|
6
6
|
* content-addressed storage with no origin compute, and a control plane the
|
|
7
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
|
-
*
|
|
10
|
-
*
|
|
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
11
|
*
|
|
12
12
|
* static-passthrough → platform bytes; `next()` plus baseline headers.
|
|
13
13
|
* public-render → ./publicRender — anonymous output, no request-time D1.
|
|
@@ -21,10 +21,10 @@
|
|
|
21
21
|
*/
|
|
22
22
|
import { defineMiddleware } from "astro:middleware";
|
|
23
23
|
import { getTenantResolver } from "@/config/resolver";
|
|
24
|
-
import { readEnv, readKv } from "@/lib/runtimeEnv";
|
|
24
|
+
import { readEnv, readKv, readR2 } from "@/lib/runtimeEnv";
|
|
25
25
|
import { resolveTenantRouting } from "@/lib/tenantRouting";
|
|
26
26
|
import { compileRouteTierIndexV1, type RouteTierIndexV1 } from "@tot/public-runtime";
|
|
27
|
-
import type { TenantConfig } from "@tot/public-runtime";
|
|
27
|
+
import type { PublicationArtifactBucketV1, TenantConfig } from "@tot/public-runtime";
|
|
28
28
|
import { DEV_DRAFT_COOKIE } from "@/lib/dev/devDraft";
|
|
29
29
|
import { applySecurityHeaders } from "@/lib/securityHeaders";
|
|
30
30
|
import { tryServeStaticBundle } from "./staticBundle.js";
|
|
@@ -35,9 +35,19 @@ import { isMaintenanceOn, maintenanceResponse } from "@/lib/maintenance";
|
|
|
35
35
|
import { HEALTH_PATH, healthResponse } from "@/lib/health";
|
|
36
36
|
import { observeDeployVersion } from "@/lib/activity/deployVersion";
|
|
37
37
|
import { primeActivityKillSwitch } from "@/lib/activity/killSwitch";
|
|
38
|
-
import {
|
|
38
|
+
import {
|
|
39
|
+
publicationRedirectLocationV1,
|
|
40
|
+
resolveMiddlewareBranchV1,
|
|
41
|
+
} from "./routeBranch.js";
|
|
39
42
|
import { servePublicRender } from "./publicRender.js";
|
|
40
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";
|
|
41
51
|
|
|
42
52
|
/**
|
|
43
53
|
* Compiled once per tenant-config object, as the resolver's contract asks —
|
|
@@ -124,12 +134,83 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
124
134
|
|
|
125
135
|
// ---- The one route classification -----------------------------------------
|
|
126
136
|
const hostLoginGated = gateMode === "login" && hostInGateList(host, demoGateHosts);
|
|
127
|
-
const { branch, route } = resolveMiddlewareBranchV1({
|
|
137
|
+
const { branch, route, publication } = resolveMiddlewareBranchV1({
|
|
128
138
|
index: routeTierIndexFor(tenant),
|
|
129
139
|
renderPath,
|
|
130
140
|
hostLoginGated,
|
|
131
141
|
});
|
|
132
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
|
+
},
|
|
161
|
+
});
|
|
162
|
+
applySecurityHeaders(response.headers);
|
|
163
|
+
return response;
|
|
164
|
+
}
|
|
165
|
+
|
|
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;
|
|
186
|
+
}
|
|
187
|
+
|
|
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;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
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;
|
|
133
214
|
|
|
134
215
|
// Workers Cache API: backs the full-page edge cache in the render chain, and
|
|
135
216
|
// the bundle read below. Undefined in `astro dev` (Node) — a no-op there.
|
|
@@ -138,6 +219,9 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
138
219
|
? (caches as unknown as { default: Cache }).default
|
|
139
220
|
: undefined;
|
|
140
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;
|
|
141
225
|
|
|
142
226
|
// Platform bytes: the tenant's own bundle owns them when it has one — a
|
|
143
227
|
// published `/_astro/<hash>.css` exists ONLY in R2, never in this Worker's build
|
|
@@ -174,24 +258,43 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
174
258
|
url,
|
|
175
259
|
tenant,
|
|
176
260
|
basePath,
|
|
177
|
-
renderPath,
|
|
261
|
+
renderPath: publicationRenderPath,
|
|
178
262
|
cache,
|
|
179
263
|
kv,
|
|
264
|
+
routeTierIndex: routeTierIndexFor(tenant),
|
|
265
|
+
publicationRequestPath: renderPath,
|
|
266
|
+
publicationRoute: publication,
|
|
267
|
+
publicationBucket,
|
|
180
268
|
});
|
|
181
269
|
}
|
|
182
270
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
271
|
+
try {
|
|
272
|
+
const response = await serveControlPlane({
|
|
273
|
+
context,
|
|
274
|
+
next,
|
|
275
|
+
host,
|
|
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,
|
|
290
|
+
});
|
|
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;
|
|
299
|
+
}
|
|
197
300
|
});
|
|
@@ -16,6 +16,8 @@ import { readEnv } from "@/lib/runtimeEnv";
|
|
|
16
16
|
import { tryServeStaticBundle } from "./staticBundle.js";
|
|
17
17
|
import { resolveCandidateContentPin } from "./candidatePin.js";
|
|
18
18
|
import { createContentProvider, DevDraftContentProvider } from "@/lib/storyblok/provider";
|
|
19
|
+
import { bindPublicationSpaceContentProviderV1 } from "@/lib/publication/contentProvider.js";
|
|
20
|
+
import { resolvePublicationRuntimeSourceV1 } from "@/lib/publication/runtimeSource.js";
|
|
19
21
|
import { DEV_DRAFT_COOKIE, devDraftKey, verifyDevDraftToken } from "@/lib/dev/devDraft";
|
|
20
22
|
import {
|
|
21
23
|
isCacheablePath,
|
|
@@ -52,6 +54,7 @@ import {
|
|
|
52
54
|
collectDeclaredCspProfiles,
|
|
53
55
|
INTEGRATION_NONCE_PLACEHOLDER,
|
|
54
56
|
defaultConsentSnapshot,
|
|
57
|
+
exactPublicationTimestamp,
|
|
55
58
|
parseConsentSnapshot,
|
|
56
59
|
CONSENT_SNAPSHOT_INPUT_HEADER,
|
|
57
60
|
CONSENT_SNAPSHOT_SIG_HEADER,
|
|
@@ -67,6 +70,9 @@ import type {
|
|
|
67
70
|
TenantConfig,
|
|
68
71
|
ThemeTokens,
|
|
69
72
|
BrowserEnvironment,
|
|
73
|
+
PublicationArtifactBucketV1,
|
|
74
|
+
PublicationRoutePlanV1,
|
|
75
|
+
RouteTierIndexV1,
|
|
70
76
|
} from "@tot/public-runtime";
|
|
71
77
|
import {
|
|
72
78
|
formatHtmlCacheControl,
|
|
@@ -78,6 +84,12 @@ import { resolveTenantCommerce } from "@/lib/checkoutCommerce";
|
|
|
78
84
|
import { requestBrowserEnvironment, deriveSiteSurface, cspHeaderName } from "@/lib/integration/browserEnvironment";
|
|
79
85
|
import { freshNonce, stampResponseNonce } from "@/lib/integration/nonceStamp";
|
|
80
86
|
import { recordActivity } from "@/lib/activity/recordActivity";
|
|
87
|
+
import { middlewareRewriteTargetV1 } from "./routeBranch.js";
|
|
88
|
+
import {
|
|
89
|
+
applyInternalContentResponsePolicy,
|
|
90
|
+
internalContentGrantMatchesPlan,
|
|
91
|
+
type InternalContentGrant,
|
|
92
|
+
} from "@/lib/auth/internalContentAuthority";
|
|
81
93
|
|
|
82
94
|
export interface RenderChainInput {
|
|
83
95
|
context: APIContext;
|
|
@@ -91,6 +103,10 @@ export interface RenderChainInput {
|
|
|
91
103
|
/** Workers Cache API default cache; undefined under `astro dev`. */
|
|
92
104
|
cache: Cache | undefined;
|
|
93
105
|
kv: KVNamespace | undefined;
|
|
106
|
+
routeTierIndex: RouteTierIndexV1;
|
|
107
|
+
publicationRequestPath: string;
|
|
108
|
+
publicationRoute: PublicationRoutePlanV1;
|
|
109
|
+
publicationBucket: PublicationArtifactBucketV1 | undefined;
|
|
94
110
|
/**
|
|
95
111
|
* Managed-install declarations, read from D1 by the caller. Empty on the public
|
|
96
112
|
* branch: a managed install is admin-installed state, and ADR 0013 puts that
|
|
@@ -111,11 +127,22 @@ export interface RenderChainInput {
|
|
|
111
127
|
* parameter rather than the D1 module, so this file names no D1 type at all.
|
|
112
128
|
*/
|
|
113
129
|
readCatalogD1: (() => Promise<Parameters<typeof createToTClient>[1]>) | null;
|
|
130
|
+
/** Exact per-request grant for an F2-selected internal publication space. */
|
|
131
|
+
internalContentGrant?: InternalContentGrant;
|
|
114
132
|
}
|
|
115
133
|
|
|
116
134
|
export async function runRenderChain(input: RenderChainInput): Promise<Response> {
|
|
117
135
|
const { context, next, host, url, tenant, basePath, renderPath, cache, kv } = input;
|
|
118
136
|
const { request, locals } = context;
|
|
137
|
+
const isInternalPublication = input.publicationRoute.action !== "fail-closed" &&
|
|
138
|
+
input.publicationRoute.manifest.visibility === "internal";
|
|
139
|
+
if (isInternalPublication &&
|
|
140
|
+
!internalContentGrantMatchesPlan(input.internalContentGrant, input.publicationRoute)) {
|
|
141
|
+
const response = new Response("Not Found", { status: 404 });
|
|
142
|
+
applyInternalContentResponsePolicy(response.headers);
|
|
143
|
+
applySecurityHeaders(response.headers);
|
|
144
|
+
return response;
|
|
145
|
+
}
|
|
119
146
|
|
|
120
147
|
// Preview/test detection (also reused for the customization env below). A
|
|
121
148
|
// production read is one with no preview host, query token, or preview cookie.
|
|
@@ -142,13 +169,62 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
|
|
|
142
169
|
// signature, expiry, tenant binding — before the overlay is applied further down.
|
|
143
170
|
const draftCookie = context.cookies.get(DEV_DRAFT_COOKIE)?.value ?? null;
|
|
144
171
|
|
|
172
|
+
// Resolve preview/live once before any publication source, static-bundle, or
|
|
173
|
+
// page-cache read. An exact publication selection is therefore authoritative
|
|
174
|
+
// for the whole request and can scope every downstream cache decision.
|
|
175
|
+
const previewToken = queryToken ?? cookieToken;
|
|
176
|
+
const previewSecret = (await readEnv("CUSTOMIZATION_PREVIEW_SECRET")) ?? null;
|
|
177
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
178
|
+
const customization = await resolveCustomizationEnv({
|
|
179
|
+
isPreviewHost,
|
|
180
|
+
tenantId: tenant.tenant_id,
|
|
181
|
+
token: previewToken,
|
|
182
|
+
secret: previewSecret,
|
|
183
|
+
nowSeconds,
|
|
184
|
+
pathname: url.pathname,
|
|
185
|
+
});
|
|
186
|
+
if (customization.reject) {
|
|
187
|
+
return new Response(customization.reject.message, {
|
|
188
|
+
status: customization.reject.status,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
if (customization.env === "test" && queryToken) {
|
|
192
|
+
context.cookies.set("tot_preview", queryToken, {
|
|
193
|
+
httpOnly: true,
|
|
194
|
+
secure: true,
|
|
195
|
+
sameSite: "lax",
|
|
196
|
+
path: "/",
|
|
197
|
+
maxAge: 3600,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const publicationSource = await resolvePublicationRuntimeSourceV1({
|
|
202
|
+
index: input.routeTierIndex,
|
|
203
|
+
requestPath: input.publicationRequestPath,
|
|
204
|
+
plan: input.publicationRoute,
|
|
205
|
+
channel: channelFromEnv(customization.env),
|
|
206
|
+
store: kv ? { get: (key) => kv.get(key) } : undefined,
|
|
207
|
+
bucket: input.publicationBucket,
|
|
208
|
+
});
|
|
209
|
+
if (publicationSource.status === "unavailable") {
|
|
210
|
+
const response = new Response("Not Found", {
|
|
211
|
+
status: 404,
|
|
212
|
+
headers: { "Cache-Control": "private, no-store" },
|
|
213
|
+
});
|
|
214
|
+
applySecurityHeaders(response.headers);
|
|
215
|
+
if (isInternalPublication) applyInternalContentResponsePolicy(response.headers);
|
|
216
|
+
return response;
|
|
217
|
+
}
|
|
218
|
+
const publicationTimestamp = publicationSource.status === "selected"
|
|
219
|
+
? publicationSource.publicationTimestamp
|
|
220
|
+
: exactPublicationTimestamp(await readEnv("TOT_PUBLICATION_TIMESTAMP"));
|
|
221
|
+
|
|
145
222
|
const managedIntegrationDeclarations = input.managedIntegrationDeclarations;
|
|
146
223
|
|
|
147
|
-
// Digest-addressed static-bundle serving. Deliberately
|
|
148
|
-
// cache
|
|
149
|
-
//
|
|
150
|
-
|
|
151
|
-
const bundled = await tryServeStaticBundle({
|
|
224
|
+
// Digest-addressed static-bundle serving. Deliberately before the edge page
|
|
225
|
+
// cache/catalog/theme machinery. A selected publication bypasses this legacy
|
|
226
|
+
// whole-site bundle until B1 can compose mounted spaces into it reproducibly.
|
|
227
|
+
const bundled = publicationSource.status === "legacy-root" ? await tryServeStaticBundle({
|
|
152
228
|
request,
|
|
153
229
|
url,
|
|
154
230
|
tenant,
|
|
@@ -160,7 +236,7 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
|
|
|
160
236
|
hasCandidatePin: !!candidatePin,
|
|
161
237
|
hasDraftCookie: !!draftCookie,
|
|
162
238
|
hasManagedInstalls: managedIntegrationDeclarations.length > 0,
|
|
163
|
-
});
|
|
239
|
+
}) : null;
|
|
164
240
|
if (bundled) return bundled;
|
|
165
241
|
|
|
166
242
|
// ---- Tenant-local integration materialization (ext-kernel-09) --------------
|
|
@@ -182,20 +258,11 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
|
|
|
182
258
|
...managedIntegrationDeclarations,
|
|
183
259
|
];
|
|
184
260
|
if (integrationDeclarations.length > 0) {
|
|
185
|
-
const declPreviewSecret = (await readEnv("CUSTOMIZATION_PREVIEW_SECRET")) ?? null;
|
|
186
|
-
const declEnv = await resolveCustomizationEnv({
|
|
187
|
-
isPreviewHost,
|
|
188
|
-
tenantId: tenant.tenant_id,
|
|
189
|
-
token: queryToken ?? cookieToken,
|
|
190
|
-
secret: declPreviewSecret,
|
|
191
|
-
nowSeconds: Math.floor(Date.now() / 1000),
|
|
192
|
-
pathname: url.pathname,
|
|
193
|
-
});
|
|
194
261
|
const declFeatures = resolveTenantFeatures(tenant);
|
|
195
262
|
resolvedBrowserEnvironment = requestBrowserEnvironment({
|
|
196
263
|
host,
|
|
197
264
|
isPreviewHost,
|
|
198
|
-
customizationEnv:
|
|
265
|
+
customizationEnv: customization.env,
|
|
199
266
|
canonicalHost: tenant.canonicalDomain || host,
|
|
200
267
|
siteSurface: deriveSiteSurface({ catalog: declFeatures.catalog, pathname: renderPath }),
|
|
201
268
|
});
|
|
@@ -275,6 +342,7 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
|
|
|
275
342
|
!cookieToken &&
|
|
276
343
|
!candidatePin &&
|
|
277
344
|
!draftCookie &&
|
|
345
|
+
!isInternalPublication &&
|
|
278
346
|
isCacheablePath(url.pathname, url.searchParams);
|
|
279
347
|
let pageKey: Request | null = null;
|
|
280
348
|
if (pageCacheEligible && cache && kv) {
|
|
@@ -291,6 +359,7 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
|
|
|
291
359
|
url.search,
|
|
292
360
|
version,
|
|
293
361
|
locals.integrationCsp?.planRevision,
|
|
362
|
+
publicationSource.status === "selected" ? publicationSource.cacheScope : undefined,
|
|
294
363
|
);
|
|
295
364
|
const hit = await getCachedPage(cache, pageKey);
|
|
296
365
|
// The cached artifact is nonce-free (carries the placeholder). Stamp a fresh
|
|
@@ -317,37 +386,6 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
|
|
|
317
386
|
? `${proto}://${host}${basePath}`
|
|
318
387
|
: `https://${tenant.canonicalDomain}`;
|
|
319
388
|
|
|
320
|
-
// Live vs test customization. Production hosts always serve "live" and
|
|
321
|
-
// ignore any token; a "preview." host is auth-gated by a signed, tenant-bound,
|
|
322
|
-
// short-TTL preview token (?__preview= on first hit, then the tot_preview
|
|
323
|
-
// cookie). Test is never indexable (X-Robots-Tag below).
|
|
324
|
-
const previewToken = queryToken ?? cookieToken;
|
|
325
|
-
const previewSecret = (await readEnv("CUSTOMIZATION_PREVIEW_SECRET")) ?? null;
|
|
326
|
-
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
327
|
-
const customization = await resolveCustomizationEnv({
|
|
328
|
-
isPreviewHost,
|
|
329
|
-
tenantId: tenant.tenant_id,
|
|
330
|
-
token: previewToken,
|
|
331
|
-
secret: previewSecret,
|
|
332
|
-
nowSeconds,
|
|
333
|
-
pathname: url.pathname,
|
|
334
|
-
});
|
|
335
|
-
if (customization.reject) {
|
|
336
|
-
return new Response(customization.reject.message, {
|
|
337
|
-
status: customization.reject.status,
|
|
338
|
-
});
|
|
339
|
-
}
|
|
340
|
-
// Persist a query-supplied token as a cookie so later navigations stay in test.
|
|
341
|
-
if (customization.env === "test" && queryToken) {
|
|
342
|
-
context.cookies.set("tot_preview", queryToken, {
|
|
343
|
-
httpOnly: true,
|
|
344
|
-
secure: true,
|
|
345
|
-
sameSite: "lax",
|
|
346
|
-
path: "/",
|
|
347
|
-
maxAge: 3600,
|
|
348
|
-
});
|
|
349
|
-
}
|
|
350
|
-
|
|
351
389
|
// Apply a published theme override for the resolved environment (store->app).
|
|
352
390
|
// The live domain serves `live`; a gated preview serves `test`. When nothing
|
|
353
391
|
// is published the build-time tenant theme is used unchanged.
|
|
@@ -469,6 +507,10 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
|
|
|
469
507
|
const cspNonce = INTEGRATION_NONCE_PLACEHOLDER;
|
|
470
508
|
|
|
471
509
|
locals.tenant = tenant;
|
|
510
|
+
// One immutable clock for every visibility consumer in this render. Selected
|
|
511
|
+
// Worker content uses its audited pointer transition; materialization uses the
|
|
512
|
+
// signed build input; an ordinary legacy request has no clock and schedules stay hidden.
|
|
513
|
+
locals.publicationTimestamp = publicationTimestamp;
|
|
472
514
|
// Effective capability flags for this tenant (commerce defaults unless the
|
|
473
515
|
// tenant declares siteType:"marketing" / explicit features). Pages and chrome
|
|
474
516
|
// read these instead of hard-coding tenant identity checks.
|
|
@@ -483,15 +525,19 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
|
|
|
483
525
|
: null;
|
|
484
526
|
locals.commerce = commerce ?? undefined;
|
|
485
527
|
locals.tot = tot;
|
|
486
|
-
//
|
|
487
|
-
//
|
|
488
|
-
//
|
|
489
|
-
|
|
528
|
+
// Resolve the shared root adapter first. An opened publication version then
|
|
529
|
+
// becomes authoritative for selected content while a mounted space may still
|
|
530
|
+
// reuse root-owned chrome. Neither source falls through to another space.
|
|
531
|
+
const sharedRootContent = candidatePin
|
|
490
532
|
? createContentProvider(tenant, undefined, { get: kvGet, versionId: candidatePin.versionId })
|
|
491
533
|
: createContentProvider(tenant, {
|
|
492
534
|
get: kvGet,
|
|
493
535
|
env: customization.env,
|
|
494
536
|
});
|
|
537
|
+
locals.content = bindPublicationSpaceContentProviderV1(
|
|
538
|
+
sharedRootContent,
|
|
539
|
+
publicationSource.status === "selected" ? publicationSource.artifact : undefined,
|
|
540
|
+
);
|
|
495
541
|
// Developer-draft content overlay — serves the AI-edited artifacts to the draft
|
|
496
542
|
// owner only; every other viewer keeps the published/base chain above.
|
|
497
543
|
if (draft) {
|
|
@@ -543,11 +589,17 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
|
|
|
543
589
|
locals.integrationPlan ??
|
|
544
590
|
resolveEnvironmentOnlyPlan(locals.browserEnvironment, tenant.tenant_id);
|
|
545
591
|
|
|
546
|
-
//
|
|
547
|
-
//
|
|
548
|
-
//
|
|
549
|
-
|
|
550
|
-
|
|
592
|
+
// Rewrite when a /<domain>/ prefix was stripped OR a publication render alias
|
|
593
|
+
// resolved to its canonical mount. next(path) does not re-run middleware, so
|
|
594
|
+
// the one route plan and locals above remain authoritative.
|
|
595
|
+
const rewriteTarget = middlewareRewriteTargetV1({
|
|
596
|
+
requestPath: url.pathname,
|
|
597
|
+
basePath,
|
|
598
|
+
renderPath,
|
|
599
|
+
search: url.search,
|
|
600
|
+
});
|
|
601
|
+
let response = rewriteTarget !== undefined
|
|
602
|
+
? await next(rewriteTarget)
|
|
551
603
|
: await next();
|
|
552
604
|
|
|
553
605
|
// On-publish auto-refresh: inject the live-reload client into SHARED preview
|
|
@@ -678,6 +730,7 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
|
|
|
678
730
|
formatHtmlCacheControl(locals.tenant?.serving?.htmlCachePolicy),
|
|
679
731
|
);
|
|
680
732
|
}
|
|
733
|
+
if (isInternalPublication) applyInternalContentResponsePolicy(response.headers);
|
|
681
734
|
// Stamp a fresh per-response nonce into this cold response's body + CSP header
|
|
682
735
|
// (the warm-hit path above stamps its own). Both paths therefore serve a fresh,
|
|
683
736
|
// matching nonce/CSP pair, and no nonce is reused across responses (ADR 0018).
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Which of the three middleware branches serves this request.
|
|
3
3
|
*
|
|
4
|
-
* The TIER answer is F2's
|
|
5
|
-
*
|
|
4
|
+
* The TIER answer is F2's, carried by `resolvePublicationRoutePlanV1`, and is
|
|
5
|
+
* never re-derived here. This module only maps that one answer onto the three
|
|
6
6
|
* serving shapes the Worker actually has, and applies the literal path tables
|
|
7
7
|
* that correct the resolver's deliberate fail-closed over-restriction of
|
|
8
8
|
* platform-reserved paths.
|
|
@@ -26,7 +26,13 @@
|
|
|
26
26
|
*
|
|
27
27
|
* Contract: docs/architecture/decisions/0013-public-serving-contract.md
|
|
28
28
|
*/
|
|
29
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
resolvePublicationRoutePlanV1,
|
|
31
|
+
type PublicationRoutePlanV1,
|
|
32
|
+
type PublicationRouteRedirectPlanV1,
|
|
33
|
+
type RouteTierIndexV1,
|
|
34
|
+
type ResolvedRouteTierV1,
|
|
35
|
+
} from "@tot/public-runtime";
|
|
30
36
|
|
|
31
37
|
/**
|
|
32
38
|
* The three serving shapes.
|
|
@@ -132,6 +138,30 @@ export interface ResolvedMiddlewareBranchV1 {
|
|
|
132
138
|
branch: MiddlewareBranchV1;
|
|
133
139
|
/** F2's answer for this request, surfaced on `locals.routeTier`. */
|
|
134
140
|
route: ResolvedRouteTierV1;
|
|
141
|
+
/** One normalized publication-space answer shared by dispatch and rendering. */
|
|
142
|
+
publication: PublicationRoutePlanV1;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Astro rewrite target for tenant-prefix stripping or canonical render aliases. */
|
|
146
|
+
export function middlewareRewriteTargetV1(input: {
|
|
147
|
+
requestPath: string;
|
|
148
|
+
basePath: string;
|
|
149
|
+
renderPath: string;
|
|
150
|
+
search: string;
|
|
151
|
+
}): string | undefined {
|
|
152
|
+
return input.basePath !== "" || input.renderPath !== input.requestPath
|
|
153
|
+
? `${input.renderPath}${input.search}`
|
|
154
|
+
: undefined;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Absolute 308 location for host- and path-routed publication aliases. */
|
|
158
|
+
export function publicationRedirectLocationV1(input: {
|
|
159
|
+
origin: string;
|
|
160
|
+
basePath: string;
|
|
161
|
+
plan: PublicationRouteRedirectPlanV1;
|
|
162
|
+
search: string;
|
|
163
|
+
}): string {
|
|
164
|
+
return `${input.origin}${input.basePath}${input.plan.locationPath}${input.search}`;
|
|
135
165
|
}
|
|
136
166
|
|
|
137
167
|
/**
|
|
@@ -142,19 +172,21 @@ export interface ResolvedMiddlewareBranchV1 {
|
|
|
142
172
|
export function resolveMiddlewareBranchV1(
|
|
143
173
|
input: MiddlewareBranchInputV1,
|
|
144
174
|
): ResolvedMiddlewareBranchV1 {
|
|
145
|
-
const
|
|
146
|
-
|
|
175
|
+
const publication = resolvePublicationRoutePlanV1(input.index, input.renderPath);
|
|
176
|
+
const route = publication.route;
|
|
177
|
+
if (input.hostLoginGated) return { branch: "control-plane", route, publication };
|
|
147
178
|
if (isPlatformStaticAssetPath(input.renderPath)) {
|
|
148
|
-
return { branch: "static-passthrough", route };
|
|
179
|
+
return { branch: "static-passthrough", route, publication };
|
|
149
180
|
}
|
|
150
181
|
if (
|
|
151
182
|
isPlatformPublicDiscoveryPath(input.renderPath) ||
|
|
152
183
|
isPlatformPublicAppRoutePath(input.renderPath)
|
|
153
184
|
) {
|
|
154
|
-
return { branch: "public-render", route };
|
|
185
|
+
return { branch: "public-render", route, publication };
|
|
155
186
|
}
|
|
156
187
|
return {
|
|
157
188
|
branch: route.requiresAuthorization ? "control-plane" : "public-render",
|
|
158
189
|
route,
|
|
190
|
+
publication,
|
|
159
191
|
};
|
|
160
192
|
}
|
|
@@ -137,6 +137,7 @@ export async function POST(context: APIContext): Promise<Response> {
|
|
|
137
137
|
const { id } = await createSession(
|
|
138
138
|
store,
|
|
139
139
|
{
|
|
140
|
+
subject: derived.subject,
|
|
140
141
|
email: derived.email,
|
|
141
142
|
// The verified aud (storefront app identity) — the console session's scope.
|
|
142
143
|
resource: derived.resource,
|
|
@@ -98,7 +98,10 @@ export const POST: APIRoute = async (context) => {
|
|
|
98
98
|
// Best-effort: no-ops for a non-live post; never throws. When there is no KV
|
|
99
99
|
// binding (local dev) the increment can't persist, but we still return the
|
|
100
100
|
// (all-zero) tallies so the client has a consistent shape.
|
|
101
|
-
if (kv) await recordReaction(kv, tenantId, collection, slug, reaction
|
|
101
|
+
if (kv) await recordReaction(kv, tenantId, collection, slug, reaction, {
|
|
102
|
+
content: context.locals.content,
|
|
103
|
+
now: context.locals.publicationTimestamp,
|
|
104
|
+
});
|
|
102
105
|
|
|
103
106
|
return json({ ok: true, counts: await getReactionCounts(kv, tenantId, collection, slug) });
|
|
104
107
|
};
|
|
@@ -61,7 +61,10 @@ export const POST: APIRoute = async (context) => {
|
|
|
61
61
|
if (tenantId && kv) {
|
|
62
62
|
const cfContext =
|
|
63
63
|
(context.locals as { cfContext?: CfExecutionContextLike })?.cfContext ?? null;
|
|
64
|
-
runAfterResponse(recordBlogView(kv, tenantId, sample.path
|
|
64
|
+
runAfterResponse(recordBlogView(kv, tenantId, sample.path, {
|
|
65
|
+
content: context.locals.content,
|
|
66
|
+
now: context.locals.publicationTimestamp,
|
|
67
|
+
}), cfContext);
|
|
65
68
|
}
|
|
66
69
|
}
|
|
67
70
|
}
|
|
@@ -11,9 +11,9 @@ import { buildAlternates } from "@/lib/seo/alternates.js";
|
|
|
11
11
|
import { languageLabel } from "@/lib/i18n.js";
|
|
12
12
|
import { breadcrumbLd, blogPostingLd } from "@/lib/jsonld.js";
|
|
13
13
|
|
|
14
|
-
const { tenant, canonicalBase, basePath, content } = Astro.locals;
|
|
14
|
+
const { tenant, canonicalBase, basePath, content, publicationTimestamp } = Astro.locals;
|
|
15
15
|
const slug = Astro.params.slug ?? "";
|
|
16
|
-
const post = await getBlogPost(tenant.tenant_id, slug, content);
|
|
16
|
+
const post = await getBlogPost(tenant.tenant_id, slug, content, publicationTimestamp);
|
|
17
17
|
if (!post) {
|
|
18
18
|
const redirect = await resolveBlogRedirect(tenant.tenant_id, slug, content);
|
|
19
19
|
if (redirect) return Astro.redirect(`${basePath}/blog/${redirect}/`, 301);
|
|
@@ -38,7 +38,7 @@ const canonical = new URL(`/blog/${post.slug}/`, canonicalBase + "/").toString()
|
|
|
38
38
|
// (the invariant `blog/author/[author].astro` documents). Same slug + same source
|
|
39
39
|
// the archive itself filters on, so the two can't disagree.
|
|
40
40
|
const authorArchiveSlug = authorSlug(post.author);
|
|
41
|
-
const authorHasArchive = (await listBlogPosts(tenant.tenant_id, content)).some(
|
|
41
|
+
const authorHasArchive = (await listBlogPosts(tenant.tenant_id, content, publicationTimestamp)).some(
|
|
42
42
|
(p) => authorSlug(p.author) === authorArchiveSlug,
|
|
43
43
|
);
|
|
44
44
|
const authorUrl = authorHasArchive
|
|
@@ -63,7 +63,7 @@ const authorHref = authorHasArchive
|
|
|
63
63
|
// I2 — hreflang alternates + per-variant <html lang>. Singleton ⇒ [] alternates
|
|
64
64
|
// and no `lang` override, so a non-translated post is byte-identical to today.
|
|
65
65
|
const translations = post.translationGroupId
|
|
66
|
-
? await getTranslations(tenant.tenant_id, "blog", post.slug, { content })
|
|
66
|
+
? await getTranslations(tenant.tenant_id, "blog", post.slug, { content, now: publicationTimestamp })
|
|
67
67
|
: [];
|
|
68
68
|
const alternates = buildAlternates(translations, (s) =>
|
|
69
69
|
new URL(`/blog/${s}/`, canonicalBase + "/").toString(),
|
|
@@ -77,6 +77,7 @@ const lang = alternates.length ? (post.inLanguage ?? "en") : undefined;
|
|
|
77
77
|
path={`/blog/${post.slug}/`}
|
|
78
78
|
ogType="article"
|
|
79
79
|
ogImage={post.featuredImage?.src}
|
|
80
|
+
noindex={post.status === "unlisted"}
|
|
80
81
|
jsonLd={[crumbs, articleLd]}
|
|
81
82
|
article={{
|
|
82
83
|
publishedTime: post.publishedAt,
|