@tokenoftrust/storefront-runner 2.5.5 → 3.0.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.
- package/agency-kit/tools/lib/public-binaries.mjs +95 -0
- package/apps/storefront/astro.config.mjs +7 -0
- package/apps/storefront/env.d.ts +3 -0
- package/apps/storefront/src/components/catalog/storefront/ProductCard.astro +5 -4
- package/apps/storefront/src/components/catalog/storefront/commerce/PriceDisplay.astro +2 -1
- package/apps/storefront/src/components/catalog/storefront/islands/ImageGallery.tsx +20 -3
- package/apps/storefront/src/components/catalog/storefront/islands/SearchTypeahead.tsx +17 -20
- package/apps/storefront/src/components/catalog/storefront/islands/VariantSelector.tsx +13 -21
- package/apps/storefront/src/components/catalog/storefront/plp/Pagination.astro +9 -5
- package/apps/storefront/src/components/checkout/storefront/commerce/CheckoutLoader.astro +18 -6
- package/apps/storefront/src/components/compliance/storefront/islands/CheckoutComplianceGate.tsx +19 -4
- package/apps/storefront/src/components/content/storefront/Badge.astro +1 -0
- package/apps/storefront/src/components/content/storefront/blog/BlogIndexView.astro +10 -1
- package/apps/storefront/src/components/publication/storefront/islands/CatalogQueryView.tsx +321 -0
- package/apps/storefront/src/config/declaredTenantConfig.ts +59 -27
- package/apps/storefront/src/config/devTenantSeed.ts +2 -1
- package/apps/storefront/src/layouts/Layout.astro +1 -1
- package/apps/storefront/src/lib/access/auth/gatePage.ts +29 -14
- package/apps/storefront/src/lib/access/shared/publicAccess.ts +95 -0
- package/apps/storefront/src/lib/access/shared/tenantRouting.ts +19 -0
- package/apps/storefront/src/lib/catalog/shared/catalogIndex.ts +109 -0
- package/apps/storefront/src/lib/catalog/shared/catalogQueryView.ts +60 -0
- package/apps/storefront/src/lib/catalog/shared/collectionPaging.ts +78 -0
- package/apps/storefront/src/lib/catalog/shared/stockStatus.ts +23 -0
- package/apps/storefront/src/lib/catalog/tot/query.ts +8 -0
- package/apps/storefront/src/lib/checkout/browser/checkoutRequirementsForm.ts +54 -0
- package/apps/storefront/src/lib/checkout/commerce/checkoutHandoffRoute.ts +103 -35
- package/apps/storefront/src/lib/checkout/commerce/checkoutRequirements.ts +88 -0
- package/apps/storefront/src/lib/checkout/commerce/finalCartAuthorization.ts +2 -0
- package/apps/storefront/src/lib/checkout/commerce/sharedStoreAdmission.ts +95 -0
- package/apps/storefront/src/lib/checkout/shared/lazyCheckoutLoader.ts +191 -0
- package/apps/storefront/src/lib/compliance/browser/checkoutVerificationFlow.ts +9 -0
- package/apps/storefront/src/lib/compliance/enforcement.ts +7 -5
- package/apps/storefront/src/lib/content/seo/structuredData.ts +12 -3
- package/apps/storefront/src/lib/content/storyblok/provider.ts +14 -7
- package/apps/storefront/src/lib/observability/shared/serverTiming.ts +110 -0
- package/apps/storefront/src/lib/publication/shared/edgeCache.ts +3 -4
- package/apps/storefront/src/lib/publication/shared/staticQuery.ts +64 -0
- package/apps/storefront/src/middleware/controlPlane.ts +14 -11
- package/apps/storefront/src/middleware/index.ts +33 -12
- package/apps/storefront/src/middleware/renderChain.ts +52 -26
- package/apps/storefront/src/pages/[...slug].astro +3 -0
- package/apps/storefront/src/pages/api/commerce/final-cart-authorization.ts +112 -42
- package/apps/storefront/src/pages/api/compliance/preflight.ts +61 -13
- package/apps/storefront/src/pages/blog/author/[author].astro +1 -1
- package/apps/storefront/src/pages/blog/category/[category].astro +1 -1
- package/apps/storefront/src/pages/blog/tag/[tag].astro +1 -1
- package/apps/storefront/src/pages/catalog-index.json.ts +20 -0
- package/apps/storefront/src/pages/collections/[handle]/page/[n].astro +28 -0
- package/apps/storefront/src/pages/collections/[handle].astro +13 -198
- package/apps/storefront/src/pages/collections/_CollectionView.astro +251 -0
- package/apps/storefront/src/pages/collections/index.astro +1 -1
- package/apps/storefront/src/pages/products/[handle].astro +18 -8
- package/apps/storefront/src/pages/search.astro +33 -33
- package/apps/storefront/src/pages/sitemap.xml.ts +7 -1
- package/apps/storefront/vitest.config.cts +3 -0
- package/package.json +1 -1
- package/packages/cli/src/declared-config.mjs +318 -32
- package/packages/cli/src/publication-spaces.mjs +1 -1
- package/packages/public-runtime/package.json +1 -0
- package/packages/public-runtime/schemas/publication-space/v1/manifest.schema.json +1 -1
- package/packages/public-runtime/schemas/publication-space/v1/normalized-mount.schema.json +1 -1
- package/packages/public-runtime/src/activity/catalog.ts +8 -1
- package/packages/public-runtime/src/candidate-index.ts +5 -0
- package/packages/public-runtime/src/capabilities.ts +12 -9
- package/packages/public-runtime/src/capture-rewrites.ts +687 -0
- package/packages/public-runtime/src/checkout-orders.ts +6 -7
- package/packages/public-runtime/src/checkout.ts +190 -6
- package/packages/public-runtime/src/compliance/evaluate.ts +7 -3
- package/packages/public-runtime/src/compliance/profile.ts +90 -14
- package/packages/public-runtime/src/compliance-coherence.d.mts +10 -1
- package/packages/public-runtime/src/compliance-coherence.mjs +129 -29
- package/packages/public-runtime/src/customization-runtime.ts +64 -2
- package/packages/public-runtime/src/declared-tenant-config.d.mts +13 -2
- package/packages/public-runtime/src/declared-tenant-config.mjs +83 -1
- package/packages/public-runtime/src/image-preview-rewrite-overlay.ts +13 -37
- package/packages/public-runtime/src/membership.ts +3 -0
- package/packages/public-runtime/src/publication-space.ts +1 -0
- package/packages/public-runtime/src/tenant.ts +28 -4
- package/scripts/tenant/validate.mjs +17 -8
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The static catalog index (`/catalog-index.json`): every public product in the
|
|
3
|
+
* fields listing, faceting, search and a compact card read, plus each public
|
|
4
|
+
* collection's declared membership. It is emitted once per publish, so a static
|
|
5
|
+
* host answers sort, facet and search queries in the browser with the same
|
|
6
|
+
* engine the server uses (lib/catalog/tot/query.ts, lib/catalog/search/index.ts)
|
|
7
|
+
* — no origin compute on the public path (ADR 0013).
|
|
8
|
+
*/
|
|
9
|
+
import type { CatalogCollection, CatalogProduct } from "@tot/public-runtime";
|
|
10
|
+
|
|
11
|
+
export const CATALOG_INDEX_PATH = "/catalog-index.json";
|
|
12
|
+
export const CATALOG_INDEX_VERSION = 1;
|
|
13
|
+
|
|
14
|
+
/** Description characters kept for search: enough to rank on, small enough to ship. */
|
|
15
|
+
const DESCRIPTION_CHARS = 280;
|
|
16
|
+
|
|
17
|
+
export interface CatalogIndexVariant {
|
|
18
|
+
price: number;
|
|
19
|
+
compare_at_price?: number;
|
|
20
|
+
option_values: Record<string, string>;
|
|
21
|
+
inventory: { available: boolean };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface CatalogIndexProduct {
|
|
25
|
+
id: string;
|
|
26
|
+
handle: string;
|
|
27
|
+
title: string;
|
|
28
|
+
vendor?: string;
|
|
29
|
+
product_type?: string;
|
|
30
|
+
description_text: string;
|
|
31
|
+
tags: string[];
|
|
32
|
+
status: CatalogProduct["status"];
|
|
33
|
+
options: { name: string }[];
|
|
34
|
+
variants: CatalogIndexVariant[];
|
|
35
|
+
price_range: CatalogProduct["price_range"];
|
|
36
|
+
collections: string[];
|
|
37
|
+
created_at: string;
|
|
38
|
+
image?: { url: string; alt?: string; rehosted?: boolean; id?: string };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface CatalogIndexCollection {
|
|
42
|
+
handle: string;
|
|
43
|
+
product_handles: string[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface CatalogIndex {
|
|
47
|
+
version: number;
|
|
48
|
+
products: CatalogIndexProduct[];
|
|
49
|
+
collections: CatalogIndexCollection[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function toCatalogIndexProduct(p: CatalogProduct): CatalogIndexProduct {
|
|
53
|
+
const doc: CatalogIndexProduct = {
|
|
54
|
+
id: p.id,
|
|
55
|
+
handle: p.handle,
|
|
56
|
+
title: p.title,
|
|
57
|
+
description_text: (p.description_text ?? "").slice(0, DESCRIPTION_CHARS),
|
|
58
|
+
tags: p.tags,
|
|
59
|
+
status: p.status,
|
|
60
|
+
options: p.options.map((o) => ({ name: o.name })),
|
|
61
|
+
variants: p.variants.map((v) => ({
|
|
62
|
+
price: v.price,
|
|
63
|
+
...(v.compare_at_price != null ? { compare_at_price: v.compare_at_price } : {}),
|
|
64
|
+
option_values: v.option_values,
|
|
65
|
+
inventory: { available: v.inventory.available },
|
|
66
|
+
})),
|
|
67
|
+
price_range: p.price_range,
|
|
68
|
+
collections: p.collections,
|
|
69
|
+
created_at: p.created_at,
|
|
70
|
+
};
|
|
71
|
+
if (p.vendor) doc.vendor = p.vendor;
|
|
72
|
+
if (p.product_type) doc.product_type = p.product_type;
|
|
73
|
+
const img = p.images[0];
|
|
74
|
+
if (img?.url) {
|
|
75
|
+
doc.image = { url: img.url };
|
|
76
|
+
if (img.alt) doc.image.alt = img.alt;
|
|
77
|
+
if (img.rehosted) doc.image.rehosted = img.rehosted;
|
|
78
|
+
if (img.id) doc.image.id = img.id;
|
|
79
|
+
}
|
|
80
|
+
return doc;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The index for a catalog's active products (in catalog order) and its public collections. */
|
|
84
|
+
export function buildCatalogIndex(
|
|
85
|
+
activeProducts: CatalogProduct[],
|
|
86
|
+
collections: CatalogCollection[],
|
|
87
|
+
): CatalogIndex {
|
|
88
|
+
return {
|
|
89
|
+
version: CATALOG_INDEX_VERSION,
|
|
90
|
+
products: activeProducts.map(toCatalogIndexProduct),
|
|
91
|
+
collections: collections.map((c) => ({ handle: c.handle, product_handles: c.product_handles })),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* An index product in the shape the shared query/search engines read. They touch
|
|
97
|
+
* only the fields the index carries; everything else is absent by construction.
|
|
98
|
+
*/
|
|
99
|
+
export function asCatalogProduct(doc: CatalogIndexProduct): CatalogProduct {
|
|
100
|
+
return {
|
|
101
|
+
...doc,
|
|
102
|
+
images: doc.image ? [doc.image] : [],
|
|
103
|
+
} as unknown as CatalogProduct;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** An index collection in the shape `collectionProducts` reads. */
|
|
107
|
+
export function asCatalogCollection(doc: CatalogIndexCollection): CatalogCollection {
|
|
108
|
+
return doc as unknown as CatalogCollection;
|
|
109
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Listing and search over the static catalog index (`/catalog-index.json`), with
|
|
3
|
+
* the same engines the server uses, for a page whose query no renderer saw (a
|
|
4
|
+
* static host serves one document per path). Pure apart from the one fetch —
|
|
5
|
+
* the island owns the DOM, lib/publication/shared/staticQuery.ts owns the URL.
|
|
6
|
+
*/
|
|
7
|
+
import type { ListProductsQuery, ListProductsResponse } from "@tot/public-runtime";
|
|
8
|
+
import { applyQuery, buildFacets, collectionProducts, paginate } from "../tot/query.js";
|
|
9
|
+
import { buildSearchIndex } from "../search/index.js";
|
|
10
|
+
import {
|
|
11
|
+
asCatalogCollection,
|
|
12
|
+
asCatalogProduct,
|
|
13
|
+
CATALOG_INDEX_PATH,
|
|
14
|
+
type CatalogIndex,
|
|
15
|
+
type CatalogIndexProduct,
|
|
16
|
+
} from "./catalogIndex.js";
|
|
17
|
+
|
|
18
|
+
export { bucketByKey } from "./priceBuckets.js";
|
|
19
|
+
|
|
20
|
+
/** Results per search page — matches the server render of /search. */
|
|
21
|
+
export const SEARCH_RESULT_LIMIT = 24;
|
|
22
|
+
|
|
23
|
+
/** A collection's listing over the index — the FixtureToTClient.listProducts semantics. */
|
|
24
|
+
export function collectionViewFromIndex(
|
|
25
|
+
index: CatalogIndex,
|
|
26
|
+
handle: string,
|
|
27
|
+
query: ListProductsQuery,
|
|
28
|
+
): ListProductsResponse {
|
|
29
|
+
const active = index.products.map(asCatalogProduct);
|
|
30
|
+
const collection = index.collections.find((c) => c.handle === handle);
|
|
31
|
+
const pool = collection ? collectionProducts(active, asCatalogCollection(collection)) : [];
|
|
32
|
+
const facets = buildFacets(pool, query);
|
|
33
|
+
const { items, page, pageSize, total } = paginate(applyQuery(pool, query, true), query);
|
|
34
|
+
return { products: items, page, pageSize, total, facets };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Search results over the index — the FixtureToTClient.search semantics. */
|
|
38
|
+
export function searchFromIndex(index: CatalogIndex, q: string, limit = SEARCH_RESULT_LIMIT): CatalogIndexProduct[] {
|
|
39
|
+
const byHandle = new Map(index.products.map((p) => [p.handle, p]));
|
|
40
|
+
return buildSearchIndex(index.products.map(asCatalogProduct))
|
|
41
|
+
.search(q, limit)
|
|
42
|
+
.map((hit) => byHandle.get(hit.product.handle)!)
|
|
43
|
+
.filter(Boolean);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let indexRequest: Promise<CatalogIndex> | null = null;
|
|
47
|
+
|
|
48
|
+
/** Fetch the tenant's catalog index once per page (`base` is the tenant's client base path). */
|
|
49
|
+
export function loadCatalogIndex(base: string): Promise<CatalogIndex> {
|
|
50
|
+
indexRequest ??= fetch(`${base.replace(/\/+$/, "")}${CATALOG_INDEX_PATH}`, {
|
|
51
|
+
headers: { Accept: "application/json" },
|
|
52
|
+
}).then((res) => {
|
|
53
|
+
if (!res.ok) throw new Error(`catalog index: HTTP ${res.status}`);
|
|
54
|
+
return res.json() as Promise<CatalogIndex>;
|
|
55
|
+
});
|
|
56
|
+
indexRequest.catch(() => {
|
|
57
|
+
indexRequest = null;
|
|
58
|
+
});
|
|
59
|
+
return indexRequest;
|
|
60
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collection (PLP) paging — shared by the server render, the client query view,
|
|
3
|
+
* and the sitemap so the three can't drift.
|
|
4
|
+
*
|
|
5
|
+
* A public page is served as one prerendered document per PATH on both CDNs
|
|
6
|
+
* (ADR 0013), so the query string never reaches a renderer there. Two strategies
|
|
7
|
+
* follow from "what is N?":
|
|
8
|
+
* - paging is N-bounded and indexable → a path variant per page,
|
|
9
|
+
* `/collections/<h>/page/<n>/` (page 1 lives at the bare collection path);
|
|
10
|
+
* - sort / facets / price / availability / inline search are unbounded and
|
|
11
|
+
* noindex → the URL keeps its query and the client applies it over the static
|
|
12
|
+
* catalog index (`/catalog-index.json`) whenever the render did not
|
|
13
|
+
* (lib/publication/shared/staticQuery.ts).
|
|
14
|
+
*/
|
|
15
|
+
import type { CatalogCollection, ListProductsQuery, ListProductsResponse } from "@tot/public-runtime";
|
|
16
|
+
import type { ToTClient } from "../tot/totClientInterface.js";
|
|
17
|
+
|
|
18
|
+
/** Products per collection page. */
|
|
19
|
+
export const COLLECTION_PAGE_SIZE = 9;
|
|
20
|
+
|
|
21
|
+
/** Path of collection page `n` (unformed: the caller applies the tenant's URL form). */
|
|
22
|
+
export function collectionPagePath(handle: string, n: number): string {
|
|
23
|
+
return n > 1 ? `/collections/${handle}/page/${n}` : `/collections/${handle}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Number of pages a collection of `total` products spans (at least 1). */
|
|
27
|
+
export function collectionPageCount(total: number, pageSize = COLLECTION_PAGE_SIZE): number {
|
|
28
|
+
return Math.max(1, Math.ceil(total / pageSize));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The listing query a collection view applies: the URL's own (parsed) params,
|
|
33
|
+
* the path page when the query names none, and the default page size.
|
|
34
|
+
*/
|
|
35
|
+
export function withPathPage(query: ListProductsQuery, pathPage?: number): ListProductsQuery {
|
|
36
|
+
const out = { ...query };
|
|
37
|
+
if (out.page == null && pathPage != null && pathPage > 1) out.page = pathPage;
|
|
38
|
+
if (!out.pageSize) out.pageSize = COLLECTION_PAGE_SIZE;
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Is this view expressible as a path variant — nothing but a page number at the
|
|
44
|
+
* default page size? Only those views get path pager links; any other view keeps
|
|
45
|
+
* its query and is applied client-side on a static host.
|
|
46
|
+
*/
|
|
47
|
+
export function isPathPageable(query: ListProductsQuery): boolean {
|
|
48
|
+
return (
|
|
49
|
+
(!query.sort || query.sort === "featured") &&
|
|
50
|
+
!query.q &&
|
|
51
|
+
!query.filters &&
|
|
52
|
+
!query.tags?.length &&
|
|
53
|
+
query.priceMin == null &&
|
|
54
|
+
query.priceMax == null &&
|
|
55
|
+
(!query.availability || query.availability === "all") &&
|
|
56
|
+
(!query.pageSize || query.pageSize === COLLECTION_PAGE_SIZE)
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface CollectionListing {
|
|
61
|
+
collection: CatalogCollection;
|
|
62
|
+
result: ListProductsResponse;
|
|
63
|
+
/** Active members with no filters applied: 0 marks a synthesized-empty collection. */
|
|
64
|
+
memberTotal: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A public collection's listing for `query`, or null when the collection is absent or hidden. */
|
|
68
|
+
export async function loadCollectionListing(
|
|
69
|
+
tot: ToTClient,
|
|
70
|
+
handle: string,
|
|
71
|
+
query: ListProductsQuery,
|
|
72
|
+
): Promise<CollectionListing | null> {
|
|
73
|
+
const collection = await tot.getCollectionByHandle(handle);
|
|
74
|
+
if (!collection || collection.visibility === "hidden") return null;
|
|
75
|
+
const result = await tot.listProducts({ ...query, collection: handle });
|
|
76
|
+
const { total: memberTotal } = await tot.listProducts({ collection: handle, pageSize: 1 });
|
|
77
|
+
return { collection, result, memberTotal };
|
|
78
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The availability line a variant earns on the PDP. Only what the catalog actually knows is
|
|
3
|
+
* claimed: "In stock" and "Only N left" need a tracked quantity behind them, so a variant with no
|
|
4
|
+
* inventory tracking says nothing (like a source that shows no stock text). "Sold out" needs
|
|
5
|
+
* only `available: false`.
|
|
6
|
+
*/
|
|
7
|
+
export const LOW_STOCK_THRESHOLD = 5;
|
|
8
|
+
|
|
9
|
+
export interface StockStatus {
|
|
10
|
+
state: "sold-out" | "low-stock" | "in-stock";
|
|
11
|
+
label: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function stockStatus(inventory: {
|
|
15
|
+
available: boolean;
|
|
16
|
+
quantity?: number | null;
|
|
17
|
+
}): StockStatus | undefined {
|
|
18
|
+
if (!inventory.available) return { state: "sold-out", label: "Sold out" };
|
|
19
|
+
const qty = inventory.quantity;
|
|
20
|
+
if (qty === undefined || qty === null) return undefined;
|
|
21
|
+
if (qty <= LOW_STOCK_THRESHOLD) return { state: "low-stock", label: `Only ${qty} left` };
|
|
22
|
+
return { state: "in-stock", label: "In stock" };
|
|
23
|
+
}
|
|
@@ -27,10 +27,18 @@ export function isAvailable(p: CatalogProduct): boolean {
|
|
|
27
27
|
return p.variants.some((v) => v.inventory.available);
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* "New" is the product's real age. The `new` tag is the merchant's explicit call. Otherwise a
|
|
32
|
+
* product is new only when it has a known creation date inside the window: a record with no
|
|
33
|
+
* creation date, or whose `created_at` is no earlier than its `migrated_at` (the import stamped
|
|
34
|
+
* it, so it says when the record arrived, not when the product was created), is not new.
|
|
35
|
+
*/
|
|
30
36
|
export function isNew(p: CatalogProduct, days = 60): boolean {
|
|
31
37
|
if (p.tags.includes("new")) return true;
|
|
32
38
|
const created = Date.parse(p.created_at);
|
|
33
39
|
if (Number.isNaN(created)) return false;
|
|
40
|
+
const migrated = Date.parse(p.migrated_at);
|
|
41
|
+
if (!Number.isNaN(migrated) && created >= migrated) return false;
|
|
34
42
|
return Date.now() - created < days * 24 * 60 * 60 * 1000;
|
|
35
43
|
}
|
|
36
44
|
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** Browser wiring for the /admin "Checkout requirements" form: collect the switches, POST, report. */
|
|
2
|
+
|
|
3
|
+
type StatusState = "ok" | "err";
|
|
4
|
+
|
|
5
|
+
interface SaveResponse {
|
|
6
|
+
notice?: string;
|
|
7
|
+
error?: string;
|
|
8
|
+
errors?: unknown;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function saveFailureMessage(status: number, data: SaveResponse): string {
|
|
12
|
+
if (Array.isArray(data.errors) && data.errors.length) return data.errors.join(" ");
|
|
13
|
+
return data.error || `Save failed (${status})`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function mountCheckoutRequirementsForm(form: HTMLElement): void {
|
|
17
|
+
const endpoint = form.dataset.endpoint || "/api/preview/checkout-requirements";
|
|
18
|
+
const targetTenant = form.dataset.targetTenant || "";
|
|
19
|
+
const status = form.querySelector<HTMLElement>("[data-checkout-requirements-status]");
|
|
20
|
+
const saveBtn = form.querySelector<HTMLButtonElement>("[data-checkout-requirements-save]");
|
|
21
|
+
const setStatus = (message: string, state?: StatusState) => {
|
|
22
|
+
if (!status) return;
|
|
23
|
+
status.textContent = message;
|
|
24
|
+
if (state) status.dataset.state = state;
|
|
25
|
+
else delete status.dataset.state;
|
|
26
|
+
};
|
|
27
|
+
saveBtn?.addEventListener("click", async () => {
|
|
28
|
+
const body: Record<string, unknown> = targetTenant ? { targetTenant } : {};
|
|
29
|
+
for (const input of form.querySelectorAll<HTMLInputElement>("[data-cr-field]")) {
|
|
30
|
+
body[input.dataset.crField ?? ""] = input.checked;
|
|
31
|
+
}
|
|
32
|
+
setStatus("Saving…");
|
|
33
|
+
saveBtn.disabled = true;
|
|
34
|
+
try {
|
|
35
|
+
const res = await fetch(endpoint, {
|
|
36
|
+
method: "POST",
|
|
37
|
+
headers: { "content-type": "application/json" },
|
|
38
|
+
body: JSON.stringify(body),
|
|
39
|
+
});
|
|
40
|
+
let data: SaveResponse = {};
|
|
41
|
+
try {
|
|
42
|
+
data = (await res.json()) as SaveResponse;
|
|
43
|
+
} catch {
|
|
44
|
+
// non-JSON error body
|
|
45
|
+
}
|
|
46
|
+
if (res.ok) setStatus(data.notice || "Saved to test", "ok");
|
|
47
|
+
else setStatus(saveFailureMessage(res.status, data), "err");
|
|
48
|
+
} catch {
|
|
49
|
+
setStatus("Save failed — network error", "err");
|
|
50
|
+
} finally {
|
|
51
|
+
saveBtn.disabled = false;
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
}
|
|
@@ -26,14 +26,20 @@ import {
|
|
|
26
26
|
buildVariantCartPayload,
|
|
27
27
|
commerceTenantId,
|
|
28
28
|
MAX_CART_QUANTITY,
|
|
29
|
+
SAMPLE_CART_HANDOFF_URL,
|
|
30
|
+
takeVerificationRef,
|
|
31
|
+
withVerificationRef,
|
|
32
|
+
withShippingPolicy,
|
|
33
|
+
SHIPPING_UNAVAILABLE_CART_MESSAGE,
|
|
34
|
+
ShippingPolicyUnavailableError,
|
|
29
35
|
type CartDenialCode,
|
|
36
|
+
type CartSubmissionField,
|
|
30
37
|
type CatalogProduct,
|
|
31
38
|
type TenantConfig,
|
|
32
39
|
type ResolvedManagedCheckout,
|
|
33
40
|
} from "@tot/public-runtime";
|
|
34
41
|
import { resolveCheckoutHandoffCorrelation } from "@/lib/checkout/commerce/checkoutHandoffCorrelation";
|
|
35
42
|
import { resolveCartSigning, shopperCartDenialMessage } from "@/lib/checkout/shared/checkoutCommerce";
|
|
36
|
-
import { readEnv } from "@/lib/publication/shared/runtimeEnv";
|
|
37
43
|
|
|
38
44
|
export type SampleProductReader = (tenant: TenantConfig, handle: string, commerce: ResolvedManagedCheckout) => Promise<CatalogProduct | null>;
|
|
39
45
|
|
|
@@ -83,8 +89,27 @@ function isCartForm(request: Request): boolean {
|
|
|
83
89
|
);
|
|
84
90
|
}
|
|
85
91
|
|
|
92
|
+
/**
|
|
93
|
+
* The cart with the trusted tenant's declared shipping policy bound in, or `null` when that
|
|
94
|
+
* policy cannot be charged in the cart's currency: the cart is refused rather than sent on
|
|
95
|
+
* without the charge the store declared.
|
|
96
|
+
*/
|
|
97
|
+
async function boundShippingPolicy(
|
|
98
|
+
fields: readonly CartSubmissionField[],
|
|
99
|
+
tenant: TenantConfig,
|
|
100
|
+
mode: ResolvedManagedCheckout["mode"],
|
|
101
|
+
secret: string | undefined,
|
|
102
|
+
): Promise<CartSubmissionField[] | null> {
|
|
103
|
+
try {
|
|
104
|
+
return await withShippingPolicy(fields, { mode, secret, policy: tenant.shipping });
|
|
105
|
+
} catch (err) {
|
|
106
|
+
if (err instanceof ShippingPolicyUnavailableError) return null;
|
|
107
|
+
throw err;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
86
111
|
/** Native cart POST. Browser preflight is not consulted. Payment is never authorized. */
|
|
87
|
-
async function cartFormPost(context: APIContext
|
|
112
|
+
async function cartFormPost(context: APIContext): Promise<Response> {
|
|
88
113
|
const { tenant, commerce } = context.locals;
|
|
89
114
|
if (!commerce) {
|
|
90
115
|
return cartDenial(
|
|
@@ -102,38 +127,13 @@ async function cartFormPost(context: APIContext, readSampleProduct?: SampleProdu
|
|
|
102
127
|
);
|
|
103
128
|
}
|
|
104
129
|
const form = await context.request.formData();
|
|
105
|
-
const
|
|
130
|
+
const submitted: Array<[string, string]> = [];
|
|
106
131
|
for (const [name, value] of form.entries()) {
|
|
107
132
|
if (typeof value !== "string") return cartDenial("signature", "Invalid cart.", 422);
|
|
108
|
-
|
|
109
|
-
}
|
|
110
|
-
if (form.has("sample_product") || form.has("sample_variant")) {
|
|
111
|
-
const sampleTenant = await readEnv("COMMERCE_SAMPLE_TENANT_ID");
|
|
112
|
-
if (
|
|
113
|
-
!sampleTenant || sampleTenant !== commerceTenantId(tenant) ||
|
|
114
|
-
await readEnv("COMMERCE_DEMO_CHECKOUT_TIER") !== "test" || commerce.mode !== "signed"
|
|
115
|
-
) return cartDenial("tenant", "Invalid cart.", 422);
|
|
116
|
-
const allowed = new Set(["sample_product", "sample_variant", "quantity"]);
|
|
117
|
-
if (entries.length !== 3 || entries.some(([name]) => !allowed.has(name)) ||
|
|
118
|
-
new Set(entries.map(([name]) => name)).size !== 3) {
|
|
119
|
-
return cartDenial("signature", "Invalid cart.", 422);
|
|
120
|
-
}
|
|
121
|
-
const quantity = form.get("quantity") as string;
|
|
122
|
-
if (!/^[1-9][0-9]*$/.test(quantity) || !Number.isSafeInteger(Number(quantity)) || Number(quantity) > MAX_CART_QUANTITY) {
|
|
123
|
-
return cartDenial("quantity", "Invalid quantity.", 422);
|
|
124
|
-
}
|
|
125
|
-
const product = await readSampleProduct?.(tenant, form.get("sample_product") as string, commerce) ?? null;
|
|
126
|
-
const variant = product?.variants.find((v) => v.id === form.get("sample_variant"));
|
|
127
|
-
if (!product || product.status !== "active" || product.visibility === "hidden" || product.visibility === "unlisted" ||
|
|
128
|
-
!variant || !variant.inventory.available || product.tenant_id !== tenant.tot_data_scope ||
|
|
129
|
-
variant.tenant_id !== tenant.tot_data_scope) {
|
|
130
|
-
return cartDenial("signature", "Product is unavailable.", 422);
|
|
131
|
-
}
|
|
132
|
-
const payload = await buildVariantCartPayload(product, variant, {
|
|
133
|
-
mode: "signed", secret: signing.secret, tenantId: commerceTenantId(tenant),
|
|
134
|
-
});
|
|
135
|
-
entries.splice(0, entries.length, ...payload.hidden.map((f): [string, string] => [f.name, f.value]), [payload.quantityName, quantity]);
|
|
133
|
+
submitted.push([name, value]);
|
|
136
134
|
}
|
|
135
|
+
// The compliance gate adds the shopper's verification ref; it rides to the cart signed.
|
|
136
|
+
const { entries, ref } = takeVerificationRef(submitted);
|
|
137
137
|
const result = await enforceCartSubmission({
|
|
138
138
|
entries,
|
|
139
139
|
mode: commerce.mode,
|
|
@@ -144,18 +144,21 @@ async function cartFormPost(context: APIContext, readSampleProduct?: SampleProdu
|
|
|
144
144
|
const status = result.code === "signing-unavailable" ? 503 : 422;
|
|
145
145
|
return cartDenial(result.code, shopperCartDenialMessage(result.code, result.message), status);
|
|
146
146
|
}
|
|
147
|
+
const verified = await withVerificationRef(result.fields, { mode: commerce.mode, secret: signing.secret, ref });
|
|
148
|
+
const shipped = await boundShippingPolicy(verified, tenant, commerce.mode, signing.secret);
|
|
149
|
+
if (!shipped) return cartDenial("shipping", SHIPPING_UNAVAILABLE_CART_MESSAGE, 409);
|
|
147
150
|
return new Response(null, {
|
|
148
151
|
status: 303,
|
|
149
152
|
headers: {
|
|
150
|
-
location: foxyCartRedirectUrl(commerce.cartUrl,
|
|
153
|
+
location: foxyCartRedirectUrl(commerce.cartUrl, shipped),
|
|
151
154
|
"cache-control": "no-store",
|
|
152
155
|
"x-tot-payment-authorized": "false",
|
|
153
156
|
},
|
|
154
157
|
});
|
|
155
158
|
}
|
|
156
159
|
|
|
157
|
-
export async function checkoutHandoffPost(context: APIContext
|
|
158
|
-
if (isCartForm(context.request)) return cartFormPost(context
|
|
160
|
+
export async function checkoutHandoffPost(context: APIContext): Promise<Response> {
|
|
161
|
+
if (isCartForm(context.request)) return cartFormPost(context);
|
|
159
162
|
const { tenant, browserEnvironment, commerce } = context.locals;
|
|
160
163
|
|
|
161
164
|
// No managed checkout on this tenant → nothing to attribute.
|
|
@@ -189,7 +192,72 @@ export async function checkoutHandoffPost(context: APIContext, readSampleProduct
|
|
|
189
192
|
return json({ attributed: false, reason: result.reason });
|
|
190
193
|
}
|
|
191
194
|
|
|
192
|
-
|
|
195
|
+
/** Hosted-only dependencies; the renderer GET below never receives these. */
|
|
196
|
+
export interface SampleCartContext {
|
|
197
|
+
tenant: TenantConfig;
|
|
198
|
+
commerce: ResolvedManagedCheckout;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function sampleResponse(body: string | null, status: number, location?: string): Response {
|
|
202
|
+
return new Response(body, { status, headers: {
|
|
203
|
+
"content-type": "application/json; charset=utf-8",
|
|
204
|
+
"cache-control": "private, no-store",
|
|
205
|
+
"referrer-policy": "no-referrer",
|
|
206
|
+
"x-robots-tag": "noindex, nofollow",
|
|
207
|
+
"x-tot-payment-authorized": "false",
|
|
208
|
+
...(location ? { location } : {}),
|
|
209
|
+
} });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Identifier-only GET: caller state never supplies tenant, catalog or checkout authority. */
|
|
213
|
+
export async function sampleCartGet(
|
|
214
|
+
context: APIContext,
|
|
215
|
+
resolveContext: () => Promise<SampleCartContext | null>,
|
|
216
|
+
readProduct: SampleProductReader,
|
|
217
|
+
): Promise<Response> {
|
|
218
|
+
const url = new URL(context.request.url);
|
|
219
|
+
const canonical = new URL(SAMPLE_CART_HANDOFF_URL);
|
|
220
|
+
const invalid = () => sampleResponse('{"error":"invalid_sample_cart"}', 422);
|
|
221
|
+
if (context.request.method !== "GET" || url.origin !== canonical.origin || url.pathname !== canonical.pathname ||
|
|
222
|
+
(context.request.headers.get("host") ?? url.host) !== canonical.host || url.search.length > 4096) return invalid();
|
|
223
|
+
const entries = [...url.searchParams];
|
|
224
|
+
const allowed = new Set(["sample_product", "sample_variant", "quantity"]);
|
|
225
|
+
if (entries.length !== 3 ||
|
|
226
|
+
entries.some(([key]) => !allowed.has(key)) || new Set(entries.map(([key]) => key)).size !== 3) return invalid();
|
|
227
|
+
const productId = url.searchParams.get("sample_product")!;
|
|
228
|
+
const variantId = url.searchParams.get("sample_variant")!;
|
|
229
|
+
const quantity = url.searchParams.get("quantity")!;
|
|
230
|
+
const safeId = (value: string) => value.length > 0 && value.length <= 256 &&
|
|
231
|
+
!/[\p{Cc}\s/\\%]/u.test(value) && value !== "." && !value.includes("..");
|
|
232
|
+
if (!safeId(productId) || !safeId(variantId) || !/^[1-9][0-9]*$/.test(quantity) ||
|
|
233
|
+
!Number.isSafeInteger(Number(quantity)) || Number(quantity) > MAX_CART_QUANTITY) return invalid();
|
|
234
|
+
try {
|
|
235
|
+
const trusted = await resolveContext();
|
|
236
|
+
if (!trusted) return sampleResponse('{"error":"sample_unavailable"}', 503);
|
|
237
|
+
const { tenant, commerce } = trusted;
|
|
238
|
+
const product = await readProduct(tenant, productId, commerce);
|
|
239
|
+
const variant = product?.variants.find(v => v.id === variantId);
|
|
240
|
+
if (!product || product.status !== "active" || product.visibility === "hidden" || product.visibility === "unlisted" ||
|
|
241
|
+
!variant || !variant.inventory.available || product.tenant_id !== tenant.tot_data_scope ||
|
|
242
|
+
variant.tenant_id !== tenant.tot_data_scope) return invalid();
|
|
243
|
+
const signing = await resolveCartSigning(commerce);
|
|
244
|
+
if (!signing.enabled) return sampleResponse('{"error":"sample_unavailable"}', 503);
|
|
245
|
+
const payload = await buildVariantCartPayload(product, variant, {
|
|
246
|
+
mode: "signed", secret: signing.secret, tenantId: commerceTenantId(tenant), currency: tenant.currency,
|
|
247
|
+
});
|
|
248
|
+
const result = await enforceCartSubmission({
|
|
249
|
+
entries: [...payload.hidden.map((f): [string, string] => [f.name, f.value]), [payload.quantityName, quantity]],
|
|
250
|
+
mode: "signed", secret: signing.secret, trustedTenantId: commerceTenantId(tenant),
|
|
251
|
+
});
|
|
252
|
+
if (!result.ok) return invalid();
|
|
253
|
+
const shipped = await boundShippingPolicy(result.fields, tenant, "signed", signing.secret);
|
|
254
|
+
if (!shipped) return sampleResponse('{"error":"shipping_unavailable"}', 409);
|
|
255
|
+
return sampleResponse(null, 303, foxyCartRedirectUrl(commerce.cartUrl, shipped));
|
|
256
|
+
} catch { return sampleResponse('{"error":"sample_unavailable"}', 503); }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Same `(context)` signature as the hosted page's GET, so the runner's projection typechecks against the middleware call. */
|
|
260
|
+
export async function GET(_context: APIContext): Promise<Response> {
|
|
193
261
|
return json({ error: "method_not_allowed" }, 405);
|
|
194
262
|
}
|
|
195
263
|
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A store's checkout requirements on the shared ToT test store: the three switches in
|
|
3
|
+
* /admin → Settings → Checkout requirements.
|
|
4
|
+
*
|
|
5
|
+
* They are merchant-operated commerce settings (ADR 0035 "operational"), saved as the
|
|
6
|
+
* tenant-owned artifact `tenants/<id>/checkout-requirements.json` on the publish spine — saved
|
|
7
|
+
* to `test`, promoted to `live` with the rest of the store. Every switch defaults off and can
|
|
8
|
+
* only make checkout stricter, so no write can weaken what the platform enforces. They apply
|
|
9
|
+
* only while the store checks out on the shared test store; a store with its own checkout store,
|
|
10
|
+
* and production, keep the full fail-closed contract regardless of what is saved here.
|
|
11
|
+
*/
|
|
12
|
+
import { readPublishedArtifact, type CustomizationEnv } from "@tot/public-runtime";
|
|
13
|
+
import { readKv } from "@/lib/publication/shared/runtimeEnv";
|
|
14
|
+
|
|
15
|
+
export interface CheckoutRequirements {
|
|
16
|
+
/** Require a real ID verification the final-cart authority re-reads server-side. */
|
|
17
|
+
verification: boolean;
|
|
18
|
+
/** Require tax evidence. No producer exists yet, so the save refuses `true`. */
|
|
19
|
+
tax: boolean;
|
|
20
|
+
/** Enforce compliance rules (destination, flavor, purchase limits). */
|
|
21
|
+
rules: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const CHECKOUT_REQUIREMENT_KEYS = ["verification", "tax", "rules"] as const;
|
|
25
|
+
|
|
26
|
+
export const DEFAULT_CHECKOUT_REQUIREMENTS: Readonly<CheckoutRequirements> = Object.freeze({
|
|
27
|
+
verification: false,
|
|
28
|
+
tax: false,
|
|
29
|
+
rules: false,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export function checkoutRequirementsArtifactPath(tenantId: string): string {
|
|
33
|
+
return `tenants/${tenantId}/checkout-requirements.json`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Parse a published artifact. Absent → the defaults. Anything other than an object of the
|
|
38
|
+
* known boolean keys is `null` (unreadable): a reader must then fail closed, never treat a
|
|
39
|
+
* corrupt switch as off.
|
|
40
|
+
*/
|
|
41
|
+
export function parseCheckoutRequirements(raw: string | null): CheckoutRequirements | null {
|
|
42
|
+
if (raw === null) return { ...DEFAULT_CHECKOUT_REQUIREMENTS };
|
|
43
|
+
let parsed: unknown;
|
|
44
|
+
try {
|
|
45
|
+
parsed = JSON.parse(raw);
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
50
|
+
const value = parsed as Record<string, unknown>;
|
|
51
|
+
if (Object.keys(value).some(key => !(CHECKOUT_REQUIREMENT_KEYS as readonly string[]).includes(key))) return null;
|
|
52
|
+
const out = { ...DEFAULT_CHECKOUT_REQUIREMENTS };
|
|
53
|
+
for (const key of CHECKOUT_REQUIREMENT_KEYS) {
|
|
54
|
+
if (value[key] === undefined) continue;
|
|
55
|
+
if (typeof value[key] !== "boolean") return null;
|
|
56
|
+
out[key] = value[key] as boolean;
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type CheckoutRequirementsInput =
|
|
62
|
+
| { ok: true; requirements: CheckoutRequirements }
|
|
63
|
+
| { ok: false; errors: string[] };
|
|
64
|
+
|
|
65
|
+
/** Validate a save from /admin. Tax evidence cannot be required until it has a producer. */
|
|
66
|
+
export function validateCheckoutRequirementsInput(raw: unknown): CheckoutRequirementsInput {
|
|
67
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { ok: false, errors: ["body must be a JSON object"] };
|
|
68
|
+
const body = raw as Record<string, unknown>;
|
|
69
|
+
const errors: string[] = [];
|
|
70
|
+
const requirements = { ...DEFAULT_CHECKOUT_REQUIREMENTS };
|
|
71
|
+
for (const key of CHECKOUT_REQUIREMENT_KEYS) {
|
|
72
|
+
if (typeof body[key] !== "boolean") errors.push(`${key} must be true or false`);
|
|
73
|
+
else requirements[key] = body[key] as boolean;
|
|
74
|
+
}
|
|
75
|
+
if (requirements.tax) errors.push("Tax evidence can't be required yet: it needs excise setup first.");
|
|
76
|
+
return errors.length ? { ok: false, errors } : { ok: true, requirements };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The store's saved requirements for `env`, or `null` when they cannot be read. */
|
|
80
|
+
export async function readCheckoutRequirements(tenantId: string, env: CustomizationEnv): Promise<CheckoutRequirements | null> {
|
|
81
|
+
const kv = await readKv("TENANT_CACHE");
|
|
82
|
+
if (!kv) return null;
|
|
83
|
+
try {
|
|
84
|
+
return parseCheckoutRequirements(await readPublishedArtifact(key => kv.get(key), tenantId, env, checkoutRequirementsArtifactPath(tenantId)));
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -26,6 +26,8 @@ export interface SeamView {
|
|
|
26
26
|
status: "clear" | "absent" | "blocked";
|
|
27
27
|
failedClosed?: boolean;
|
|
28
28
|
codes?: readonly string[];
|
|
29
|
+
/** Flavor labels that triggered a `flavored_prohibited` block, for the refusal. */
|
|
30
|
+
flavors?: readonly string[];
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
export interface FinalCartRequest {
|