@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.
Files changed (46) hide show
  1. package/apps/storefront/env.d.ts +5 -0
  2. package/apps/storefront/migrations-apps/0013_publication_space_editorial.sql +64 -0
  3. package/apps/storefront/src/config/publicationSpaceRegistry.ts +19 -0
  4. package/apps/storefront/src/lib/auth/brokerAssertion.ts +18 -0
  5. package/apps/storefront/src/lib/auth/identityToken.ts +11 -1
  6. package/apps/storefront/src/lib/auth/internalContentAuthority.ts +153 -0
  7. package/apps/storefront/src/lib/auth/session.ts +3 -0
  8. package/apps/storefront/src/lib/blog/collection.ts +24 -18
  9. package/apps/storefront/src/lib/blog/provider.ts +28 -5
  10. package/apps/storefront/src/lib/blog/reactions.ts +4 -2
  11. package/apps/storefront/src/lib/blog/types.ts +1 -1
  12. package/apps/storefront/src/lib/blog/viewCounts.ts +3 -1
  13. package/apps/storefront/src/lib/d1/catalog.ts +18 -1
  14. package/apps/storefront/src/lib/d1/schema-apps.ts +93 -0
  15. package/apps/storefront/src/lib/edgeCache.ts +19 -1
  16. package/apps/storefront/src/lib/publication/contentProvider.ts +159 -0
  17. package/apps/storefront/src/lib/publication/runtimeSource.ts +194 -0
  18. package/apps/storefront/src/lib/the-build/provider.ts +2 -2
  19. package/apps/storefront/src/middleware/controlPlane.ts +24 -3
  20. package/apps/storefront/src/middleware/index.ts +125 -22
  21. package/apps/storefront/src/middleware/renderChain.ts +109 -56
  22. package/apps/storefront/src/middleware/routeBranch.ts +39 -7
  23. package/apps/storefront/src/pages/api/auth/magic-exchange.ts +1 -0
  24. package/apps/storefront/src/pages/api/auth/step-up-verify.ts +1 -0
  25. package/apps/storefront/src/pages/api/auth/verify.ts +1 -0
  26. package/apps/storefront/src/pages/api/blog/[collection]/[slug]/react.ts +4 -1
  27. package/apps/storefront/src/pages/api/rum/vitals.ts +4 -1
  28. package/apps/storefront/src/pages/blog/[slug].astro +5 -4
  29. package/apps/storefront/src/pages/blog/author/[author].astro +2 -2
  30. package/apps/storefront/src/pages/blog/category/[category].astro +2 -2
  31. package/apps/storefront/src/pages/blog/index.astro +2 -2
  32. package/apps/storefront/src/pages/blog/tag/[tag].astro +2 -2
  33. package/apps/storefront/src/pages/llms.txt.ts +2 -2
  34. package/apps/storefront/src/pages/sitemap.xml.ts +8 -4
  35. package/apps/storefront/src/pages/the-build/[slug].astro +6 -3
  36. package/apps/storefront/src/pages/the-build/index.astro +2 -2
  37. package/apps/storefront/src/pages/the-build/rss.xml.ts +2 -2
  38. package/package.json +1 -1
  39. package/packages/public-runtime/package.json +2 -1
  40. package/packages/public-runtime/src/blog-visibility.d.mts +8 -0
  41. package/packages/public-runtime/src/blog-visibility.mjs +32 -0
  42. package/packages/public-runtime/src/index.ts +3 -0
  43. package/packages/public-runtime/src/publication-artifact.ts +20 -3
  44. package/packages/public-runtime/src/publication-reader.ts +187 -0
  45. package/packages/public-runtime/src/publication-routing.ts +246 -0
  46. 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
- * `resolveRouteTierV1` (packages/public-runtime) for the tier and returns the
10
- * branch, and nothing downstream re-derives it.
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 { resolveMiddlewareBranchV1 } from "./routeBranch.js";
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
- return await serveControlPlane({
184
- context,
185
- next,
186
- host,
187
- url,
188
- tenant,
189
- basePath,
190
- renderPath,
191
- cache,
192
- kv,
193
- gateMode,
194
- demoGateHosts,
195
- hostLoginGated,
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 BEFORE the edge page
148
- // cache / catalog / theme / customization machinery below: none of that applies
149
- // to bundle bytes served verbatim, and once eligible that path must fully own
150
- // the response rather than degrade into the normal render.
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: declEnv.env,
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
- // A verified candidate content pin takes priority over the channel-resolved
487
- // read — a pinned candidate/revision render must never fall through to the
488
- // shared `{live,test}` content (see `pinnedRouteRender.ts`).
489
- locals.content = candidatePin
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
- // When a /<domain>/ prefix selected the tenant, rewrite to the stripped route
547
- // so the existing file-based routes match. next(path) rewrites WITHOUT
548
- // re-running middleware, so the locals set above are preserved.
549
- let response = basePath
550
- ? await next(`${renderPath}${url.search}`)
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 — `resolveRouteTierV1` (packages/public-runtime) — and
5
- * is never re-derived here. This module only maps that one answer onto the three
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 { resolveRouteTierV1, type RouteTierIndexV1, type ResolvedRouteTierV1 } from "@tot/public-runtime";
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 route = resolveRouteTierV1(input.index, input.renderPath);
146
- if (input.hostLoginGated) return { branch: "control-plane", route };
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
  }
@@ -125,6 +125,7 @@ export async function POST(context: APIContext): Promise<Response> {
125
125
  const { id } = await createSession(
126
126
  store,
127
127
  {
128
+ subject: derived.subject,
128
129
  email: derived.email,
129
130
  resource: derived.resource,
130
131
  roles: [],
@@ -110,6 +110,7 @@ export async function POST(context: APIContext): Promise<Response> {
110
110
  const { id } = await createSession(
111
111
  store,
112
112
  {
113
+ subject: derived.subject,
113
114
  email: derived.email,
114
115
  resource: derived.resource,
115
116
  roles: [],
@@ -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), cfContext);
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,