@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.
- package/apps/storefront/env.d.ts +3 -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/edgeCache.ts +17 -1
- package/apps/storefront/src/lib/publication/contentProvider.ts +159 -0
- package/apps/storefront/src/lib/publication/runtimeSource.ts +97 -0
- package/apps/storefront/src/middleware/controlPlane.ts +24 -3
- package/apps/storefront/src/middleware/index.ts +125 -22
- package/apps/storefront/src/middleware/renderChain.ts +101 -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/package.json +1 -1
- package/packages/public-runtime/src/index.ts +2 -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,
|
|
@@ -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
|
|
148
|
-
// cache
|
|
149
|
-
//
|
|
150
|
-
|
|
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:
|
|
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
|
-
//
|
|
487
|
-
//
|
|
488
|
-
//
|
|
489
|
-
|
|
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
|
-
//
|
|
547
|
-
//
|
|
548
|
-
//
|
|
549
|
-
|
|
550
|
-
|
|
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
|
|
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,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "2.0.
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
+
}
|