@tokenoftrust/storefront-runner 4.1.2 → 4.2.0

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.
@@ -131,6 +131,12 @@ declare global {
131
131
  * Look" content for the resolved customizationEnv over Storyblok/local.
132
132
  */
133
133
  content: ContentProvider;
134
+ /**
135
+ * Immutable content version this response rendered, set only for a candidate
136
+ * pin. Absent on the shared preview channel and on publication. Preview cart
137
+ * signing copies it; it is not the asset cookie revision.
138
+ */
139
+ contentVersionId?: string;
134
140
  /** Inline CSS-variable theme block already serialized for <head>. */
135
141
  themeStyle: string;
136
142
  /** Absolute canonical base for this request. */
@@ -30,7 +30,7 @@ interface Props {
30
30
  }
31
31
 
32
32
  const { product, priority = false, rating, reviewCount } = Astro.props;
33
- const { tenant, basePath, features, commerce, apiBasePath, browserEnvironment } = Astro.locals;
33
+ const { tenant, basePath, features, commerce, apiBasePath, browserEnvironment, contentVersionId } = Astro.locals;
34
34
  const showBadges = features.productBadges;
35
35
 
36
36
  // A compliance-verified item (age/lab/regulatory) earns a "Compliant" badge —
@@ -93,6 +93,7 @@ const cardCart = await buildCardCartForm({
93
93
  commerce,
94
94
  apiBasePath,
95
95
  environment: browserEnvironment.environment,
96
+ contentVersionId,
96
97
  });
97
98
 
98
99
  const quickview = {
@@ -37,6 +37,8 @@ export async function buildCardCartForm(input: {
37
37
  commerce: ResolvedManagedCheckout | null | undefined;
38
38
  apiBasePath: string;
39
39
  environment: string;
40
+ /** Candidate content pin this card rendered. Ignored on a live page. */
41
+ contentVersionId?: string;
40
42
  }): Promise<CardCartForm | null> {
41
43
  const variant = defaultCardVariant(input.product);
42
44
  if (!variant) return null;
@@ -58,6 +60,7 @@ export async function buildCardCartForm(input: {
58
60
  autopilotEnabled,
59
61
  tenantId: commerceTenantId(input.tenant),
60
62
  currency: input.tenant.currency,
63
+ contentVersionId: input.environment === "live" ? undefined : input.contentVersionId,
61
64
  });
62
65
  const provider = input.tenant.serving?.provider === "s3-overwrite" ? "s3-overwrite" : "worker-r2-pointer";
63
66
  return {
@@ -733,6 +733,10 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
733
733
  // (checkout handoff, compliance preflight, newsletter) must stay off `basePath`
734
734
  // once it's pin-overridden, or they 404 under preview.
735
735
  locals.apiBasePath = basePath;
736
+ // The catalog pin the page rendered. Absent on the shared preview channel and on
737
+ // publication. Cart signing copies this onto a preview cart; the asset cookie
738
+ // revision is a different value and is not a catalog pin.
739
+ if (candidatePin) locals.contentVersionId = candidatePin.versionId;
736
740
  // Renderer-owned four-stage browser environment (ADR 0014 E1 / 0017), computed
737
741
  // ONCE here from authoritative request state and serialized before app code by
738
742
  // both document mounts (Layout.astro + rawHtmlResponse). The candidate render
@@ -2,7 +2,7 @@
2
2
  import type { APIContext } from "astro";
3
3
  import { createLocalJWKSet, decodeProtectedHeader, jwtVerify, type JSONWebKeySet } from "jose";
4
4
  import {
5
- commerceTenantId, totalsMatch, canonicalJson, shippingMinorFor,
5
+ commerceTenantId, totalsMatch, canonicalJson, shippingMinorFor, isContentVersionId, providerCartDigestMaterial,
6
6
  type CallerToken, type CatalogProduct, type CommerceEvidence, type CommerceEvidenceProvenance,
7
7
  type CommerceProviderCartSnapshot, type FinalCartBinding, type TenantConfig,
8
8
  } from "@tot/public-runtime";
@@ -52,8 +52,9 @@ function readSnapshot(value: unknown): CommerceProviderCartSnapshot | null {
52
52
  if (!value || typeof value !== "object" || Array.isArray(value)) return null;
53
53
  const v = value as Record<string, unknown>;
54
54
  const allowed = ["provider", "providerCartId", "providerCartDigest", "tenantId", "stage", "currency", "lines", "totals", "destinationDigest", "destination"];
55
- const optional = ["expectedAuthorizationId", "verificationRef"];
55
+ const optional = ["expectedAuthorizationId", "verificationRef", "contentVersionId"];
56
56
  if (Object.keys(v).some(k => !allowed.includes(k) && !optional.includes(k)) || allowed.some(k => !(k in v))) return null;
57
+ if ("contentVersionId" in v && (typeof v.contentVersionId !== "string" || !isContentVersionId(v.contentVersionId))) return null;
57
58
  if ("expectedAuthorizationId" in v && (typeof v.expectedAuthorizationId !== "string" || !/^[a-z0-9][a-z0-9-]{0,80}$/.test(v.expectedAuthorizationId))) return null;
58
59
  if ("verificationRef" in v && (typeof v.verificationRef !== "string" || !VERIFICATION_REF.test(v.verificationRef))) return null;
59
60
  if (v.provider !== "foxycart" || typeof v.providerCartId !== "string" || !/^[a-zA-Z0-9._:-]{1,128}$/.test(v.providerCartId) ||
@@ -171,12 +172,12 @@ async function authorize(context: APIContext): Promise<Response> {
171
172
  if (err instanceof DeclaredTenantConfigUnreadError) return deny("config-unreadable", 503);
172
173
  throw err;
173
174
  }
174
- const content = createContentProvider(tenant, { get: kvGet, env: contentEnv });
175
+ const content = snapshot.stage === "preview" && snapshot.contentVersionId
176
+ ? createContentProvider(tenant, undefined, { get: kvGet, versionId: snapshot.contentVersionId })
177
+ : createContentProvider(tenant, { get: kvGet, env: contentEnv });
175
178
  const tot = await createToTClient(tenant, await readD1("CATALOG_DB"), content);
176
179
  const authorityContext = { ...context, locals: { ...context.locals, tenant, tot, content } } as APIContext;
177
- const { tenantId, stage, currency, totals, destinationDigest } = snapshot;
178
- const lines = [...snapshot.lines].sort((a, b) => a.sku.localeCompare(b.sku) || a.unitMinor - b.unitMinor || a.quantity - b.quantity);
179
- if (snapshot.providerCartDigest !== await digest(canonicalJson({ tenantId, stage, currency, lines, totals, destinationDigest }))) return deny("provider-cart-digest");
180
+ if (snapshot.providerCartDigest !== await digest(canonicalJson(providerCartDigestMaterial(snapshot)))) return deny("provider-cart-digest");
180
181
  let listing: CatalogProduct[];
181
182
  try { listing = await tot.getAllActiveProducts(); } catch { return deny("catalog-unavailable", 503); }
182
183
  if (!Array.isArray(listing)) return deny("catalog-unavailable", 503);
@@ -267,6 +267,7 @@ if (commerce || sampleHandoff) {
267
267
  autopilotEnabled,
268
268
  tenantId: commerceTenantId(tenant),
269
269
  currency: tenant.currency,
270
+ contentVersionId: Astro.locals.browserEnvironment.environment === "live" ? undefined : Astro.locals.contentVersionId,
270
271
  }),
271
272
  ] as const;
272
273
  }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "4.1.2",
3
+ "version": "4.2.0",
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",
@@ -347,6 +347,12 @@ export interface CommerceProviderCartSnapshot {
347
347
  totals: MinorTotals;
348
348
  destinationDigest: string;
349
349
  destination: { country: string; region: string };
350
+ /**
351
+ * Immutable content version the preview page rendered, from the signed `content_version`
352
+ * cart option. Present only for a candidate pin. Part of the provider-cart digest when
353
+ * present. A preview authority reads that pin's catalog; a publication cart still reads live.
354
+ */
355
+ contentVersionId?: string;
350
356
  /**
351
357
  * The shopper's verification correlation handle, from the signed `verification` cart option.
352
358
  * Present only when the cart carries one; outside the cart digest. A handle, not a claim: the
@@ -355,6 +361,46 @@ export interface CommerceProviderCartSnapshot {
355
361
  verificationRef?: string;
356
362
  }
357
363
 
364
+ const CONTENT_VERSION_ID = /^[A-Za-z0-9._-]{1,128}$/;
365
+
366
+ /** A rendered content pin. The same shape as a preview version id, and never a path. */
367
+ export function isContentVersionId(value: string): boolean {
368
+ return CONTENT_VERSION_ID.test(value) && !value.includes("..");
369
+ }
370
+
371
+ /**
372
+ * The provider-cart digest body. `contentVersionId` is included only when the snapshot
373
+ * names one, so a cart that carries no pin keeps the digest it already had.
374
+ */
375
+ export function providerCartDigestMaterial(snapshot: {
376
+ tenantId: string;
377
+ stage: string;
378
+ currency: string;
379
+ lines: readonly FinalCartLine[];
380
+ totals: MinorTotals;
381
+ destinationDigest: string;
382
+ contentVersionId?: string;
383
+ }): {
384
+ tenantId: string;
385
+ stage: string;
386
+ currency: string;
387
+ lines: FinalCartLine[];
388
+ totals: MinorTotals;
389
+ destinationDigest: string;
390
+ contentVersionId?: string;
391
+ } {
392
+ const lines = [...snapshot.lines].sort((a, b) => a.sku.localeCompare(b.sku) || a.unitMinor - b.unitMinor || a.quantity - b.quantity);
393
+ const material = {
394
+ tenantId: snapshot.tenantId,
395
+ stage: snapshot.stage,
396
+ currency: snapshot.currency,
397
+ lines,
398
+ totals: snapshot.totals,
399
+ destinationDigest: snapshot.destinationDigest,
400
+ };
401
+ return snapshot.contentVersionId === undefined ? material : { ...material, contentVersionId: snapshot.contentVersionId };
402
+ }
403
+
358
404
  /** Safe source references explaining each server-owned authority decision. */
359
405
  export interface CommerceAuthoritySource {
360
406
  requirement: "required" | "not-required";
@@ -27,6 +27,7 @@ import type {
27
27
  import { resolveStoreCurrency } from "./declared-tenant-config.mjs";
28
28
  import { DEFAULT_ONE_TIME_PURCHASE_OPTION } from "./product.js";
29
29
  import type { ShippingPolicy, TenantConfig } from "./tenant.js";
30
+ import { isContentVersionId } from "./checkout-orders.js";
30
31
 
31
32
  /** Stable commerce scope shared by signed carts, provider callers and operator authorization. */
32
33
  export function commerceTenantId(tenant: Pick<TenantConfig, "appDomain">): string {
@@ -165,6 +166,11 @@ export interface BuildVariantCartPayloadOptions {
165
166
  * `resolveStoreCurrency`, so absent or invalid is USD. Never taken from the submitted form.
166
167
  */
167
168
  currency?: string;
169
+ /**
170
+ * Content version the preview page rendered. Signed as `content_version` when present.
171
+ * A publication page and a shared-channel preview omit it. Never taken from the form.
172
+ */
173
+ contentVersionId?: string;
168
174
  }
169
175
 
170
176
  /** Declared public serving substrate (ADR 0013 `serving.provider`). */
@@ -205,9 +211,13 @@ export class CartSigningUnavailableError extends Error {
205
211
  * both bind the cart to it. `verification` carries the shopper's verification
206
212
  * ref when the gate produced one (`withVerificationRef`). `shipping_policy` carries the
207
213
  * store's declared shipping charge (`withShippingPolicy`). `return_url` carries the page the
208
- * shopper submitted from (`withReturnUrl`).
214
+ * shopper submitted from (`withReturnUrl`). `content_version` carries the candidate
215
+ * content pin the page rendered, and only then.
209
216
  */
210
- export const INTERNAL_CART_FIELDS = ["currency", "tenant", "verification", "shipping_policy", "return_url"] as const;
217
+ export const INTERNAL_CART_FIELDS = ["currency", "tenant", "verification", "shipping_policy", "return_url", "content_version"] as const;
218
+
219
+ /** Signed cart option naming the content version the preview page rendered. */
220
+ export const CONTENT_VERSION_CART_FIELD = "content_version";
211
221
 
212
222
  /** The signed cart option the checkout store's Continue Shopping link reads (`withReturnUrl`). */
213
223
  export const RETURN_URL_CART_FIELD = "return_url";
@@ -499,7 +509,7 @@ export function cartFieldsForVariant(
499
509
  product: CatalogProduct,
500
510
  variant: CatalogVariant,
501
511
  purchaseOption?: ProductPurchaseOption,
502
- opts?: { signedPrice?: number; tenantId?: string; currency?: string },
512
+ opts?: { signedPrice?: number; tenantId?: string; currency?: string; contentVersionId?: string },
503
513
  ): { code: string; fields: CartField[] } {
504
514
  const code = variant.sku && variant.sku.length ? variant.sku : variant.id;
505
515
  const image = product.images.find((i) => i.id === variant.image_id)?.url ??
@@ -527,6 +537,12 @@ export function cartFieldsForVariant(
527
537
  if (opts?.tenantId) {
528
538
  fields.push({ name: "tenant", value: opts.tenantId });
529
539
  }
540
+ if (opts?.contentVersionId) {
541
+ if (!isContentVersionId(opts.contentVersionId)) {
542
+ throw new Error("cartFieldsForVariant: content version id is not a rendered pin");
543
+ }
544
+ fields.push({ name: CONTENT_VERSION_CART_FIELD, value: opts.contentVersionId });
545
+ }
530
546
  if (image) fields.push({ name: "image", value: image });
531
547
  // Variant options (Flavor, Nicotine strength, …) travel as product options so
532
548
  // the cart line reflects the exact variant the shopper picked.
@@ -728,7 +744,7 @@ export async function buildVariantCartPayload(
728
744
  product,
729
745
  variant,
730
746
  opts.purchaseOption,
731
- { signedPrice: opts.signedPrice, tenantId: opts.tenantId, currency: opts.currency },
747
+ { signedPrice: opts.signedPrice, tenantId: opts.tenantId, currency: opts.currency, contentVersionId: opts.contentVersionId },
732
748
  );
733
749
  const signed = opts.mode === "signed";
734
750
  if (signed && !opts.secret) {
@@ -280,6 +280,8 @@ export interface MemberSignedCartPayloadOptions {
280
280
  tenantId?: string;
281
281
  /** The store's currency, signed into the cart (see {@link BuildVariantCartPayloadOptions.currency}). */
282
282
  currency?: string;
283
+ /** Candidate content pin the preview page rendered. Omitted for publication and the shared channel. */
284
+ contentVersionId?: string;
283
285
  }
284
286
 
285
287
  /**
@@ -315,6 +317,7 @@ export async function buildMemberSignedCartPayload(
315
317
  autopilotEnabled: opts.autopilotEnabled,
316
318
  tenantId: opts.tenantId,
317
319
  currency: opts.currency,
320
+ contentVersionId: opts.contentVersionId,
318
321
  });
319
322
  }
320
323