@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
|
@@ -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,
|
|
5
|
+
commerceTenantId, totalsMatch, canonicalJson, shippingMinorFor,
|
|
6
6
|
type CallerToken, type CatalogProduct, type CommerceEvidence, type CommerceEvidenceProvenance,
|
|
7
7
|
type CommerceProviderCartSnapshot, type FinalCartBinding, type TenantConfig,
|
|
8
8
|
} from "@tot/public-runtime";
|
|
@@ -12,14 +12,20 @@ import { evaluateFinalCart, type SeamView } from "@/lib/checkout/commerce/finalC
|
|
|
12
12
|
import { d1CommerceRunner, reserveAllocation } from "@/lib/inventory/allocations.js";
|
|
13
13
|
import { readMirrorStock } from "@/lib/inventory/mirrorStock.js";
|
|
14
14
|
import { admissionMatches, readCommerceAdmission, admissionSnapshotStatement } from "@/lib/checkout/commerce/commerceAdmission.js";
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
import { evaluatePreflight } from "../compliance/preflight.js";
|
|
16
|
+
import {
|
|
17
|
+
admitSharedStoreTenant, testStoreEvidencePolicy, SHARED_STORE_POLICY_REVISION, SHARED_STORE_POLICY_SOURCE,
|
|
18
|
+
SHARED_STORE_VERIFICATION_SOURCE, type SharedStoreEvidencePolicy,
|
|
19
|
+
} from "@/lib/checkout/commerce/sharedStoreAdmission.js";
|
|
20
|
+
import { readCheckoutRequirements } from "@/lib/checkout/commerce/checkoutRequirements.js";
|
|
21
|
+
import { DeclaredTenantConfigUnreadError, withDeclaredTenantConfig } from "@/config/declaredTenantConfig";
|
|
22
|
+
import { resolveStoreCurrency } from "@tot/public-runtime/declared-tenant-config";
|
|
19
23
|
import { createContentProvider } from "@/lib/content/storyblok/provider";
|
|
20
24
|
import { createToTClient } from "@/lib/catalog/tot/ToTClient";
|
|
21
25
|
export const prerender = false;
|
|
22
26
|
const HEX = /^[a-f0-9]{64}$/;
|
|
27
|
+
/** The shopper's verification correlation handle, as the signed `verification` cart option carries it. */
|
|
28
|
+
const VERIFICATION_REF = /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,199}$/;
|
|
23
29
|
function json(body: unknown, status = 200): Response {
|
|
24
30
|
return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" } });
|
|
25
31
|
}
|
|
@@ -46,8 +52,10 @@ function readSnapshot(value: unknown): CommerceProviderCartSnapshot | null {
|
|
|
46
52
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
47
53
|
const v = value as Record<string, unknown>;
|
|
48
54
|
const allowed = ["provider", "providerCartId", "providerCartDigest", "tenantId", "stage", "currency", "lines", "totals", "destinationDigest", "destination"];
|
|
49
|
-
|
|
55
|
+
const optional = ["expectedAuthorizationId", "verificationRef"];
|
|
56
|
+
if (Object.keys(v).some(k => !allowed.includes(k) && !optional.includes(k)) || allowed.some(k => !(k in v))) return null;
|
|
50
57
|
if ("expectedAuthorizationId" in v && (typeof v.expectedAuthorizationId !== "string" || !/^[a-z0-9][a-z0-9-]{0,80}$/.test(v.expectedAuthorizationId))) return null;
|
|
58
|
+
if ("verificationRef" in v && (typeof v.verificationRef !== "string" || !VERIFICATION_REF.test(v.verificationRef))) return null;
|
|
51
59
|
if (v.provider !== "foxycart" || typeof v.providerCartId !== "string" || !/^[a-zA-Z0-9._:-]{1,128}$/.test(v.providerCartId) ||
|
|
52
60
|
typeof v.providerCartDigest !== "string" || !HEX.test(v.providerCartDigest) || typeof v.destinationDigest !== "string" || !HEX.test(v.destinationDigest) ||
|
|
53
61
|
typeof v.tenantId !== "string" || !["preview", "publication"].includes(String(v.stage)) || typeof v.currency !== "string" || !/^[A-Z]{3}$/.test(v.currency) ||
|
|
@@ -64,19 +72,46 @@ function readSnapshot(value: unknown): CommerceProviderCartSnapshot | null {
|
|
|
64
72
|
if (!totalsMatch(snapshot.totals) || snapshot.lines.reduce((sum, line) => sum + line.quantity * line.unitMinor, 0) !== snapshot.totals.subtotalMinor) return null;
|
|
65
73
|
return snapshot;
|
|
66
74
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
75
|
+
/**
|
|
76
|
+
* The compliance seam under the shared test store's policy. When verification is required the
|
|
77
|
+
* cart's verification ref goes to preflight, whose live provider re-reads its real status
|
|
78
|
+
* server-side; `verified` is true only when that read satisfied the requirement.
|
|
79
|
+
*/
|
|
80
|
+
async function seam(context: APIContext, snapshot: CommerceProviderCartSnapshot, selected: ReadonlyMap<string, CatalogProduct>, policy: SharedStoreEvidencePolicy): Promise<SeamView & { verified: boolean }> {
|
|
81
|
+
if (policy.rules === "off" && policy.verification === "not-required") return { status: "clear", verified: false };
|
|
82
|
+
const products = [...selected.values()];
|
|
74
83
|
try {
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
84
|
+
const { decision } = await evaluatePreflight({ locals: { tenant: context.locals.tenant, tot: {
|
|
85
|
+
getProductByHandle: async (handle: string) => selected.get(handle) ?? null,
|
|
86
|
+
} } } as APIContext, {
|
|
87
|
+
lines: snapshot.lines.map(line => ({ handle: products.find(p => p.variants.some(v => v.sku === line.sku))!.handle, sku: line.sku, quantity: line.quantity })),
|
|
88
|
+
destination: snapshot.destination,
|
|
89
|
+
...(policy.verification === "required" && snapshot.verificationRef ? { verificationRef: snapshot.verificationRef } : {}),
|
|
90
|
+
}, { verification: policy.verification, rules: policy.rules });
|
|
91
|
+
const verified = policy.verification === "required" && decision.requirements.ageVerification === "satisfied";
|
|
92
|
+
const blocks = decision.blocks;
|
|
93
|
+
if (blocks.length === 0) return { status: "clear", verified };
|
|
94
|
+
const flavors = [...new Set(blocks.flatMap(block => block.code === "flavored_prohibited" ? block.flavors ?? [] : []))];
|
|
95
|
+
return { status: "blocked", verified, failedClosed: blocks.some(block => block.failedClosed === true), codes: blocks.map(block => block.code),
|
|
96
|
+
...(flavors.length ? { flavors } : {}) };
|
|
97
|
+
} catch { return { status: "absent", verified: false }; }
|
|
98
|
+
}
|
|
99
|
+
function fullCartProduct(value: unknown, handle: string, scope: string): value is CatalogProduct {
|
|
100
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
101
|
+
const product = value as CatalogProduct;
|
|
102
|
+
return product.handle === handle && product.tenant_id === scope && typeof product.id === "string" && !!product.id &&
|
|
103
|
+
product.status === "active" && (product.visibility === undefined || product.visibility === "public") &&
|
|
104
|
+
typeof product.title === "string" && (product.product_type === undefined || typeof product.product_type === "string") &&
|
|
105
|
+
Array.isArray(product.tags) && product.tags.every(tag => typeof tag === "string") &&
|
|
106
|
+
Array.isArray(product.metafields) && product.metafields.every(field => field && typeof field === "object" && !Array.isArray(field) &&
|
|
107
|
+
typeof field.namespace === "string" && typeof field.key === "string" && typeof field.value === "string") &&
|
|
108
|
+
Array.isArray(product.variants) && product.variants.length > 0 && product.variants.every(variant => variant && typeof variant === "object" && !Array.isArray(variant) &&
|
|
109
|
+
variant.tenant_id === scope && variant.product_id === product.id && typeof variant.id === "string" && !!variant.id &&
|
|
110
|
+
(variant.sku === undefined || typeof variant.sku === "string") && Number.isFinite(variant.price) && variant.price >= 0 &&
|
|
111
|
+
typeof variant.currency === "string" && /^[A-Z]{3}$/.test(variant.currency) &&
|
|
112
|
+
variant.option_values && typeof variant.option_values === "object" && !Array.isArray(variant.option_values) &&
|
|
113
|
+
Object.values(variant.option_values).every(option => typeof option === "string") &&
|
|
114
|
+
variant.inventory && typeof variant.inventory === "object" && !Array.isArray(variant.inventory) && typeof variant.inventory.available === "boolean");
|
|
80
115
|
}
|
|
81
116
|
export async function POST(context: APIContext): Promise<Response> {
|
|
82
117
|
if (Number(context.request.headers.get("content-length") ?? 0) > 65536) return deny("malformed-request");
|
|
@@ -102,28 +137,58 @@ export async function POST(context: APIContext): Promise<Response> {
|
|
|
102
137
|
token = { scheme: "asymmetric-jwks", signatureValid: true, kid: header.kid, issuer: payload.iss ?? "", audience,
|
|
103
138
|
expiresAt: new Date(payload.exp * 1000).toISOString(), jti: payload.jti ?? "", tenantId: snapshot.tenantId, stage: snapshot.stage };
|
|
104
139
|
} catch { return deny("signature", 401); }
|
|
105
|
-
const
|
|
106
|
-
if (!
|
|
107
|
-
const registered =
|
|
108
|
-
if (!registered || commerceTenantId(registered) !== snapshot.tenantId) return deny("tenant-unregistered", 401);
|
|
140
|
+
const admission = await admitSharedStoreTenant(snapshot.tenantId);
|
|
141
|
+
if (!admission.ok) return deny(admission.finding, 401);
|
|
142
|
+
const registered = admission.tenant;
|
|
109
143
|
const kv = await readKv("TENANT_CACHE");
|
|
110
144
|
const kvGet = async (key: string): Promise<string | null> => kv ? kv.get(key) : null;
|
|
111
145
|
const contentEnv = snapshot.stage === "publication" ? "live" : "test";
|
|
112
|
-
|
|
146
|
+
let tenant: TenantConfig;
|
|
147
|
+
try { tenant = await withDeclaredTenantConfig(registered, kvGet, { env: contentEnv }); } catch (err) {
|
|
148
|
+
if (err instanceof DeclaredTenantConfigUnreadError) return deny("config-unreadable", 503);
|
|
149
|
+
throw err;
|
|
150
|
+
}
|
|
113
151
|
const content = createContentProvider(tenant, { get: kvGet, env: contentEnv });
|
|
114
152
|
const tot = await createToTClient(tenant, await readD1("CATALOG_DB"), content);
|
|
115
153
|
const authorityContext = { ...context, locals: { ...context.locals, tenant, tot, content } } as APIContext;
|
|
116
154
|
const { tenantId, stage, currency, totals, destinationDigest } = snapshot;
|
|
117
155
|
const lines = [...snapshot.lines].sort((a, b) => a.sku.localeCompare(b.sku) || a.unitMinor - b.unitMinor || a.quantity - b.quantity);
|
|
118
156
|
if (snapshot.providerCartDigest !== await digest(canonicalJson({ tenantId, stage, currency, lines, totals, destinationDigest }))) return deny("provider-cart-digest");
|
|
119
|
-
let
|
|
120
|
-
try {
|
|
157
|
+
let listing: CatalogProduct[];
|
|
158
|
+
try { listing = await tot.getAllActiveProducts(); } catch { return deny("catalog-unavailable", 503); }
|
|
159
|
+
if (!Array.isArray(listing)) return deny("catalog-unavailable", 503);
|
|
160
|
+
const selected = new Map<string, CatalogProduct>();
|
|
161
|
+
if (snapshot.currency !== resolveStoreCurrency(tenant.currency)) return deny("catalog-cart-mismatch");
|
|
121
162
|
for (const line of snapshot.lines) {
|
|
122
|
-
const matches =
|
|
123
|
-
? product.variants.filter(v => v.sku === line.sku).map(variant => ({ product, variant })) : []);
|
|
163
|
+
const matches = listing.flatMap(product => product && product.status === "active" && (!product.visibility || product.visibility === "public") && product.tenant_id === tenant.tot_data_scope && Array.isArray(product.variants)
|
|
164
|
+
? product.variants.filter(v => v && v.sku === line.sku).map(variant => ({ product, variant })) : []);
|
|
165
|
+
if (matches.length !== 1) return deny("catalog-cart-mismatch");
|
|
166
|
+
const match = matches[0]!;
|
|
167
|
+
const handle = match.product.handle;
|
|
168
|
+
if (typeof handle !== "string" || !handle) return deny("catalog-cart-mismatch");
|
|
169
|
+
if (!selected.has(handle)) {
|
|
170
|
+
let detail: CatalogProduct | null;
|
|
171
|
+
try { detail = await tot.getProductByHandle(handle); } catch { return deny("catalog-unavailable", 503); }
|
|
172
|
+
if (!fullCartProduct(detail, handle, tenant.tot_data_scope)) return deny("catalog-cart-mismatch");
|
|
173
|
+
selected.set(handle, detail);
|
|
174
|
+
}
|
|
175
|
+
const detail = selected.get(handle)!;
|
|
176
|
+
if (detail.id !== match.product.id) return deny("catalog-cart-mismatch");
|
|
177
|
+
const variants = detail.variants.filter(variant => variant.sku === line.sku);
|
|
178
|
+
if (variants.length !== 1 || variants[0]!.id !== match.variant.id) return deny("catalog-cart-mismatch");
|
|
179
|
+
const variant = variants[0]!;
|
|
124
180
|
const digits = new Intl.NumberFormat("en", { style: "currency", currency: snapshot.currency }).resolvedOptions().maximumFractionDigits ?? 2;
|
|
125
|
-
const scaled =
|
|
126
|
-
if (
|
|
181
|
+
const scaled = variant.price * 10 ** digits;
|
|
182
|
+
if (!variant.inventory.available || variant.currency !== snapshot.currency || Math.abs(scaled - Math.round(scaled)) > 0.000001 || Math.round(scaled) !== line.unitMinor) return deny("catalog-cart-mismatch");
|
|
183
|
+
}
|
|
184
|
+
const products = [...selected.values()];
|
|
185
|
+
if (snapshot.lines.some(line => products.flatMap(product => product.variants.filter(variant => variant.sku === line.sku)).length !== 1)) return deny("catalog-cart-mismatch");
|
|
186
|
+
// Shipping is re-derived from the store's own declaration, never from the cart: a cart that
|
|
187
|
+
// dropped, replayed or altered its signed `shipping_policy` option was charged another amount.
|
|
188
|
+
if (tenant.shipping) {
|
|
189
|
+
let owed: number;
|
|
190
|
+
try { owed = shippingMinorFor(tenant.shipping, snapshot.currency, snapshot.totals.subtotalMinor); } catch { return deny("shipping-unavailable"); }
|
|
191
|
+
if (snapshot.totals.shippingMinor !== owed) return deny("shipping-changed");
|
|
127
192
|
}
|
|
128
193
|
const database = await readD1("STOREFRONT_APPS_DB");
|
|
129
194
|
if (!database) return deny("store-unconfigured", 503);
|
|
@@ -142,35 +207,40 @@ export async function POST(context: APIContext): Promise<Response> {
|
|
|
142
207
|
const existing = prior ? await readCommerceAdmission(queryable, snapshot.tenantId, snapshot.stage, prior.authorization_id) : null;
|
|
143
208
|
if (prior && (!existing || existing.expiresAt <= now)) return deny("admission-expired");
|
|
144
209
|
if (existing && !admissionMatches(existing, snapshot)) return deny("provider-cart-changed");
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
tenant.commerce?.checkout?.storeSubDomain === "tot-preview" && tenant.commerce.checkout.mode === "signed" &&
|
|
149
|
-
["verification", "tax", "risk"].every(field => ["required", "not-required"].includes(policy.requirements?.[field as "verification" | "tax" | "risk"]));
|
|
150
|
-
const regulated = isRegulatedTenant(tenant) || !!tenant.compliance?.pactAct || !!tenant.compliance?.exciseTax ||
|
|
151
|
-
snapshot.lines.some(line => products.some(p => p.variants.some(v => v.sku === line.sku && ["ends", "e-liquid", "other-nicotine"].includes(deriveProductProfile(p, v).category))));
|
|
152
|
-
if (testPolicy && regulated && (policy.requirements.verification === "not-required" || policy.requirements.tax === "not-required")) return deny("authority-policy-regulated");
|
|
210
|
+
const requirements = await readCheckoutRequirements(tenant.tenant_id, contentEnv);
|
|
211
|
+
if (!requirements) return deny("checkout-requirements-unreadable", 503);
|
|
212
|
+
const policy = testStoreEvidencePolicy(requirements);
|
|
153
213
|
const authorities = [];
|
|
154
214
|
for (const sku of new Set(snapshot.lines.map(line => line.sku))) {
|
|
155
215
|
authorities.push(await readMirrorStock(queryable, { tenantId: snapshot.tenantId, stage: snapshot.stage, sku, locationId: "unspecified", now, maxAgeMs: 15 * 60_000 }));
|
|
156
216
|
}
|
|
157
217
|
const stockExpiry = Math.min(...authorities.flatMap(authority => authority ? [Date.parse(authority.asOf) + 15 * 60_000] : []));
|
|
158
218
|
const evidence: CommerceEvidence = { verification: "absent", tax: "absent", risk: "absent", stock: "fresh" };
|
|
159
|
-
const expiresAt = existing?.expiresAt ?? new Date(Math.min(Date.now() + 5 * 60_000, stockExpiry
|
|
219
|
+
const expiresAt = existing?.expiresAt ?? new Date(Math.min(Date.now() + 5 * 60_000, stockExpiry)).toISOString();
|
|
160
220
|
const provenance = {} as CommerceEvidenceProvenance;
|
|
161
221
|
for (const field of ["verification", "tax", "risk"] as const) {
|
|
162
|
-
if (
|
|
222
|
+
if (policy[field] === "not-required") {
|
|
163
223
|
evidence[field] = "not-required";
|
|
164
|
-
provenance[field] = { requirement: "not-required", state: "not-required", source:
|
|
224
|
+
provenance[field] = { requirement: "not-required", state: "not-required", source: SHARED_STORE_POLICY_SOURCE, policyRevision: SHARED_STORE_POLICY_REVISION, expiresAt };
|
|
165
225
|
}
|
|
166
226
|
}
|
|
167
227
|
provenance.stock = { requirement: "required", state: "satisfied", source: "tenant-inventory-movements", policyRevision: "mirror-v1", expiresAt };
|
|
168
228
|
const presented: FinalCartBinding = { tenantId: snapshot.tenantId, stage: snapshot.stage, currency: snapshot.currency, lines: snapshot.lines,
|
|
169
229
|
destinationDigest: snapshot.destinationDigest, verificationExpiresAt: expiresAt, taxExpiresAt: expiresAt };
|
|
230
|
+
const seamView = await seam(authorityContext, snapshot, selected, policy);
|
|
231
|
+
if (policy.verification === "required" && seamView.verified) {
|
|
232
|
+
evidence.verification = "fresh";
|
|
233
|
+
provenance.verification = { requirement: "required", state: "satisfied", source: SHARED_STORE_VERIFICATION_SOURCE, policyRevision: SHARED_STORE_POLICY_REVISION, expiresAt };
|
|
234
|
+
}
|
|
170
235
|
const decision = await evaluateFinalCart({ now, routedTenantId: commerceTenantId(tenant), quoted: presented, presented, evidence,
|
|
171
|
-
seam:
|
|
236
|
+
seam: seamView, catalogAvailable: true, browserConfirmed: false, token,
|
|
172
237
|
trustedIssuers: issuers, expectedAudience: audience, activeKids, authorities });
|
|
173
|
-
if (!decision.ok)
|
|
238
|
+
if (!decision.ok) {
|
|
239
|
+
if (decision.finding === "verification-refused:flavored_prohibited" && seamView.flavors) {
|
|
240
|
+
return json({ ok: false, finding: decision.finding, flavors: seamView.flavors }, 409);
|
|
241
|
+
}
|
|
242
|
+
return deny(decision.finding, 409, decision.owner);
|
|
243
|
+
}
|
|
174
244
|
const authorizationId = existing?.authorizationId ?? `a${crypto.randomUUID()}`;
|
|
175
245
|
// Retries of the same verified provider cart share one reservation, including concurrent callbacks.
|
|
176
246
|
const cartDigest = existing?.cartDigest ?? await digest(`commerce-provider-cart-v1\n${snapshot.providerCartId}\n${snapshot.providerCartDigest}`);
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
type TotVerificationClient,
|
|
35
35
|
type TotVerificationStatus,
|
|
36
36
|
} from "@/lib/compliance/verification/totVerificationClient";
|
|
37
|
+
import { sharedStorePolicy } from "@/lib/checkout/commerce/sharedStoreAdmission";
|
|
37
38
|
|
|
38
39
|
export const prerender = false;
|
|
39
40
|
|
|
@@ -268,8 +269,7 @@ function withProviderCleared(ruleset: ComplianceRuleset): ComplianceRuleset {
|
|
|
268
269
|
}
|
|
269
270
|
|
|
270
271
|
export async function POST(context: APIContext): Promise<Response> {
|
|
271
|
-
const
|
|
272
|
-
const regulated = isRegulatedTenant(tenant);
|
|
272
|
+
const regulated = isRegulatedTenant(context.locals.tenant);
|
|
273
273
|
|
|
274
274
|
let body: PreflightBody;
|
|
275
275
|
try {
|
|
@@ -281,7 +281,45 @@ export async function POST(context: APIContext): Promise<Response> {
|
|
|
281
281
|
: json({ decision: evaluateEmpty() });
|
|
282
282
|
}
|
|
283
283
|
|
|
284
|
+
// The shared test store applies the store's own saved requirements, computed here from the
|
|
285
|
+
// tenant's config — never from the request, so a shopper cannot waive one.
|
|
286
|
+
const policy = await sharedStorePolicy(context.locals.tenant, context.locals.commerce, context.locals.customizationEnv);
|
|
287
|
+
return json(await evaluatePreflight(context, body, policy ? { verification: policy.verification, rules: policy.rules } : {}));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export interface PreflightOptions {
|
|
291
|
+
/**
|
|
292
|
+
* Server-owned verification requirement, from the shared test store's policy for this
|
|
293
|
+
* tenant. "required" requires verification even where the ruleset would not; absent keeps
|
|
294
|
+
* the ruleset's own. Never read from the request body.
|
|
295
|
+
*/
|
|
296
|
+
verification?: "required" | "not-required";
|
|
297
|
+
/**
|
|
298
|
+
* Server-owned rule enforcement, from the same policy. "off" keeps only the verification
|
|
299
|
+
* outcome: destination, flavor, purchase-limit and excise blocks do not apply.
|
|
300
|
+
*/
|
|
301
|
+
rules?: "enforce" | "off";
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export interface PreflightResult {
|
|
305
|
+
decision: CartComplianceDecision;
|
|
306
|
+
verification?: LiveVerificationMeta;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** The preflight decision for `body`, evaluated server-side against the tenant's ruleset. */
|
|
310
|
+
export async function evaluatePreflight(
|
|
311
|
+
context: APIContext,
|
|
312
|
+
body: PreflightBody,
|
|
313
|
+
options: PreflightOptions = {},
|
|
314
|
+
): Promise<PreflightResult> {
|
|
315
|
+
const { tenant, tot } = context.locals;
|
|
316
|
+
const regulated = isRegulatedTenant(tenant);
|
|
317
|
+
|
|
284
318
|
let ruleset = await resolveTenantRuleset(tenant);
|
|
319
|
+
if (ruleset && options.verification) {
|
|
320
|
+
ruleset = { ...ruleset, verification: { ...ruleset.verification, required: options.verification === "required" } };
|
|
321
|
+
}
|
|
322
|
+
const rulesOff = options.rules === "off";
|
|
285
323
|
let verification = normalizeVerification(body.verification);
|
|
286
324
|
const destination = normalizeDestination(body.destination);
|
|
287
325
|
let liveVerification: LiveVerificationMeta | undefined;
|
|
@@ -291,7 +329,7 @@ export async function POST(context: APIContext): Promise<Response> {
|
|
|
291
329
|
// not evidence) and the affirmation shortcut below is unreachable — "tot" is
|
|
292
330
|
// never in SANDBOX_VERIFICATION_PROVIDERS. Any adapter failure clears the
|
|
293
331
|
// provider so the engine blocks fail-closed, exactly as when none is wired.
|
|
294
|
-
if (ruleset && isLiveTotVerificationProvider(ruleset.verification.provider)) {
|
|
332
|
+
if (ruleset && ruleset.verification.required && isLiveTotVerificationProvider(ruleset.verification.provider)) {
|
|
295
333
|
const resolved = await resolveLiveVerification(body);
|
|
296
334
|
if (!resolved) {
|
|
297
335
|
ruleset = withProviderCleared(ruleset);
|
|
@@ -330,15 +368,12 @@ export async function POST(context: APIContext): Promise<Response> {
|
|
|
330
368
|
if (!product) {
|
|
331
369
|
// A line we can't resolve on a regulated tenant is a fail-closed block:
|
|
332
370
|
// we can't prove it's shippable, so we don't let it through.
|
|
333
|
-
if (regulated) {
|
|
334
|
-
return
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
},
|
|
340
|
-
200,
|
|
341
|
-
);
|
|
371
|
+
if (regulated && !rulesOff) {
|
|
372
|
+
return {
|
|
373
|
+
decision: blockedDecision(
|
|
374
|
+
`Product "${raw.handle}" could not be resolved for compliance evaluation.`,
|
|
375
|
+
),
|
|
376
|
+
};
|
|
342
377
|
}
|
|
343
378
|
continue;
|
|
344
379
|
}
|
|
@@ -364,7 +399,20 @@ export async function POST(context: APIContext): Promise<Response> {
|
|
|
364
399
|
ageAffirmed: body.ageAffirmed === true,
|
|
365
400
|
});
|
|
366
401
|
|
|
367
|
-
|
|
402
|
+
const applied = rulesOff ? verificationOnly(decision) : decision;
|
|
403
|
+
return liveVerification ? { decision: applied, verification: liveVerification } : { decision: applied };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** The decision with only its verification outcome: every rule block removed, status recomputed. */
|
|
407
|
+
function verificationOnly(decision: CartComplianceDecision): CartComplianceDecision {
|
|
408
|
+
const blocks = decision.blocks.filter((block) => block.code.startsWith("verification_"));
|
|
409
|
+
return {
|
|
410
|
+
...decision,
|
|
411
|
+
blocks,
|
|
412
|
+
purchaseLimit: null,
|
|
413
|
+
failedClosed: blocks.some((block) => block.failedClosed === true),
|
|
414
|
+
status: blocks.some((block) => block.code !== "verification_required") ? "blocked" : blocks.length ? "action_required" : "clear",
|
|
415
|
+
};
|
|
368
416
|
}
|
|
369
417
|
|
|
370
418
|
/** The trivial "nothing to enforce" decision for an unregulated empty request. */
|
|
@@ -70,7 +70,7 @@ const crumbs = breadcrumbLd(trail, canonicalBase);
|
|
|
70
70
|
path={blogPaths.author(slug)}
|
|
71
71
|
fullBleed
|
|
72
72
|
jsonLd={[crumbs, profileLd, itemListLd]}
|
|
73
|
-
structuredData={{ pageType: "collection" }}
|
|
73
|
+
structuredData={{ pageType: "collection", crumbs: trail, items: posts.map((post) => ({ title: post.title, url: new URL(blogPaths.post(post.slug), canonicalBase + "/").toString() })) }}
|
|
74
74
|
>
|
|
75
75
|
<header class:list={["author-hero", { "author-hero--plain": !presentation.hero }]}>
|
|
76
76
|
<div class="blog-shell">
|
|
@@ -48,7 +48,7 @@ const crumbs = breadcrumbLd(trail, canonicalBase);
|
|
|
48
48
|
path={path}
|
|
49
49
|
fullBleed
|
|
50
50
|
jsonLd={[crumbs, itemList]}
|
|
51
|
-
structuredData={{ pageType: "collection" }}
|
|
51
|
+
structuredData={{ pageType: "collection", crumbs: trail, items: posts.map((post) => ({ title: post.title, url: new URL(blogPaths.post(post.slug), canonicalBase + "/").toString() })) }}
|
|
52
52
|
>
|
|
53
53
|
<header class:list={["blog-hero", { "blog-hero--plain": !presentation.hero }]}>
|
|
54
54
|
<div class="blog-shell">
|
|
@@ -46,7 +46,7 @@ const crumbs = breadcrumbLd(trail, canonicalBase);
|
|
|
46
46
|
path={path}
|
|
47
47
|
fullBleed
|
|
48
48
|
jsonLd={[crumbs, itemList]}
|
|
49
|
-
structuredData={{ pageType: "collection" }}
|
|
49
|
+
structuredData={{ pageType: "collection", crumbs: trail, items: posts.map((post) => ({ title: post.title, url: new URL(blogPaths.post(post.slug), canonicalBase + "/").toString() })) }}
|
|
50
50
|
>
|
|
51
51
|
<header class:list={["blog-hero", { "blog-hero--plain": !presentation.hero }]}>
|
|
52
52
|
<div class="blog-shell">
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-tenant static catalog index (lib/catalog/shared/catalogIndex.ts): what the
|
|
3
|
+
* browser filters, sorts and searches when a static host serves a listing or
|
|
4
|
+
* search page whose query no renderer saw. Published alongside the pages it
|
|
5
|
+
* serves; commerce-only.
|
|
6
|
+
*/
|
|
7
|
+
import type { APIContext } from "astro";
|
|
8
|
+
import { buildCatalogIndex } from "@/lib/catalog/shared/catalogIndex.js";
|
|
9
|
+
|
|
10
|
+
export async function GET(context: APIContext): Promise<Response> {
|
|
11
|
+
const { tot, features } = context.locals;
|
|
12
|
+
if (!features.catalog && !features.productSearch) return new Response(null, { status: 404 });
|
|
13
|
+
const [products, collections] = await Promise.all([tot.getAllActiveProducts(), tot.listCollections()]);
|
|
14
|
+
return new Response(JSON.stringify(buildCatalogIndex(products, collections)), {
|
|
15
|
+
headers: {
|
|
16
|
+
"content-type": "application/json; charset=utf-8",
|
|
17
|
+
"cache-control": "public, max-age=300, s-maxage=3600",
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* `/collections/<h>/page/<n>/` — page N≥2 of a collection, the indexable path
|
|
4
|
+
* variant every static host can serve (ADR 0013: paging is N-bounded). Page 1
|
|
5
|
+
* lives at the bare collection path; both render through _CollectionView.
|
|
6
|
+
*/
|
|
7
|
+
import CollectionView from "../../_CollectionView.astro";
|
|
8
|
+
import { collectionPageCount, loadCollectionListing } from "@/lib/catalog/shared/collectionPaging";
|
|
9
|
+
import { effectiveCollectionQuery } from "@/lib/publication/shared/staticQuery";
|
|
10
|
+
import { parsePagedRouteParam } from "@/lib/content/blog/pagination";
|
|
11
|
+
|
|
12
|
+
// Same query contract as page 1: sort/facets/price/search are applied client-side
|
|
13
|
+
// on a static host, the page number comes from the path.
|
|
14
|
+
export const STATIC_QUERY = "client-applied";
|
|
15
|
+
|
|
16
|
+
const { handle } = Astro.params;
|
|
17
|
+
if (!Astro.locals.features.catalog) return new Response(null, { status: 404 });
|
|
18
|
+
const pathPage = parsePagedRouteParam(Astro.params.n);
|
|
19
|
+
if (!handle || pathPage === null) return new Response(null, { status: 404 });
|
|
20
|
+
|
|
21
|
+
const query = effectiveCollectionQuery(Astro.url, pathPage);
|
|
22
|
+
const listing = await loadCollectionListing(Astro.locals.tot, handle, query);
|
|
23
|
+
if (!listing) return new Response(null, { status: 404 });
|
|
24
|
+
// A page past the collection's end is a real 404, never a clamped copy of the last page.
|
|
25
|
+
if (pathPage > collectionPageCount(listing.memberTotal)) return new Response(null, { status: 404 });
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
<CollectionView {...listing} query={query} pathPage={pathPage} />
|