@tokenoftrust/storefront-runner 2.0.13 → 2.0.14

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.
@@ -23,7 +23,9 @@ import type { CustomizationEnv, KvGet, TenantConfig } from "@tot/public-runtime"
23
23
  import {
24
24
  contentArtifactPath,
25
25
  listPublishedArtifactPaths,
26
+ listVersionArtifactPaths,
26
27
  readPublishedArtifact,
28
+ readVersionArtifact,
27
29
  tenantDirRelative,
28
30
  tenantDirSegment,
29
31
  } from "@tot/public-runtime";
@@ -681,6 +683,99 @@ export class PublishedContentProvider implements ContentProvider {
681
683
  }
682
684
  }
683
685
 
686
+ // ---------------------------------------------------------------------------
687
+ // VersionPinnedContentProvider — pins content to ONE exact immutable
688
+ // `versionId`, bypassing channel/pointer resolution entirely (the candidate/
689
+ // revision preview's read path). Structurally the version-pinned twin of
690
+ // PublishedContentProvider (channel -> versionId vs. versionId direct); every
691
+ // method mirrors it 1:1 so a candidate's OWN pages/blog/products/etc. render
692
+ // through the exact same generic-content plumbing as a live/preview page,
693
+ // pinned to the exact commit under review instead of the shared channel.
694
+ // ---------------------------------------------------------------------------
695
+
696
+ export class VersionPinnedContentProvider implements ContentProvider {
697
+ constructor(
698
+ private readonly base: ContentProvider,
699
+ private readonly tenantId: string,
700
+ private readonly get: KvGet,
701
+ private readonly versionId: string,
702
+ ) {}
703
+
704
+ private pinnedRaw(relative: string): Promise<string | null> {
705
+ return readVersionArtifact(
706
+ this.get,
707
+ this.tenantId,
708
+ this.versionId,
709
+ contentArtifactPath(this.tenantId, relative),
710
+ );
711
+ }
712
+
713
+ private async pinned<T>(relative: string): Promise<T | null> {
714
+ const raw = await this.pinnedRaw(relative);
715
+ if (!raw) return null;
716
+ try {
717
+ return JSON.parse(raw) as T;
718
+ } catch {
719
+ return null;
720
+ }
721
+ }
722
+
723
+ async getHome(): Promise<HomeContent> {
724
+ return (await this.pinned<HomeContent>("home.json")) ?? this.base.getHome();
725
+ }
726
+ async getHomeHtml(): Promise<string | null> {
727
+ const raw = await this.pinnedRaw("home.html");
728
+ return raw !== null ? stampTrackingIdsAtPublish(raw) : this.base.getHomeHtml();
729
+ }
730
+ async getPageHtml(slug: string): Promise<string | null> {
731
+ const rel = `pages-html/${normalizeRawPageSlug(slug)}.html`;
732
+ const raw = await this.pinnedRaw(rel);
733
+ return raw !== null ? stampTrackingIdsAtPublish(raw) : this.base.getPageHtml(slug);
734
+ }
735
+ listPageHtmlSlugs(): Promise<string[]> {
736
+ return this.listArtifactPaths("pages-html/").then((paths) =>
737
+ paths === null
738
+ ? this.base.listPageHtmlSlugs()
739
+ : paths
740
+ .filter((path) => path.endsWith(".html"))
741
+ .map((path) => path.slice("pages-html/".length, -".html".length)),
742
+ );
743
+ }
744
+ async getChromeHtml(currentPath?: string): Promise<string | null> {
745
+ const publishedHtml = await this.pinnedRaw("chrome.html");
746
+ if (publishedHtml) return stampTrackingIdsAtPublish(publishedHtml);
747
+ const config = parseChromeConfig(await this.pinned<unknown>("chrome.json"));
748
+ if (config) {
749
+ const assignments = parseChromeAssignments(await this.pinned<unknown>("chrome-assignments.json"));
750
+ return renderRawChrome(config, currentPath, assignments);
751
+ }
752
+ return this.base.getChromeHtml(currentPath);
753
+ }
754
+ async getChrome(): Promise<ChromeConfig> {
755
+ const config = parseChromeConfig(await this.pinned<unknown>("chrome.json"));
756
+ return config ?? this.base.getChrome();
757
+ }
758
+ async getPage(slug: string): Promise<EditorialPage | null> {
759
+ return (await this.pinned<EditorialPage>(`pages/${slug}.json`)) ?? this.base.getPage(slug);
760
+ }
761
+ async listPages(): Promise<EditorialPage[]> {
762
+ const paths = await this.listArtifactPaths("pages/");
763
+ if (paths === null) return this.base.listPages();
764
+ const pages = await Promise.all(
765
+ paths.filter((path) => path.endsWith(".json")).map(async (path) => this.pinned<EditorialPage>(path)),
766
+ );
767
+ return pages.filter((page): page is EditorialPage => page !== null);
768
+ }
769
+
770
+ readArtifact(relative: string): Promise<string | null> {
771
+ return this.pinnedRaw(relative);
772
+ }
773
+
774
+ async listArtifactPaths(prefix = ""): Promise<string[] | null> {
775
+ return listVersionArtifactPaths(this.get, this.tenantId, this.versionId, prefix);
776
+ }
777
+ }
778
+
684
779
  // ---------------------------------------------------------------------------
