@tokenoftrust/storefront-runner 2.0.18 → 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.
@@ -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,
@@ -67,6 +69,9 @@ import type {
67
69
  TenantConfig,
68
70
  ThemeTokens,
69
71
  BrowserEnvironment,
72
+ PublicationArtifactBucketV1,
73
+ PublicationRoutePlanV1,
74
+ RouteTierIndexV1,
70
75
  } from "@tot/public-runtime";
71
76
  import {
72
77
  formatHtmlCacheControl,
@@ -78,6 +83,12 @@ import { resolveTenantCommerce } from "@/lib/checkoutCommerce";
78
83
  import { requestBrowserEnvironment, deriveSiteSurface, cspHeaderName } from "@/lib/integration/browserEnvironment";
79
84
  import { freshNonce, stampResponseNonce } from "@/lib/integration/nonceStamp";
80
85
  import { recordActivity } from "@/lib/activity/recordActivity";
86
+ import { middlewareRewriteTargetV1 } from "./routeBranch.js";
87
+ import {
88
+ applyInternalContentResponsePolicy,
89
+ internalContentGrantMatchesPlan,
90
+ type InternalContentGrant,
91
+ } from "@/lib/auth/internalContentAuthority";
81
92
 
82
93
  export interface RenderChainInput {
83
94
  context: APIContext;
@@ -91,6 +102,10 @@ export interface RenderChainInput {
91
102
  /** Workers Cache API default cache; undefined under `astro dev`. */
92
103
  cache: Cache | undefined;
93
104
  kv: KVNamespace | undefined;
105
+ routeTierIndex: RouteTierIndexV1;
106
+ publicationRequestPath: string;
107
+ publicationRoute: PublicationRoutePlanV1;
108
+ publicationBucket: PublicationArtifactBucketV1 | undefined;
94
109
  /**
95
110
  * Managed-install declarations, read from D1 by the caller. Empty on the public
96
111
  * branch: a managed install is admin-installed state, and ADR 0013 puts that
@@ -111,11 +126,22 @@ export interface RenderChainInput {
111
126
  * parameter rather than the D1 module, so this file names no D1 type at all.
112
127
  */
113
128
  readCatalogD1: (() => Promise<Parameters<typeof createToTClient>[1]>) | null;
129
+ /** Exact per-request grant for an F2-selected internal publication space. */
130
+ internalContentGrant?: InternalContentGrant;
114
131
  }
115
132
 
116
133
  export async function runRenderChain(input: RenderChainInput): Promise<Response> {
117
134
  const { context, next, host, url, tenant, basePath, renderPath, cache, kv } = input;
118
135
  const { request, locals } = context;
136
+ const isInternalPublication = input.publicationRoute.action !== "fail-closed" &&
137
+ input.publicationRoute.manifest.visibility === "internal";
138
+ if (isInternalPublication &&
139
+ !internalContentGrantMatchesPlan(input.internalContentGrant, input.publicationRoute)) {
140
+ const response = new Response("Not Found", { status: 404 });
141
+ applyInternalContentResponsePolicy(response.headers);
142
+ applySecurityHeaders(response.headers);
143
+ return response;
144
+ }
119
145
 
120
146
  // Preview/test detection (also reused for the customization env below). A
121
147
  // production read is one with no preview host, query token, or preview cookie.
@@ -142,13 +168,59 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
142
168
  // signature, expiry, tenant binding — before the overlay is applied further down.
143
169
  const draftCookie = context.cookies.get(DEV_DRAFT_COOKIE)?.value ?? null;
144
170
 
171
+ // Resolve preview/live once before any publication source, static-bundle, or
172
+ // page-cache read. An exact publication selection is therefore authoritative
173
+ // for the whole request and can scope every downstream cache decision.
174
+ const previewToken = queryToken ?? cookieToken;
175
+ const previewSecret = (await readEnv("CUSTOMIZATION_PREVIEW_SECRET")) ?? null;
176
+ const nowSeconds = Math.floor(Date.now() / 1000);
177
+ const customization = await resolveCustomizationEnv({
178
+ isPreviewHost,
179
+ tenantId: tenant.tenant_id,
180
+ token: previewToken,
181
+ secret: previewSecret,
182
+ nowSeconds,
183
+ pathname: url.pathname,
184
+ });
185
+ if (customization.reject) {
186
+ return new Response(customization.reject.message, {
187
+ status: customization.reject.status,
188
+ });
189
+ }
190
+ if (customization.env === "test" && queryToken) {
191
+ context.cookies.set("tot_preview", queryToken, {
192
+ httpOnly: true,
193
+ secure: true,
194
+ sameSite: "lax",
195
+ path: "/",
196
+ maxAge: 3600,
197
+ });
198
+ }
199
+
200
+ const publicationSource = await resolvePublicationRuntimeSourceV1({
201
+ index: input.routeTierIndex,
202
+ requestPath: input.publicationRequestPath,
203
+ plan: input.publicationRoute,
204
+ channel: channelFromEnv(customization.env),
205
+ store: kv ? { get: (key) => kv.get(key) } : undefined,
206
+ bucket: input.publicationBucket,
207
+ });
208
+ if (publicationSource.status === "unavailable") {
209
+ const response = new Response("Not Found", {
210
+ status: 404,
211
+ headers: { "Cache-Control": "private, no-store" },
212
+ });
213
+ applySecurityHeaders(response.headers);
214
+ if (isInternalPublication) applyInternalContentResponsePolicy(response.headers);
215
+ return response;
216
+ }
217
+
145
218
  const managedIntegrationDeclarations = input.managedIntegrationDeclarations;
146
219
 
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({
220
+ // Digest-addressed static-bundle serving. Deliberately before the edge page
221
+ // cache/catalog/theme machinery. A selected publication bypasses this legacy
222
+ // whole-site bundle until B1 can compose mounted spaces into it reproducibly.
223
+ const bundled = publicationSource.status === "legacy-root" ? await tryServeStaticBundle({
152
224
  request,
153
225
  url,
154
226
  tenant,
@@ -160,7 +232,7 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
160
232
  hasCandidatePin: !!candidatePin,
161
233
  hasDraftCookie: !!draftCookie,
162
234
  hasManagedInstalls: managedIntegrationDeclarations.length > 0,
163
- });
235
+ }) : null;
164
236
  if (bundled) return bundled;
165
237
 
166
238
  // ---- Tenant-local integration materialization (ext-kernel-09) --------------
@@ -182,20 +254,11 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
182
254
  ...managedIntegrationDeclarations,
183
255
  ];
184
256
  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
257
  const declFeatures = resolveTenantFeatures(tenant);
195
258
  resolvedBrowserEnvironment = requestBrowserEnvironment({
196
259
  host,
197
260
  isPreviewHost,
198
- customizationEnv: declEnv.env,
261
+ customizationEnv: customization.env,
199
262
  canonicalHost: tenant.canonicalDomain || host,
200
263
  siteSurface: deriveSiteSurface({ catalog: declFeatures.catalog, pathname: renderPath }),
201
264
  });
@@ -275,6 +338,7 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
275
338
  !cookieToken &&
276
339
  !candidatePin &&
277
340
  !draftCookie &&
341
+ !isInternalPublication &&
278
342
  isCacheablePath(url.pathname, url.searchParams);
279
343
  let pageKey: Request | null = null;
280
344
  if (pageCacheEligible && cache && kv) {
@@ -291,6 +355,7 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
291
355
  url.search,
292
356
  version,
293
357
  locals.integrationCsp?.planRevision,
358
+ publicationSource.status === "selected" ? publicationSource.cacheScope : undefined,
294
359
  );
295
360
  const hit = await getCachedPage(cache, pageKey);
296
361
  // The cached artifact is nonce-free (carries the placeholder). Stamp a fresh
@@ -317,37 +382,6 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
317
382
  ? `${proto}://${host}${basePath}`
318
383
  : `https://${tenant.canonicalDomain}`;
319
384
 
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
385
  // Apply a published theme override for the resolved environment (store->app).
352
386
  // The live domain serves `live`; a gated preview serves `test`. When nothing
353
387
  // is published the build-time tenant theme is used unchanged.
@@ -483,15 +517,19 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
483
517
  : null;
484
518
  locals.commerce = commerce ?? undefined;
485
519
  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
520
+ // Resolve the shared root adapter first. An opened publication version then
521
+ // becomes authoritative for selected content while a mounted space may still
522
+ // reuse root-owned chrome. Neither source falls through to another space.
523
+ const sharedRootContent = candidatePin
490
524
  ? createContentProvider(tenant, undefined, { get: kvGet, versionId: candidatePin.versionId })
491
525
  : createContentProvider(tenant, {
492
526
  get: kvGet,
493
527
  env: customization.env,
494
528
  });
529
+ locals.content = bindPublicationSpaceContentProviderV1(
530
+ sharedRootContent,
531
+ publicationSource.status === "selected" ? publicationSource.artifact : undefined,
532
+ );
495
533
  // Developer-draft content overlay — serves the AI-edited artifacts to the draft
496
534
  // owner only; every other viewer keeps the published/base chain above.
497
535
  if (draft) {
@@ -543,11 +581,17 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
543
581
  locals.integrationPlan ??
544
582
  resolveEnvironmentOnlyPlan(locals.browserEnvironment, tenant.tenant_id);
545
583
 
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}`)
584
+ // Rewrite when a /<domain>/ prefix was stripped OR a publication render alias
585
+ // resolved to its canonical mount. next(path) does not re-run middleware, so
586
+ // the one route plan and locals above remain authoritative.
587
+ const rewriteTarget = middlewareRewriteTargetV1({
588
+ requestPath: url.pathname,
589
+ basePath,
590
+ renderPath,
591
+ search: url.search,
592
+ });
593
+ let response = rewriteTarget !== undefined
594
+ ? await next(rewriteTarget)
551
595
  : await next();
552
596
 
553
597
  // On-publish auto-refresh: inject the live-reload client into SHARED preview
@@ -678,6 +722,7 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
678
722
  formatHtmlCacheControl(locals.tenant?.serving?.htmlCachePolicy),
679
723
  );
680
724
  }
725
+ if (isInternalPublication) applyInternalContentResponsePolicy(response.headers);
681
726
  // Stamp a fresh per-response nonce into this cold response's body + CSP header
682
727
  // (the warm-hit path above stamps its own). Both paths therefore serve a fresh,
683
728
  // 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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "2.0.18",
3
+ "version": "2.0.19",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
6
6
  "packageManager": "pnpm@11.9.0",
@@ -16,8 +16,10 @@
16
16
  export * from "./product.js";
17
17
  export * from "./tenant.js";
18
18
  export * from "./publication-space.js";
19
+ export * from "./publication-routing.js";
19
20
  export * from "./publication-version.js";
20
21
  export * from "./publication-artifact.js";
22
+ export * from "./publication-reader.js";
21
23
  // The ONE route-tier authority: route -> mount -> publication space ->
22
24
  // {visibility, authorization} -> an ADR 0013 tier. No consumer derives its own.
23
25
  export * from "./route-tier.js";
@@ -1,6 +1,9 @@
1
1
  import type { PublicationSpaceManifestV1 } from "./publication-space.js";
2
2
  import { sha256Hex } from "./hash.js";
3
3
 
4
+ const PUBLICATION_ARTIFACT_IDENTIFIER_RE = /^[a-z0-9](?:[a-z0-9._-]{0,126}[a-z0-9])?$/;
5
+ const PUBLICATION_ARTIFACT_VERSION_RE = /^pv_[a-z0-9](?:[a-z0-9._:-]{4,122}[a-z0-9])$/;
6
+
4
7
  /** Renderer-safe description of one immutable publication artifact file. */
5
8
  export interface PublicationArtifactFileV1 {
6
9
  lane: "content" | "public-asset" | "authorized-asset";
@@ -188,9 +191,9 @@ export function publicationArtifactKeyV1(
188
191
  lane: PublicationArtifactFileV1["lane"],
189
192
  relativePath: string,
190
193
  ): string {
191
- const identifier = /^[a-z0-9](?:[a-z0-9._-]{0,126}[a-z0-9])?$/;
192
- const version = /^pv_[a-z0-9](?:[a-z0-9._:-]{4,122}[a-z0-9])$/;
193
- if (!identifier.test(tenant) || !identifier.test(spaceId) || !version.test(versionId)) {
194
+ if (!PUBLICATION_ARTIFACT_IDENTIFIER_RE.test(tenant) ||
195
+ !PUBLICATION_ARTIFACT_IDENTIFIER_RE.test(spaceId) ||
196
+ !PUBLICATION_ARTIFACT_VERSION_RE.test(versionId)) {
194
197
  throw new Error("publication artifact identity is unsafe");
195
198
  }
196
199
  if (!["content", "public-asset", "authorized-asset"].includes(lane) ||
@@ -201,3 +204,17 @@ export function publicationArtifactKeyV1(
201
204
  }
202
205
  return `publications/${tenant}/${spaceId}/${versionId}/${lane}/${relativePath}`;
203
206
  }
207
+
208
+ /** Marker address for one immutable tenant+space+version bundle. */
209
+ export function publicationArtifactBundleKeyV1(
210
+ tenant: string,
211
+ spaceId: string,
212
+ versionId: string,
213
+ ): string {
214
+ if (!PUBLICATION_ARTIFACT_IDENTIFIER_RE.test(tenant) ||
215
+ !PUBLICATION_ARTIFACT_IDENTIFIER_RE.test(spaceId) ||
216
+ !PUBLICATION_ARTIFACT_VERSION_RE.test(versionId)) {
217
+ throw new Error("publication artifact identity is unsafe");
218
+ }
219
+ return `publications/${tenant}/${spaceId}/${versionId}/bundle.json`;
220
+ }