@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.
Files changed (80) hide show
  1. package/agency-kit/tools/lib/public-binaries.mjs +95 -0
  2. package/apps/storefront/astro.config.mjs +7 -0
  3. package/apps/storefront/env.d.ts +3 -0
  4. package/apps/storefront/src/components/catalog/storefront/ProductCard.astro +5 -4
  5. package/apps/storefront/src/components/catalog/storefront/commerce/PriceDisplay.astro +2 -1
  6. package/apps/storefront/src/components/catalog/storefront/islands/ImageGallery.tsx +20 -3
  7. package/apps/storefront/src/components/catalog/storefront/islands/SearchTypeahead.tsx +17 -20
  8. package/apps/storefront/src/components/catalog/storefront/islands/VariantSelector.tsx +13 -21
  9. package/apps/storefront/src/components/catalog/storefront/plp/Pagination.astro +9 -5
  10. package/apps/storefront/src/components/checkout/storefront/commerce/CheckoutLoader.astro +18 -6
  11. package/apps/storefront/src/components/compliance/storefront/islands/CheckoutComplianceGate.tsx +19 -4
  12. package/apps/storefront/src/components/content/storefront/Badge.astro +1 -0
  13. package/apps/storefront/src/components/content/storefront/blog/BlogIndexView.astro +10 -1
  14. package/apps/storefront/src/components/publication/storefront/islands/CatalogQueryView.tsx +321 -0
  15. package/apps/storefront/src/config/declaredTenantConfig.ts +59 -27
  16. package/apps/storefront/src/config/devTenantSeed.ts +2 -1
  17. package/apps/storefront/src/layouts/Layout.astro +1 -1
  18. package/apps/storefront/src/lib/access/auth/gatePage.ts +29 -14
  19. package/apps/storefront/src/lib/access/shared/publicAccess.ts +95 -0
  20. package/apps/storefront/src/lib/access/shared/tenantRouting.ts +19 -0
  21. package/apps/storefront/src/lib/catalog/shared/catalogIndex.ts +109 -0
  22. package/apps/storefront/src/lib/catalog/shared/catalogQueryView.ts +60 -0
  23. package/apps/storefront/src/lib/catalog/shared/collectionPaging.ts +78 -0
  24. package/apps/storefront/src/lib/catalog/shared/stockStatus.ts +23 -0
  25. package/apps/storefront/src/lib/catalog/tot/query.ts +8 -0
  26. package/apps/storefront/src/lib/checkout/browser/checkoutRequirementsForm.ts +54 -0
  27. package/apps/storefront/src/lib/checkout/commerce/checkoutHandoffRoute.ts +103 -35
  28. package/apps/storefront/src/lib/checkout/commerce/checkoutRequirements.ts +88 -0
  29. package/apps/storefront/src/lib/checkout/commerce/finalCartAuthorization.ts +2 -0
  30. package/apps/storefront/src/lib/checkout/commerce/sharedStoreAdmission.ts +95 -0
  31. package/apps/storefront/src/lib/checkout/shared/lazyCheckoutLoader.ts +191 -0
  32. package/apps/storefront/src/lib/compliance/browser/checkoutVerificationFlow.ts +9 -0
  33. package/apps/storefront/src/lib/compliance/enforcement.ts +7 -5
  34. package/apps/storefront/src/lib/content/seo/structuredData.ts +12 -3
  35. package/apps/storefront/src/lib/content/storyblok/provider.ts +14 -7
  36. package/apps/storefront/src/lib/observability/shared/serverTiming.ts +110 -0
  37. package/apps/storefront/src/lib/publication/shared/edgeCache.ts +3 -4
  38. package/apps/storefront/src/lib/publication/shared/staticQuery.ts +64 -0
  39. package/apps/storefront/src/middleware/controlPlane.ts +14 -11
  40. package/apps/storefront/src/middleware/index.ts +33 -12
  41. package/apps/storefront/src/middleware/renderChain.ts +52 -26
  42. package/apps/storefront/src/pages/[...slug].astro +3 -0
  43. package/apps/storefront/src/pages/api/commerce/final-cart-authorization.ts +112 -42
  44. package/apps/storefront/src/pages/api/compliance/preflight.ts +61 -13
  45. package/apps/storefront/src/pages/blog/author/[author].astro +1 -1
  46. package/apps/storefront/src/pages/blog/category/[category].astro +1 -1
  47. package/apps/storefront/src/pages/blog/tag/[tag].astro +1 -1
  48. package/apps/storefront/src/pages/catalog-index.json.ts +20 -0
  49. package/apps/storefront/src/pages/collections/[handle]/page/[n].astro +28 -0
  50. package/apps/storefront/src/pages/collections/[handle].astro +13 -198
  51. package/apps/storefront/src/pages/collections/_CollectionView.astro +251 -0
  52. package/apps/storefront/src/pages/collections/index.astro +1 -1
  53. package/apps/storefront/src/pages/products/[handle].astro +18 -8
  54. package/apps/storefront/src/pages/search.astro +33 -33
  55. package/apps/storefront/src/pages/sitemap.xml.ts +7 -1
  56. package/apps/storefront/vitest.config.cts +3 -0
  57. package/package.json +1 -1
  58. package/packages/cli/src/declared-config.mjs +318 -32
  59. package/packages/cli/src/publication-spaces.mjs +1 -1
  60. package/packages/public-runtime/package.json +1 -0
  61. package/packages/public-runtime/schemas/publication-space/v1/manifest.schema.json +1 -1
  62. package/packages/public-runtime/schemas/publication-space/v1/normalized-mount.schema.json +1 -1
  63. package/packages/public-runtime/src/activity/catalog.ts +8 -1
  64. package/packages/public-runtime/src/candidate-index.ts +5 -0
  65. package/packages/public-runtime/src/capabilities.ts +12 -9
  66. package/packages/public-runtime/src/capture-rewrites.ts +687 -0
  67. package/packages/public-runtime/src/checkout-orders.ts +6 -7
  68. package/packages/public-runtime/src/checkout.ts +190 -6
  69. package/packages/public-runtime/src/compliance/evaluate.ts +7 -3
  70. package/packages/public-runtime/src/compliance/profile.ts +90 -14
  71. package/packages/public-runtime/src/compliance-coherence.d.mts +10 -1
  72. package/packages/public-runtime/src/compliance-coherence.mjs +129 -29
  73. package/packages/public-runtime/src/customization-runtime.ts +64 -2
  74. package/packages/public-runtime/src/declared-tenant-config.d.mts +13 -2
  75. package/packages/public-runtime/src/declared-tenant-config.mjs +83 -1
  76. package/packages/public-runtime/src/image-preview-rewrite-overlay.ts +13 -37
  77. package/packages/public-runtime/src/membership.ts +3 -0
  78. package/packages/public-runtime/src/publication-space.ts +1 -0
  79. package/packages/public-runtime/src/tenant.ts +28 -4
  80. 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, deriveProductProfile,
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 { isRegulatedTenant } from "@/lib/compliance/enforcement.js";
16
- import { POST as compliancePreflight } from "../compliance/preflight.js";
17
- import { resolveAdminTargetTenant } from "@/config/adminTargetTenant";
18
- import { withDeclaredTenantConfig } from "@/config/declaredTenantConfig";
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
- if (Object.keys(v).some(k => !allowed.includes(k) && k !== "expectedAuthorizationId") || allowed.some(k => !(k in v))) return null;
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
- async function seam(context: APIContext, snapshot: CommerceProviderCartSnapshot, products: readonly CatalogProduct[]): Promise<SeamView> {
68
- const response = await compliancePreflight({
69
- request: new Request("https://checkout.invalid/api/compliance/preflight", {
70
- method: "POST", headers: { "content-type": "application/json" },
71
- body: JSON.stringify({ lines: snapshot.lines.map(line => ({ handle: products.find(p => p.variants.some(v => v.sku === line.sku))!.handle, sku: line.sku, quantity: line.quantity })), destination: snapshot.destination }),
72
- }), locals: { tenant: context.locals.tenant, tot: context.locals.tot },
73
- } as APIContext);
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 body = await response.json() as { decision?: { status?: string; failedClosed?: boolean; blocks?: { code?: string }[] } };
76
- if (!body.decision) return { status: "absent" };
77
- if (body.decision.status === "clear") return { status: "clear" };
78
- return { status: "blocked", failedClosed: body.decision.failedClosed === true, codes: body.decision.blocks?.map(b => b.code ?? "") };
79
- } catch { return { status: "absent" }; }
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 allowedTenants = list(await readEnv("COMMERCE_TENANT_ALLOWLIST"));
106
- if (!allowedTenants || !allowedTenants.includes(snapshot.tenantId)) return deny("tenant", 401);
107
- const registered = await resolveAdminTargetTenant(snapshot.tenantId);
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
- const tenant = await withDeclaredTenantConfig(registered, kvGet, { env: contentEnv });
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 products: CatalogProduct[];
120
- try { products = await tot.getAllActiveProducts(); } catch { return deny("catalog-unavailable", 503); }
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 = products.flatMap(product => product.status === "active" && (!product.visibility || product.visibility === "public") && product.tenant_id === tenant.tot_data_scope
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 = (matches[0]?.variant.price ?? -1) * 10 ** digits;
126
- if (matches.length !== 1 || !matches[0]!.variant.inventory.available || matches[0]!.variant.currency !== snapshot.currency || Math.abs(scaled - Math.round(scaled)) > 0.000001 || Math.round(scaled) !== line.unitMinor) return deny("catalog-cart-mismatch");
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 policy = tenant.commerce?.authorityPolicy;
146
- const testPolicy = policy?.purpose === "synthetic-nonbillable" && typeof policy.revision === "string" && /^[A-Za-z0-9._:-]{1,128}$/.test(policy.revision) &&
147
- Number.isFinite(Date.parse(policy.expiresAt)) && policy.expiresAt > now && await readEnv("COMMERCE_DEMO_CHECKOUT_TIER") === "test" &&
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, testPolicy ? Date.parse(policy.expiresAt) : Infinity)).toISOString();
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 (testPolicy && policy.requirements?.[field] === "not-required") {
222
+ if (policy[field] === "not-required") {
163
223
  evidence[field] = "not-required";
164
- provenance[field] = { requirement: "not-required", state: "not-required", source: "declared-synthetic-policy", policyRevision: policy.revision, expiresAt };
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: await seam(authorityContext, snapshot, products), catalogAvailable: true, browserConfirmed: false, token,
236
+ seam: seamView, catalogAvailable: true, browserConfirmed: false, token,
172
237
  trustedIssuers: issuers, expectedAudience: audience, activeKids, authorities });
173
- if (!decision.ok) return deny(decision.finding, 409, decision.owner);
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 { tenant, tot } = context.locals;
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 json(
335
- {
336
- decision: blockedDecision(
337
- `Product "${raw.handle}" could not be resolved for compliance evaluation.`,
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
- return json(liveVerification ? { decision, verification: liveVerification } : { decision });
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} />