@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.
@@ -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's links win; otherwise every
212
- // column's links flatten into the policy-link row (mirrors the retired
213
- // nav/Footer.astro exactly).
214
- const legalCol = chrome.footer.columns.find((c) => /legal/i.test(c.title));
215
- const bottomLinks = legalCol ? legalCol.links : chrome.footer.columns.flatMap((c) => c.links);
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={chrome.footer.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 — component surface only. The raw
28
- * renderer's `ChromeFooter.bottom` carries text only (no links); its footer
29
- * columns cover the same links under `footer` above.
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
- const columns = footer.columns
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 bottom = footer.bottom
429
- ? `\n <div class="footer-bottom">${footer.bottom.copyright ? `<p>${esc(footer.bottom.copyright)}</p>` : ""}${
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
- }</div>`
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
- 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.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
  *