@tokenoftrust/storefront-runner 4.1.2 → 4.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/storefront/env.d.ts +6 -0
- package/apps/storefront/src/components/catalog/storefront/ProductCard.astro +2 -1
- package/apps/storefront/src/lib/checkout/commerce/cardCartForm.ts +3 -0
- package/apps/storefront/src/middleware/renderChain.ts +4 -0
- package/apps/storefront/src/pages/api/commerce/final-cart-authorization.ts +14 -7
- package/apps/storefront/src/pages/products/[handle].astro +1 -0
- package/package.json +1 -1
- package/packages/public-runtime/src/checkout-orders.ts +46 -0
- package/packages/public-runtime/src/checkout.ts +20 -4
- package/packages/public-runtime/src/membership.ts +3 -0
package/apps/storefront/env.d.ts
CHANGED
|
@@ -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) ||
|
|
@@ -166,17 +167,23 @@ async function authorize(context: APIContext): Promise<Response> {
|
|
|
166
167
|
const kv = await readKv("TENANT_CACHE");
|
|
167
168
|
const kvGet = async (key: string): Promise<string | null> => kv ? kv.get(key) : null;
|
|
168
169
|
const contentEnv = snapshot.stage === "publication" ? "live" : "test";
|
|
170
|
+
const contentVersionId = snapshot.stage === "preview" && snapshot.contentVersionId ? snapshot.contentVersionId : undefined;
|
|
169
171
|
let tenant: TenantConfig;
|
|
170
|
-
try {
|
|
172
|
+
try {
|
|
173
|
+
tenant = await withDeclaredTenantConfig(registered, kvGet, {
|
|
174
|
+
env: contentEnv,
|
|
175
|
+
...(contentVersionId ? { pinnedVersionId: contentVersionId } : {}),
|
|
176
|
+
});
|
|
177
|
+
} catch (err) {
|
|
171
178
|
if (err instanceof DeclaredTenantConfigUnreadError) return deny("config-unreadable", 503);
|
|
172
179
|
throw err;
|
|
173
180
|
}
|
|
174
|
-
const content =
|
|
181
|
+
const content = contentVersionId
|
|
182
|
+
? createContentProvider(tenant, undefined, { get: kvGet, versionId: contentVersionId })
|
|
183
|
+
: createContentProvider(tenant, { get: kvGet, env: contentEnv });
|
|
175
184
|
const tot = await createToTClient(tenant, await readD1("CATALOG_DB"), content);
|
|
176
185
|
const authorityContext = { ...context, locals: { ...context.locals, tenant, tot, content } } as APIContext;
|
|
177
|
-
|
|
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");
|
|
186
|
+
if (snapshot.providerCartDigest !== await digest(canonicalJson(providerCartDigestMaterial(snapshot)))) return deny("provider-cart-digest");
|
|
180
187
|
let listing: CatalogProduct[];
|
|
181
188
|
try { listing = await tot.getAllActiveProducts(); } catch { return deny("catalog-unavailable", 503); }
|
|
182
189
|
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
|
|
3
|
+
"version": "4.2.1",
|
|
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
|
|