685
780
  // DevDraftContentProvider — the per-developer DRAFT overlay. Wraps the
686
781
  // resolved provider chain and serves a
@@ -747,9 +842,22 @@ export class DevDraftContentProvider implements ContentProvider {
747
842
 
748
843
  const PLACEHOLDER_TOKEN_RE = /PLACEHOLDER|^$/i;
749
844
 
845
+ /**
846
+ * Pin content resolution to ONE exact immutable `versionId` instead of a
847
+ * `{live,test}` channel — the candidate/revision preview's read path (see
848
+ * `VersionPinnedContentProvider`). Takes priority over `customization` when
849
+ * both are given: a pinned candidate render must never fall through to the
850
+ * shared channel.
851
+ */
852
+ export interface ContentVersionPin {
853
+ get: KvGet;
854
+ versionId: string;
855
+ }
856
+
750
857
  export function createContentProvider(
751
858
  tenant: TenantConfig,
752
859
  customization?: ContentCustomization,
860
+ pin?: ContentVersionPin,
753
861
  ): ContentProvider {
754
862
  // Content dirs are keyed by tenant_id (the adopter writes content/<tenant>/).
755
863
  const local = new LocalContentProvider(tenant.tenant_id);
@@ -758,6 +866,9 @@ export function createContentProvider(
758
866
  const base: ContentProvider = usable
759
867
  ? new StoryblokContentProvider(token, local)
760
868
  : local;
869
+ if (pin) {
870
+ return new VersionPinnedContentProvider(base, tenant.tenant_id, pin.get, pin.versionId);
871
+ }
761
872
  return customization
762
873
  ? new PublishedContentProvider(base, tenant.tenant_id, customization)
763
874
  : base;
@@ -27,6 +27,8 @@ import { STATIC_SCRIPT_HASHES, STATIC_STYLE_HASHES } from "@/lib/cspStaticHashes
27
27
  import { applySecurityHeaders } from "@/lib/securityHeaders";
28
28
  import { maybeInjectPreviewReload } from "@/lib/previewReload";
29
29
  import {
30
+ CANDIDATE_CONTENT_PIN_PARAM,
31
+ verifyContentPinToken,
30
32
  resolveCustomizationEnv,
31
33
  buildCsp,
32
34
  STOREFRONT_CSP_ALLOWLIST,
@@ -288,6 +290,34 @@ export const onRequest = defineMiddleware(async (context, next) => {
288
290
  const isPreviewHost = host.toLowerCase().startsWith("preview.");
289
291
  const queryToken = url.searchParams.get("__preview");
290
292
  const cookieToken = context.cookies.get("tot_preview")?.value ?? null;
293
+
294
+ // Candidate-render CONTENT PIN (`__tot_candidate_pin`): a signed, tenant- and
295
+ // expiry-bound token minted ONLY by the control-plane candidate/revision
296
+ // renderers (`src/lib/preview/pinnedRouteRender.ts`, never client-forgeable)
297
+ // that pins THIS one render's content resolution to an exact immutable
298
+ // `versionId`, bypassing the shared `{live,test}` channel entirely. It rides
299
+ // an in-process `context.rewrite()` from those renderers so a real Astro
300
+ // route (blog, editorial pages, …) — not just the raw `home.html`/
301
+ // `pages-html/*` documents they serve directly — renders pinned to the
302
+ // candidate under review (fb-1788536381607). Verified here so it can only
303
+ // ever name the resolved tenant's own version; a mismatched/expired/absent
304
+ // token falls through to the normal channel-resolved read below, same
305
+ // fail-closed posture as the `__preview` token.
306
+ const candidatePinParam = url.searchParams.get(CANDIDATE_CONTENT_PIN_PARAM);
307
+ let candidatePin: { versionId: string; basePath: string } | null = null;
308
+ if (candidatePinParam) {
309
+ const pinSecret = (await readEnv("CUSTOMIZATION_PREVIEW_SECRET")) ?? null;
310
+ if (pinSecret) {
311
+ const verified = await verifyContentPinToken(
312
+ pinSecret,
313
+ candidatePinParam,
314
+ Math.floor(Date.now() / 1000),
315
+ );
316
+ if (verified.ok && verified.claims.tenantId === tenant.tenant_id) {
317
+ candidatePin = { versionId: verified.claims.versionId, basePath: verified.claims.basePath };
318
+ }
319
+ }
320
+ }
291
321
  // Per-developer AI-edit draft: the signed
292
322
  // tot_dev_draft cookie's mere PRESENCE opts the request out of the shared page
293
323
  // cache below (a draft view must never be cached publicly); it's verified —
@@ -329,6 +359,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
329
359
  !isPreviewHost &&
330
360
  !queryToken &&
331
361
  !cookieToken &&
362
+ !candidatePin &&
332
363
  !draftCookie &&
333
364
  managedIntegrationDeclarations.length === 0 &&
334
365
  !url.pathname.startsWith("/api/");
@@ -451,6 +482,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
451
482
  !isPreviewHost &&
452
483
  !queryToken &&
453
484
  !cookieToken &&
485
+ !candidatePin &&
454
486
  !draftCookie &&
455
487
  isCacheablePath(url.pathname, url.searchParams);
456
488
  let pageKey: Request | null = null;
@@ -662,10 +694,15 @@ export const onRequest = defineMiddleware(async (context, next) => {
662
694
  : null;
663
695
  locals.commerce = commerce ?? undefined;
664
696
  locals.tot = tot;
665
- locals.content = createContentProvider(tenant, {
666
- get: kvGet,
667
- env: customization.env,
668
- });
697
+ // A verified candidate content pin takes priority over the channel-resolved
698
+ // read — a pinned candidate/revision render must never fall through to the
699
+ // shared `{live,test}` content (see `pinnedRouteRender.ts`).
700
+ locals.content = candidatePin
701
+ ? createContentProvider(tenant, undefined, { get: kvGet, versionId: candidatePin.versionId })
702
+ : createContentProvider(tenant, {
703
+ get: kvGet,
704
+ env: customization.env,
705
+ });
669
706
  // Developer-draft content overlay — serves the AI-edited artifacts to the draft
670
707
  // owner only; every other viewer keeps the published/base chain above.
671
708
  if (draft) {
@@ -686,8 +723,11 @@ export const onRequest = defineMiddleware(async (context, next) => {
686
723
  locals.activeWidgetPlacements = [];
687
724
  // Prefix that path-routed pages live under ("" for host-routed). Pages stay
688
725
  // tenant-agnostic; the Layout uses this to keep root-absolute links inside
689
- // the tenant prefix on the client.
690
- locals.basePath = basePath;
726
+ // the tenant prefix on the client. A candidate content pin overrides this to
727
+ // the PINNING render's own prefix (`/preview/rev/<sha>` or
728
+ // `/preview/render/<tenant>/<changeId>`) so a pinned page's OWN generated
729
+ // links stay inside that candidate instead of escaping to the live host.
730
+ locals.basePath = candidatePin ? candidatePin.basePath : basePath;
691
731
  // Renderer-owned four-stage browser environment (ADR 0014 E1 / 0017), computed
692
732
  // ONCE here from authoritative request state and serialized before app code by
693
733
  // both document mounts (Layout.astro + rawHtmlResponse). The candidate render
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "2.0.13",
3
+ "version": "2.0.14",
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",
@@ -130,6 +130,106 @@ export async function verifyPreviewToken(
130
130
  return { ok: true, claims };
131
131
  }
132
132
 
133
+ /**
134
+ * Query-string param a candidate/revision renderer's `context.rewrite()` uses to
135
+ * carry a signed content-pin token into the re-dispatched request — read by
136
+ * `src/middleware/index.ts` to bind that ONE request's content resolution to an
137
+ * exact `versionId` instead of the shared `{live,test}` channel. See
138
+ * {@link ContentPinClaims}.
139
+ */
140
+ export const CANDIDATE_CONTENT_PIN_PARAM = "__tot_candidate_pin";
141
+
142
+ /**
143
+ * Claims carried by a CONTENT-PIN token — distinct from {@link PreviewClaims}
144
+ * (which grants "test" channel access). A content pin grants read access to ONE
145
+ * exact immutable site version, bypassing channel resolution entirely. It backs
146
+ * the candidate/revision renderer's generic-route fallback: rendering any real
147
+ * Astro page (not just the raw `home.html`/`pages-html/*` documents) pinned to a
148
+ * candidate's `versionId` rather than the shared `live`/`test` channel.
149
+ */
150
+ export interface ContentPinClaims {
151
+ tenantId: string;
152
+ versionId: string;
153
+ /** basePath the pinned render's own generated links must stay under. */
154
+ basePath: string;
155
+ /** Expiry, epoch seconds. */
156
+ exp: number;
157
+ }
158
+
159
+ /**
160
+ * Mint a content-pin token: `<payloadB64Url>.<sigB64Url>`. `ttlSeconds` is added
161
+ * to `nowSeconds` (caller supplies the clock so this stays pure/testable).
162
+ */
163
+ export async function signContentPinToken(
164
+ secret: string,
165
+ claims: Omit<ContentPinClaims, "exp">,
166
+ ttlSeconds: number,
167
+ nowSeconds: number,
168
+ ): Promise<string> {
169
+ const full: ContentPinClaims = { ...claims, exp: nowSeconds + ttlSeconds };
170
+ const payload = bytesToB64Url(textEncoder.encode(JSON.stringify(full)));
171
+ const key = await hmacKey(secret);
172
+ const sig = await crypto.subtle.sign(
173
+ "HMAC",
174
+ key,
175
+ textEncoder.encode(payload) as unknown as BufferSource,
176
+ );
177
+ return `${payload}.${bytesToB64Url(new Uint8Array(sig))}`;
178
+ }
179
+
180
+ export type VerifyContentPinResult =
181
+ | { ok: true; claims: ContentPinClaims }
182
+ | { ok: false; reason: "malformed" | "bad-signature" | "expired" };
183
+
184
+ /**
185
+ * Verify a content-pin token against `secret` and the caller's clock. Signature
186
+ * is checked with `crypto.subtle.verify` (constant-time) before expiry.
187
+ */
188
+ export async function verifyContentPinToken(
189
+ secret: string,
190
+ token: string,
191
+ nowSeconds: number,
192
+ ): Promise<VerifyContentPinResult> {
193
+ const dot = token.indexOf(".");
194
+ if (dot <= 0 || dot === token.length - 1) return { ok: false, reason: "malformed" };
195
+ const payload = token.slice(0, dot);
196
+ const sigPart = token.slice(dot + 1);
197
+
198
+ let sig: Uint8Array;
199
+ try {
200
+ sig = b64UrlToBytes(sigPart);
201
+ } catch {
202
+ return { ok: false, reason: "malformed" };
203
+ }
204
+
205
+ const key = await hmacKey(secret);
206
+ const valid = await crypto.subtle.verify(
207
+ "HMAC",
208
+ key,
209
+ sig as unknown as BufferSource,
210
+ textEncoder.encode(payload) as unknown as BufferSource,
211
+ );
212
+ if (!valid) return { ok: false, reason: "bad-signature" };
213
+
214
+ let claims: ContentPinClaims;
215
+ try {
216
+ claims = JSON.parse(new TextDecoder().decode(b64UrlToBytes(payload)));
217
+ } catch {
218
+ return { ok: false, reason: "malformed" };
219
+ }
220
+ if (
221
+ !claims ||
222
+ typeof claims.tenantId !== "string" ||
223
+ typeof claims.versionId !== "string" ||
224
+ typeof claims.basePath !== "string" ||
225
+ typeof claims.exp !== "number"
226
+ ) {
227
+ return { ok: false, reason: "malformed" };
228
+ }
229
+ if (claims.exp <= nowSeconds) return { ok: false, reason: "expired" };
230
+ return { ok: true, claims };
231
+ }
232
+
133
233
  export type CustomizationEnv = "live" | "test";
134
234
 
135
235
  export interface EnvResolutionInput {
@@ -286,6 +286,46 @@ export async function readPublishedArtifact(
286
286
  return get(customizationArtifactKey(tenantId, versionId));
287
287
  }
288
288
 
289
+ /**
290
+ * Read one artifact's text from a resolved immutable `versionId`, or null when
291
+ * that specific artifact is absent from the version. Pinned to the versionId,
292
+ * never a channel pointer — a candidate/revision preview must never resolve
293
+ * live/test content. `path` is the FULL artifact path (e.g. from
294
+ * `contentArtifactPath`), not tenant-content-relative.
295
+ */
296
+ export async function readVersionArtifact(
297
+ get: KvGet,
298
+ tenantId: string,
299
+ versionId: string,
300
+ path: string,
301
+ ): Promise<string | null> {
302
+ const manifest = await readSiteVersion(get, tenantId, versionId);
303
+ const contentHash = manifest?.artifacts?.[path];
304
+ if (!contentHash) return null;
305
+ return get(customizationArtifactKey(tenantId, contentHash));
306
+ }
307
+
308
+ /**
309
+ * List artifact paths in an immutable site version, pinned directly to
310
+ * `versionId` (never a channel pointer) — the enumeration companion to
311
+ * {@link readVersionArtifact}, used by a pinned `ContentProvider` to answer
312
+ * `listArtifactPaths` the same way {@link listPublishedArtifactPaths} does for
313
+ * the channel-resolved read. `prefix` is content-relative (e.g. `"blog/"`).
314
+ */
315
+ export async function listVersionArtifactPaths(
316
+ get: KvGet,
317
+ tenantId: string,
318
+ versionId: string,
319
+ prefix = "",
320
+ ): Promise<string[]> {
321
+ const manifest = await readSiteVersion(get, tenantId, versionId);
322
+ const root = contentArtifactPath(tenantId, "");
323
+ return Object.keys(manifest?.artifacts ?? {})
324
+ .flatMap((path) => (path.startsWith(root) ? [path.slice(root.length)] : []))
325
+ .filter((path) => path.startsWith(prefix))
326
+ .sort((a, b) => a.localeCompare(b));
327
+ }
328
+
289
329
  /**
290
330
  * List artifact paths in the immutable site version selected for `(tenant, env)`.
291
331
  *