@tokenoftrust/storefront-runner 2.0.13 → 2.0.15
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/public/shared/commerce-chrome.css +5 -0
- package/apps/storefront/src/components/chrome/SiteFooter.astro +4 -1
- package/apps/storefront/src/layouts/Layout.astro +8 -6
- package/apps/storefront/src/lib/chrome/footerLegal.ts +52 -0
- package/apps/storefront/src/lib/chrome/trackingPlacements.ts +4 -3
- package/apps/storefront/src/lib/rawChrome.ts +26 -4
- package/apps/storefront/src/lib/storyblok/provider.ts +111 -0
- package/apps/storefront/src/middleware/index.ts +46 -6
- package/package.json +1 -1
- package/packages/public-runtime/src/customization-preview.ts +100 -0
- package/packages/public-runtime/src/customization-runtime.ts +40 -0
|
@@ -339,6 +339,11 @@ a.article-card__tag:hover { text-decoration: underline; }
|
|
|
339
339
|
.footer-warning p { color: color-mix(in srgb, var(--color-primary-contrast) 78%, transparent); font-size: .82rem; }
|
|
340
340
|
.footer-bottom { margin-top: 28px; padding-top: 18px; border-top: 1px solid color-mix(in srgb, var(--color-primary-contrast) 16%, transparent); display: flex; flex-wrap: wrap; gap: 10px 24px; justify-content: space-between; align-items: center; }
|
|
341
341
|
.footer-bottom p, .footer-bottom a { color: color-mix(in srgb, var(--color-primary-contrast) 60%, transparent); font-size: .82rem; }
|
|
342
|
+
/* The Legal link row (a "Legal"-titled footer column, moved here — see
|
|
343
|
+
lib/chrome/footerLegal.ts). Wraps under the copyright at narrow widths
|
|
344
|
+
rather than overflowing the bar. */
|
|
345
|
+
.footer-bottom nav ul { display: flex; flex-wrap: wrap; gap: 6px 20px; list-style: none; margin: 0; padding: 0; }
|
|
346
|
+
.footer-bottom nav a:hover { color: var(--color-primary-contrast); text-decoration: underline; }
|
|
342
347
|
|
|
343
348
|
/* --- Reveal-on-scroll (raw-chrome markup uses .reveal/.in) ---------------- */
|
|
344
349
|
.reveal { opacity: 0; transform: translateY(18px); transition: opacity .5s ease, transform .5s ease; }
|
|
@@ -27,7 +27,10 @@ interface Props {
|
|
|
27
27
|
compliance?: string;
|
|
28
28
|
/** Legal bottom-bar line (e.g. a composed "© 2026 Acme · some legal note" string). */
|
|
29
29
|
legal?: string;
|
|
30
|
-
/** Bottom-bar policy links (e.g. Privacy Policy, Terms), shown alongside `legal`.
|
|
30
|
+
/** Bottom-bar policy links (e.g. Privacy Policy, Terms), shown alongside `legal`.
|
|
31
|
+
* The links of a `Legal`-titled footer column, which the caller MOVES out of
|
|
32
|
+
* `columns` (lib/chrome/footerLegal.ts) — passing them in both places would
|
|
33
|
+
* render each one twice. */
|
|
31
34
|
bottomLinks?: ChromeLink[];
|
|
32
35
|
class?: string;
|
|
33
36
|
}
|
|
@@ -25,6 +25,7 @@ import { organizationLd, websiteLd } from "@/lib/jsonld";
|
|
|
25
25
|
import { nonceInlineTags, CHROME_BODY_MARKER } from "@/lib/rawMarketingHtml";
|
|
26
26
|
import { reportTrackingChainHealth } from "@/lib/tracking/interactionHealth";
|
|
27
27
|
import { resolveHeaderVariant, resolveFooterVariant } from "@/lib/chrome/variant";
|
|
28
|
+
import { splitFooterLegal } from "@/lib/chrome/footerLegal";
|
|
28
29
|
import type { HeaderVariant, FooterVariant } from "@/lib/chrome/model";
|
|
29
30
|
import { renderIntegrationPhases, INTERACTION_TRACKING_BOOT_JS } from "@tot/public-runtime";
|
|
30
31
|
import { readViewerSession } from "@/lib/auth/route";
|
|
@@ -208,11 +209,12 @@ const headerLogo = brandLogoValid ?? themeHeaderLogo;
|
|
|
208
209
|
const headerWordmark = brand?.label ?? tenant.displayName;
|
|
209
210
|
const headerBrandHref = brand?.href;
|
|
210
211
|
|
|
211
|
-
// Footer bottom bar: a "Legal"-titled column
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
const
|
|
215
|
-
|
|
212
|
+
// Footer bottom bar: a "Legal"-titled column MOVES out of the grid and its
|
|
213
|
+
// links render beside the copyright — one shared split with the raw renderer
|
|
214
|
+
// (lib/chrome/footerLegal.ts), so neither surface can duplicate them.
|
|
215
|
+
const { gridColumns: footerColumns, legalLinks: bottomLinks } = splitFooterLegal(
|
|
216
|
+
chrome.footer.columns,
|
|
217
|
+
);
|
|
216
218
|
const legalBase = chrome.footer.bottom?.copyright ?? `© ${new Date().getFullYear()} ${tenant.displayName}`;
|
|
217
219
|
const legalLine = `${legalBase}${chrome.footer.bottom?.note ? ` · ${chrome.footer.bottom.note}` : ""}`;
|
|
218
220
|
|
|
@@ -386,7 +388,7 @@ const footerWidgets = await renderWidgetSlot(
|
|
|
386
388
|
contact: chrome.footer.brand?.text ? [chrome.footer.brand.text] : undefined,
|
|
387
389
|
}}
|
|
388
390
|
variant={resolvedFooterVariant}
|
|
389
|
-
columns={
|
|
391
|
+
columns={footerColumns}
|
|
390
392
|
newsletter={features.newsletter ? { title: "Newsletter", placeholder: "you@example.com" } : undefined}
|
|
391
393
|
social={chrome.footer.social}
|
|
392
394
|
legal={legalLine}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The footer's Legal split — ONE source of truth for both renderers (the
|
|
3
|
+
* raw-string footer in lib/rawChrome.ts and the component footer in
|
|
4
|
+
* components/chrome/SiteFooter.astro), so a tenant's `Legal` column can never
|
|
5
|
+
* land in the grid on one surface and in the bottom bar on the other.
|
|
6
|
+
*
|
|
7
|
+
* Contract, title-based (no extra schema field): a footer column titled
|
|
8
|
+
* `Legal` — case-insensitive, surrounding whitespace ignored — is not a grid
|
|
9
|
+
* column. Its links render in the bottom bar beside the copyright, inside a
|
|
10
|
+
* single `<nav aria-label="Legal">`.
|
|
11
|
+
*
|
|
12
|
+
* The column is MOVED, never copied: it leaves `.footer-grid` entirely, so
|
|
13
|
+
* each legal link appears exactly once in the DOM. Each link keeps its own
|
|
14
|
+
* governed identity (`data-tot-el`) and action (`data-tot-action`); only the
|
|
15
|
+
* placement changes, to `footer-bottom`, which is what that placement means.
|
|
16
|
+
*
|
|
17
|
+
* A tenant with no `Legal` column gets a text-only bottom bar — the other
|
|
18
|
+
* columns stay in the grid rather than being flattened into a second copy of
|
|
19
|
+
* themselves under the copyright.
|
|
20
|
+
*/
|
|
21
|
+
import type { ChromeLink, FooterColumn } from "./model.js";
|
|
22
|
+
|
|
23
|
+
/** The bottom-bar nav's accessible name. Exactly one is emitted per footer. */
|
|
24
|
+
export const LEGAL_NAV_LABEL = "Legal";
|
|
25
|
+
|
|
26
|
+
/** The title-based contract: `Legal`, case- and surrounding-whitespace-insensitive. */
|
|
27
|
+
export function isLegalColumnTitle(title: string): boolean {
|
|
28
|
+
return title.trim().toLowerCase() === LEGAL_NAV_LABEL.toLowerCase();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface FooterLegalSplit {
|
|
32
|
+
/** The columns that stay in `.footer-grid`, in authored order. */
|
|
33
|
+
gridColumns: FooterColumn[];
|
|
34
|
+
/** The extracted bottom-bar links, in authored order. Empty when no Legal column. */
|
|
35
|
+
legalLinks: ChromeLink[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Partition footer columns into what the grid renders and what the bottom bar
|
|
40
|
+
* renders. Every `Legal`-titled column is extracted (a config with two is a
|
|
41
|
+
* tenant mistake, not a reason to emit a second nav) and its links concatenated
|
|
42
|
+
* in authored order.
|
|
43
|
+
*/
|
|
44
|
+
export function splitFooterLegal(columns: readonly FooterColumn[]): FooterLegalSplit {
|
|
45
|
+
const gridColumns: FooterColumn[] = [];
|
|
46
|
+
const legalLinks: ChromeLink[] = [];
|
|
47
|
+
for (const column of columns) {
|
|
48
|
+
if (isLegalColumnTitle(column.title)) legalLinks.push(...column.links);
|
|
49
|
+
else gridColumns.push(column);
|
|
50
|
+
}
|
|
51
|
+
return { gridColumns, legalLinks };
|
|
52
|
+
}
|
|
@@ -24,9 +24,10 @@ export const CHROME_PLACEMENT = {
|
|
|
24
24
|
/** Footer link columns. */
|
|
25
25
|
footer: "footer",
|
|
26
26
|
/**
|
|
27
|
-
* Footer bottom legal/policy link row
|
|
28
|
-
*
|
|
29
|
-
*
|
|
27
|
+
* Footer bottom legal/policy link row, on either surface — the links of a
|
|
28
|
+
* `Legal`-titled footer column, which both renderers move out of the grid and
|
|
29
|
+
* into the bottom bar (lib/chrome/footerLegal.ts). `ChromeFooter.bottom`
|
|
30
|
+
* itself carries text only.
|
|
30
31
|
*/
|
|
31
32
|
footerBottom: "footer-bottom",
|
|
32
33
|
/** The mobile nav drawer (MobileNav island) and its no-JS accordion fallback. */
|
|
@@ -28,6 +28,7 @@ import { CHROME_BODY_MARKER, CHROME_HEAD_MARKER } from "./rawMarketingHtml.js";
|
|
|
28
28
|
import { isCurrentNavHref } from "./chrome/navCurrent.js";
|
|
29
29
|
import { ctaIdentityAttr } from "./chrome/ctaIdentity.js";
|
|
30
30
|
import { CHROME_PLACEMENT } from "./chrome/trackingPlacements.js";
|
|
31
|
+
import { LEGAL_NAV_LABEL, splitFooterLegal } from "./chrome/footerLegal.js";
|
|
31
32
|
import { trackingAttributesHtml } from "@tot/public-runtime";
|
|
32
33
|
import type {
|
|
33
34
|
ChromeAgeGate,
|
|
@@ -391,7 +392,11 @@ function renderFooter(footer: ChromeFooter): string {
|
|
|
391
392
|
return ` <div class="footer-brand">${logo}${text}${news}</div>\n`;
|
|
392
393
|
})()
|
|
393
394
|
: "";
|
|
394
|
-
|
|
395
|
+
// A `Legal`-titled column moves to the bottom bar (lib/chrome/footerLegal.ts)
|
|
396
|
+
// — scope indices count RENDERED grid columns, so they describe where a link
|
|
397
|
+
// actually is rather than where it sat in the authored array.
|
|
398
|
+
const { gridColumns, legalLinks } = splitFooterLegal(footer.columns);
|
|
399
|
+
const columns = gridColumns
|
|
395
400
|
.map((c, colIndex) => {
|
|
396
401
|
const links = c.links
|
|
397
402
|
.map((l, i) => {
|
|
@@ -425,11 +430,28 @@ function renderFooter(footer: ChromeFooter): string {
|
|
|
425
430
|
const warning = footer.warning
|
|
426
431
|
? `\n <div class="footer-warning"><p><strong>WARNING:</strong> ${esc(footer.warning)}</p></div>`
|
|
427
432
|
: "";
|
|
428
|
-
const
|
|
429
|
-
?
|
|
433
|
+
const legalNav = legalLinks.length
|
|
434
|
+
? `<nav aria-label="${LEGAL_NAV_LABEL}"><ul>${legalLinks
|
|
435
|
+
.map((l, i) => {
|
|
436
|
+
const stamp = trackingAttributesHtml({
|
|
437
|
+
el: l.id,
|
|
438
|
+
action: l.action,
|
|
439
|
+
tracking: l.tracking,
|
|
440
|
+
placement: CHROME_PLACEMENT.footerBottom,
|
|
441
|
+
scope: "footer-bottom",
|
|
442
|
+
index: i,
|
|
443
|
+
});
|
|
444
|
+
return `<li><a href="${esc(l.href)}"${relFor(l)}${stamp}>${esc(l.label)}</a></li>`;
|
|
445
|
+
})
|
|
446
|
+
.join("")}</ul></nav>`
|
|
447
|
+
: "";
|
|
448
|
+
const bottomText = footer.bottom
|
|
449
|
+
? `${footer.bottom.copyright ? `<p>${esc(footer.bottom.copyright)}</p>` : ""}${
|
|
430
450
|
footer.bottom.note ? `<p>${esc(footer.bottom.note)}</p>` : ""
|
|
431
|
-
}
|
|
451
|
+
}`
|
|
432
452
|
: "";
|
|
453
|
+
const bottom =
|
|
454
|
+
bottomText || legalNav ? `\n <div class="footer-bottom">${bottomText}${legalNav}</div>` : "";
|
|
433
455
|
return (
|
|
434
456
|
`<footer class="site-footer">\n` +
|
|
435
457
|
` <div class="container">\n` +
|
|
@@ -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
|
-
|
|
666
|
-
|
|
667
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "2.0.15",
|
|
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
|
*
|