@tokenoftrust/storefront-runner 2.5.6 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/agency-kit/tools/lib/public-binaries.mjs +111 -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/SearchTypeahead.tsx +17 -20
  7. package/apps/storefront/src/components/catalog/storefront/islands/VariantSelector.tsx +13 -21
  8. package/apps/storefront/src/components/catalog/storefront/plp/Pagination.astro +9 -5
  9. package/apps/storefront/src/components/checkout/storefront/commerce/CheckoutLoader.astro +18 -6
  10. package/apps/storefront/src/components/compliance/storefront/islands/CheckoutComplianceGate.tsx +19 -4
  11. package/apps/storefront/src/components/content/storefront/Badge.astro +1 -0
  12. package/apps/storefront/src/components/content/storefront/blog/BlogIndexView.astro +10 -1
  13. package/apps/storefront/src/components/publication/storefront/islands/CatalogQueryView.tsx +321 -0
  14. package/apps/storefront/src/config/declaredTenantConfig.ts +59 -27
  15. package/apps/storefront/src/config/devTenantSeed.ts +2 -1
  16. package/apps/storefront/src/layouts/Layout.astro +1 -1
  17. package/apps/storefront/src/lib/access/auth/gatePage.ts +29 -14
  18. package/apps/storefront/src/lib/access/shared/publicAccess.ts +95 -0
  19. package/apps/storefront/src/lib/access/shared/tenantRouting.ts +19 -0
  20. package/apps/storefront/src/lib/catalog/shared/catalogIndex.ts +109 -0
  21. package/apps/storefront/src/lib/catalog/shared/catalogQueryView.ts +60 -0
  22. package/apps/storefront/src/lib/catalog/shared/collectionPaging.ts +78 -0
  23. package/apps/storefront/src/lib/catalog/shared/stockStatus.ts +23 -0
  24. package/apps/storefront/src/lib/catalog/tot/query.ts +8 -0
  25. package/apps/storefront/src/lib/checkout/browser/checkoutRequirementsForm.ts +54 -0
  26. package/apps/storefront/src/lib/checkout/commerce/checkoutHandoffRoute.ts +103 -35
  27. package/apps/storefront/src/lib/checkout/commerce/checkoutRequirements.ts +88 -0
  28. package/apps/storefront/src/lib/checkout/commerce/finalCartAuthorization.ts +2 -0
  29. package/apps/storefront/src/lib/checkout/commerce/sharedStoreAdmission.ts +95 -0
  30. package/apps/storefront/src/lib/checkout/shared/lazyCheckoutLoader.ts +191 -0
  31. package/apps/storefront/src/lib/compliance/browser/checkoutVerificationFlow.ts +9 -0
  32. package/apps/storefront/src/lib/compliance/enforcement.ts +7 -5
  33. package/apps/storefront/src/lib/content/seo/structuredData.ts +12 -3
  34. package/apps/storefront/src/lib/content/storyblok/provider.ts +4 -2
  35. package/apps/storefront/src/lib/observability/shared/serverTiming.ts +110 -0
  36. package/apps/storefront/src/lib/publication/shared/edgeCache.ts +3 -4
  37. package/apps/storefront/src/lib/publication/shared/staticQuery.ts +64 -0
  38. package/apps/storefront/src/middleware/controlPlane.ts +14 -11
  39. package/apps/storefront/src/middleware/index.ts +33 -12
  40. package/apps/storefront/src/middleware/renderChain.ts +52 -26
  41. package/apps/storefront/src/pages/[...slug].astro +3 -0
  42. package/apps/storefront/src/pages/api/commerce/final-cart-authorization.ts +112 -42
  43. package/apps/storefront/src/pages/api/compliance/preflight.ts +61 -13
  44. package/apps/storefront/src/pages/blog/author/[author].astro +1 -1
  45. package/apps/storefront/src/pages/blog/category/[category].astro +1 -1
  46. package/apps/storefront/src/pages/blog/tag/[tag].astro +1 -1
  47. package/apps/storefront/src/pages/catalog-index.json.ts +20 -0
  48. package/apps/storefront/src/pages/collections/[handle]/page/[n].astro +28 -0
  49. package/apps/storefront/src/pages/collections/[handle].astro +13 -198
  50. package/apps/storefront/src/pages/collections/_CollectionView.astro +251 -0
  51. package/apps/storefront/src/pages/collections/index.astro +1 -1
  52. package/apps/storefront/src/pages/products/[handle].astro +10 -5
  53. package/apps/storefront/src/pages/search.astro +33 -33
  54. package/apps/storefront/src/pages/sitemap.xml.ts +7 -1
  55. package/apps/storefront/vitest.config.cts +3 -0
  56. package/package.json +1 -1
  57. package/packages/cli/src/declared-config.mjs +318 -32
  58. package/packages/cli/src/publication-spaces.mjs +1 -1
  59. package/packages/public-runtime/package.json +1 -0
  60. package/packages/public-runtime/schemas/publication-space/v1/manifest.schema.json +1 -1
  61. package/packages/public-runtime/schemas/publication-space/v1/normalized-mount.schema.json +1 -1
  62. package/packages/public-runtime/src/activity/catalog.ts +8 -1
  63. package/packages/public-runtime/src/candidate-index.ts +5 -0
  64. package/packages/public-runtime/src/capabilities.ts +12 -9
  65. package/packages/public-runtime/src/capture-rewrites.ts +687 -0
  66. package/packages/public-runtime/src/checkout-orders.ts +6 -7
  67. package/packages/public-runtime/src/checkout.ts +190 -6
  68. package/packages/public-runtime/src/compliance/evaluate.ts +7 -3
  69. package/packages/public-runtime/src/compliance/profile.ts +90 -14
  70. package/packages/public-runtime/src/compliance-coherence.d.mts +10 -1
  71. package/packages/public-runtime/src/compliance-coherence.mjs +129 -29
  72. package/packages/public-runtime/src/customization-runtime.ts +64 -2
  73. package/packages/public-runtime/src/declared-tenant-config.d.mts +13 -2
  74. package/packages/public-runtime/src/declared-tenant-config.mjs +83 -1
  75. package/packages/public-runtime/src/image-preview-rewrite-overlay.ts +13 -37
  76. package/packages/public-runtime/src/membership.ts +3 -0
  77. package/packages/public-runtime/src/publication-space.ts +1 -0
  78. package/packages/public-runtime/src/tenant.ts +28 -4
  79. package/scripts/tenant/validate.mjs +17 -8
@@ -1,8 +1,8 @@
1
1
  /**
2
- * Compliance COHERENCE: what a regulated tenant's declared `compliance` block must hold
3
- * together before its preview can pass the `age_gate`, `excise` and `jurisdiction`
4
- * evidence checks. Every rule here reads declared config only, so it is checkable from
5
- * a checkout with no platform state:
2
+ * Compliance COHERENCE: what a tenant's declared `compliance` block must hold together
3
+ * before its preview can pass the `age_gate`, `excise` and `jurisdiction` evidence checks.
4
+ * Every rule here reads declared config only, so it is checkable from a checkout with no
5
+ * platform state:
6
6
  *
7
7
  * - the hosted reconcile grades these checks with it
8
8
  * (packages/private-controlplane/src/evidence-producer.ts);
@@ -10,6 +10,12 @@
10
10
  * (packages/cli/src/declared-config.mjs), pinned to this one by
11
11
  * packages/cli/test/declared-config-parity.test.mjs.
12
12
  *
13
+ * WHICH checks apply is never decided here. The caller passes the tenant's resolved
14
+ * obligations (`resolveObligations` in compliance-obligations.ts — the one registry of
15
+ * compliance duties), and each obligation names the evidence check that judges it in
16
+ * {@link OBLIGATION_EVIDENCE_CHECK}. A tenant is regulated when any applied obligation
17
+ * sits on the `regulated` surface.
18
+ *
13
19
  * Each failure names the exact `compliance.*` field that resolves it, in both the
14
20
  * detail and the hint, so the first failure a developer sees is actionable.
15
21
  *
@@ -21,14 +27,58 @@
21
27
  export const COHERENCE_CHECK_IDS = Object.freeze(["age_gate", "excise", "jurisdiction"]);
22
28
 
23
29
  /**
24
- * True when the declared compliance block marks the tenant regulated (an age minimum
25
- * or the FDA nicotine warning).
26
- *
27
- * @param {Record<string, unknown> | null | undefined} compliance
30
+ * The evidence check that judges each registered obligation, or null for a duty no
31
+ * promotion check grades (the Prop 65 warning renders from its declaration alone). Every
32
+ * id in the obligation registry has an entry: packages/private-controlplane/tests/
33
+ * compliance-obligation-checks.test.ts fails on one that does not.
28
34
  */
29
- export function isRegulatedCompliance(compliance) {
30
- const minAge = compliance?.minAge;
31
- return (typeof minAge === "number" && minAge > 0) || !!compliance?.nicotineWarning;
35
+ export const OBLIGATION_EVIDENCE_CHECK = Object.freeze({
36
+ age_gate: "age_gate",
37
+ excise: "excise",
38
+ nicotine_warning: "excise",
39
+ pact_act: "jurisdiction",
40
+ adult_signature: "jurisdiction",
41
+ jurisdiction: "jurisdiction",
42
+ prop65: null,
43
+ });
44
+
45
+ /**
46
+ * @typedef {{ id: string, surface: string }} AppliedObligation
47
+ * @typedef {"age_gate" | "excise" | "jurisdiction"} CoherenceCheckId
48
+ */
49
+
50
+ /**
51
+ * True when an applied obligation puts the tenant on the `regulated` surface.
52
+ * @param {readonly AppliedObligation[]} obligations
53
+ */
54
+ export function isRegulatedObligationSet(obligations) {
55
+ return obligations.some((o) => o.surface === "regulated");
56
+ }
57
+
58
+ /**
59
+ * The evidence checks the tenant's obligations make applicable, in report order. A
60
+ * regulated tenant owes an age gate and a shipping/jurisdiction notice whatever made it
61
+ * regulated; every applied obligation also brings the check that judges it. An id with no
62
+ * entry in {@link OBLIGATION_EVIDENCE_CHECK} brings every check: an unknown duty is judged,
63
+ * never waved through.
64
+ * @param {readonly AppliedObligation[]} obligations
65
+ * @returns {CoherenceCheckId[]}
66
+ */
67
+ export function coherenceChecksFor(obligations) {
68
+ const out = new Set();
69
+ if (isRegulatedObligationSet(obligations)) {
70
+ out.add("age_gate");
71
+ out.add("jurisdiction");
72
+ }
73
+ for (const { id } of obligations) {
74
+ if (!Object.hasOwn(OBLIGATION_EVIDENCE_CHECK, id)) {
75
+ for (const check of COHERENCE_CHECK_IDS) out.add(check);
76
+ continue;
77
+ }
78
+ const check = OBLIGATION_EVIDENCE_CHECK[/** @type {keyof typeof OBLIGATION_EVIDENCE_CHECK} */ (id)];
79
+ if (check) out.add(check);
80
+ }
81
+ return COHERENCE_CHECK_IDS.filter((c) => out.has(c));
32
82
  }
33
83
 
34
84
  /**
@@ -44,21 +94,58 @@ export function declaresJurisdictionNotice(compliance) {
44
94
  }
45
95
 
46
96
  /**
47
- * Every coherence failure in a declared compliance block, keyed by evidence check id.
48
- * An unregulated block has none. An absent key means that check is coherent.
97
+ * True when `compliance.stateEligibility` gives its notice something to say: a restricted
98
+ * or eligible jurisdiction, or a note.
99
+ *
100
+ * @param {unknown} declaration
101
+ */
102
+ function stateEligibilityHasContent(declaration) {
103
+ if (declaration === null || typeof declaration !== "object" || Array.isArray(declaration)) return false;
104
+ const d = /** @type {Record<string, unknown>} */ (declaration);
105
+ const listed = (v) => Array.isArray(v) && v.some((s) => typeof s === "string" && s.trim().length > 0);
106
+ return listed(d.restrictedStates) || listed(d.eligibleStates) || (typeof d.note === "string" && d.note.trim().length > 0);
107
+ }
108
+
109
+ /** The `compliance.*` keys that switch on a regulated-surface duty, named in failure text. */
110
+ const REGULATED_KEY_FOR = Object.freeze({
111
+ age_gate: "minAge",
112
+ excise: "exciseTax",
113
+ nicotine_warning: "nicotineWarning",
114
+ pact_act: "pactAct",
115
+ adult_signature: "adultSignature",
116
+ });
117
+
118
+ /** The declared keys that made the tenant regulated, e.g. "`compliance.pactAct`". */
119
+ function regulatedBy(compliance, obligations) {
120
+ const keys = new Set();
121
+ for (const o of obligations) {
122
+ if (o.surface !== "regulated") continue;
123
+ const key = REGULATED_KEY_FOR[/** @type {keyof typeof REGULATED_KEY_FOR} */ (o.id)];
124
+ if (o.id === "excise" && !compliance?.exciseTax) continue;
125
+ if (key) keys.add(key);
126
+ }
127
+ return [...keys].map((k) => `\`compliance.${k}\``).join(", ") || "a regulated obligation";
128
+ }
129
+
130
+ /**
131
+ * Every coherence failure in a declared compliance block, keyed by evidence check id. A
132
+ * block whose obligations make no check applicable has none. An absent key means that
133
+ * check is coherent.
49
134
  *
50
135
  * @param {Record<string, unknown> | null | undefined} compliance
51
- * @returns {Partial<Record<"age_gate" | "excise" | "jurisdiction", { field: string, detail: string, hint: string }>>}
136
+ * @param {readonly AppliedObligation[]} obligations the tenant's resolved obligations
137
+ * @returns {Partial<Record<CoherenceCheckId, { field: string, detail: string, hint: string }>>}
52
138
  */
53
- export function complianceCoherenceFailures(compliance) {
54
- /** @type {Partial<Record<"age_gate" | "excise" | "jurisdiction", { field: string, detail: string, hint: string }>>} */
139
+ export function complianceCoherenceFailures(compliance, obligations) {
140
+ /** @type {Partial<Record<CoherenceCheckId, { field: string, detail: string, hint: string }>>} */
55
141
  const out = {};
56
- if (!isRegulatedCompliance(compliance)) return out;
142
+ const checks = coherenceChecksFor(obligations);
143
+ const regulated = isRegulatedObligationSet(obligations);
57
144
  const minAge = typeof compliance?.minAge === "number" ? compliance.minAge : null;
58
- if (minAge == null || minAge < 18) {
145
+ if (checks.includes("age_gate") && regulated && (minAge == null || minAge < 18)) {
59
146
  out.age_gate = {
60
147
  field: "compliance.minAge",
61
- detail: "Age-restricted/nicotine products require `compliance.minAge` (18 or higher) — none is set.",
148
+ detail: `Age-restricted products (${regulatedBy(compliance, obligations)}) require \`compliance.minAge\` (18 or higher) — none is set.`,
62
149
  hint: "Set `compliance.minAge` (18+) in .tot/config.json.",
63
150
  };
64
151
  }
@@ -69,16 +156,29 @@ export function complianceCoherenceFailures(compliance) {
69
156
  hint: "Set `compliance.minAge` to 21 in .tot/config.json (nicotine requires 21+, not just 18+).",
70
157
  };
71
158
  }
72
- if (!declaresJurisdictionNotice(compliance)) {
73
- out.jurisdiction = {
74
- field: "compliance.pactAct",
75
- detail:
76
- "Regulated products require a shipping/jurisdiction notice: set `compliance.pactAct: true` " +
77
- "or `compliance.shippingRestriction` — neither is set.",
78
- hint:
79
- "Set `compliance.pactAct: true` for the platform's PACT Act notice, or " +
80
- "`compliance.shippingRestriction` to your own restriction text, in .tot/config.json.",
81
- };
159
+ if (checks.includes("jurisdiction")) {
160
+ if (regulated && !declaresJurisdictionNotice(compliance)) {
161
+ out.jurisdiction = {
162
+ field: "compliance.pactAct",
163
+ detail:
164
+ `Regulated products (${regulatedBy(compliance, obligations)}) require a shipping/jurisdiction notice: ` +
165
+ "set `compliance.pactAct: true` or `compliance.shippingRestriction` — neither is set.",
166
+ hint:
167
+ "Set `compliance.pactAct: true` for the platform's PACT Act notice, or " +
168
+ "`compliance.shippingRestriction` to your own restriction text, in .tot/config.json.",
169
+ };
170
+ } else if (!regulated && !declaresJurisdictionNotice(compliance)
171
+ && !stateEligibilityHasContent(compliance?.stateEligibility)) {
172
+ const field = compliance?.stateEligibility ? "compliance.stateEligibility" : "compliance.shippingRestriction";
173
+ out.jurisdiction = {
174
+ field,
175
+ detail:
176
+ `\`${field}\` is declared but gives its shipping/jurisdiction notice nothing to say, so no notice renders.`,
177
+ hint:
178
+ "Set `compliance.shippingRestriction` to your restriction text, or give `compliance.stateEligibility` " +
179
+ "`restrictedStates`, `eligibleStates` or a `note`, in .tot/config.json.",
180
+ };
181
+ }
82
182
  }
83
183
  return out;
84
184
  }
@@ -303,9 +303,14 @@ export async function resolvePublishedArtifactKey(
303
303
 
304
304
  const channel = channelFromEnv(env);
305
305
  if (pointer.channels?.[channel]) {
306
- // Versioned model engaged for this channel — authoritative.
306
+ // Versioned model engaged for this channel — authoritative. The shared preview renders a
307
+ // version's captured images the way a pinned preview of it does; `live` never reads the
308
+ // overlay (its public path is the published bundle, which carries its own rewrites).
307
309
  const { versionId } = await resolveServableChannelVersion(get, tenantId, channel);
308
- return versionId ? resolveVersionArtifactKey(get, tenantId, versionId, path) : null;
310
+ if (!versionId) return null;
311
+ return channel === "preview"
312
+ ? resolveCapturedVersionArtifactKey(get, tenantId, versionId, path)
313
+ : resolveVersionArtifactKey(get, tenantId, versionId, path);
309
314
  }
310
315
 
311
316
  // Legacy per-path selection.
@@ -313,6 +318,63 @@ export async function resolvePublishedArtifactKey(
313
318
  return versionId ? customizationArtifactKey(tenantId, versionId) : null;
314
319
  }
315
320
 
321
+ /**
322
+ * Revision-bound overlay of capture-rewritten artifacts (`POST /api/preview/image-capture`). A
323
+ * site version is immutable; once its images are captured and sealed, the rewritten bytes of each
324
+ * artifact that named them are stored content-addressed and mapped here, so a preview of that
325
+ * version references platform-owned `/_a/` URLs instead of the source.
326
+ */
327
+ export type ImagePreviewRewriteOverlay = {
328
+ version: 1;
329
+ revisionId: string;
330
+ /** Capture-manifest digest bound at write time; mismatched rewrite loses CAS. */
331
+ captureManifestDigest: string;
332
+ /** Full artifact path → content hash (same keys as SiteVersionManifest.artifacts). */
333
+ artifacts: Record<string, string>;
334
+ };
335
+
336
+ export function imagePreviewRewriteOverlayKey(tenantId: string, revisionId: string): string {
337
+ return `cust:image-preview-rewrite:${tenantId}:${revisionId}`;
338
+ }
339
+
340
+ export async function readImagePreviewRewriteOverlay(
341
+ get: KvGet,
342
+ tenantId: string,
343
+ revisionId: string,
344
+ ): Promise<ImagePreviewRewriteOverlay | null> {
345
+ try {
346
+ const raw = await get(imagePreviewRewriteOverlayKey(tenantId, revisionId));
347
+ if (!raw) return null;
348
+ const parsed = JSON.parse(raw) as Partial<ImagePreviewRewriteOverlay>;
349
+ if (parsed.version !== 1 || parsed.revisionId !== revisionId) return null;
350
+ if (typeof parsed.captureManifestDigest !== "string") return null;
351
+ if (!/^sha256:[0-9a-f]{64}$/.test(parsed.captureManifestDigest)) return null;
352
+ if (!parsed.artifacts || typeof parsed.artifacts !== "object") return null;
353
+ return {
354
+ version: 1,
355
+ revisionId,
356
+ captureManifestDigest: parsed.captureManifestDigest,
357
+ artifacts: { ...parsed.artifacts },
358
+ };
359
+ } catch {
360
+ return null;
361
+ }
362
+ }
363
+
364
+ /**
365
+ * The KV key of one artifact of an immutable version as a preview renders it: the capture
366
+ * overlay's rewritten bytes when the version's images are sealed, else the version's own.
367
+ */
368
+ export async function resolveCapturedVersionArtifactKey(
369
+ get: KvGet,
370
+ tenantId: string,
371
+ versionId: string,
372
+ path: string,
373
+ ): Promise<string | null> {
374
+ const hash = (await readImagePreviewRewriteOverlay(get, tenantId, versionId))?.artifacts[path];
375
+ return hash ? customizationArtifactKey(tenantId, hash) : resolveVersionArtifactKey(get, tenantId, versionId, path);
376
+ }
377
+
316
378
  /** The KV key of one artifact in an immutable site version, or null when the version lacks it. */
317
379
  export async function resolveVersionArtifactKey(
318
380
  get: KvGet,
@@ -1,7 +1,7 @@
1
1
  /** Types for the dependency-free declared-tenant-config module (declared-tenant-config.mjs). */
2
2
  import type { TenantConfig } from "./tenant.js";
3
3
 
4
- export type DeclaredTenantConfigField = "displayName" | "siteType" | "compliance" | "capabilities" | "features" | "embeds" | "seo";
4
+ export type DeclaredTenantConfigField = "displayName" | "siteType" | "compliance" | "capabilities" | "features" | "embeds" | "seo" | "currency" | "shipping";
5
5
  export type DeclaredTenantConfig = Partial<Pick<TenantConfig, DeclaredTenantConfigField>>;
6
6
 
7
7
  export const DECLARED_TENANT_CONFIG_FIELDS: readonly DeclaredTenantConfigField[];
@@ -9,6 +9,10 @@ export const TOT_CONFIG_KEYS: readonly string[];
9
9
  export const COMPLIANCE_KEYS: readonly string[];
10
10
  export const FEATURE_KEYS: readonly string[];
11
11
  export const SITE_TYPES_LIST: readonly string[];
12
+ export const DEFAULT_CURRENCY: "USD";
13
+ export const CURRENCY_CODES: readonly string[];
14
+ export function resolveStoreCurrency(declared: unknown): string;
15
+ export function currencyDeclarationProblems(currency: unknown): string[];
12
16
  export const SEO_KEYS: readonly string[];
13
17
  export const STRUCTURED_DATA_KEYS: readonly string[];
14
18
  export const STRUCTURED_DATA_PAGE_TYPES: readonly import("./tenant.js").StructuredDataPageType[];
@@ -16,11 +20,18 @@ export const STRUCTURED_DATA_NODE_TOKENS: readonly import("./tenant.js").Structu
16
20
  export const STRUCTURED_DATA_NAMED_TYPES: readonly string[];
17
21
  export const STRUCTURED_DATA_NAME_SOURCES: readonly string[];
18
22
  export function seoDeclarationProblems(seo: unknown): string[];
23
+ export const SHIPPING_KEYS: readonly string[];
24
+ export const MAX_SHIPPING_AMOUNT: number;
25
+ export function shippingDeclarationProblems(shipping: unknown): string[];
19
26
  export function declaredTenantConfig(config: unknown): DeclaredTenantConfig;
20
27
  export function isEmptyDeclaredTenantConfig(declared: DeclaredTenantConfig | null | undefined): boolean;
28
+ /**
29
+ * `declared` is a declaration that was READ. An unread one (`null`) is not accepted: applying it
30
+ * would render or judge the store by its bare record — decide what unread means first.
31
+ */
21
32
  export function applyDeclaredTenantConfig<T extends Partial<Pick<TenantConfig, DeclaredTenantConfigField>>>(
22
33
  tenant: T,
23
- declared: DeclaredTenantConfig | null | undefined,
34
+ declared: DeclaredTenantConfig,
24
35
  ): T;
25
36
  export function readDeclaredTenantConfigAt(
26
37
  source: { readFile(path: string, ref: string): Promise<string | null> },
@@ -26,6 +26,8 @@ export const DECLARED_TENANT_CONFIG_FIELDS = Object.freeze([
26
26
  "features",
27
27
  "embeds",
28
28
  "seo",
29
+ "currency",
30
+ "shipping",
29
31
  ]);
30
32
 
31
33
  /**
@@ -65,6 +67,7 @@ export const FEATURE_KEYS = Object.freeze([
65
67
  "productSearch",
66
68
  "savedItems",
67
69
  "quickView",
70
+ "productBadges",
68
71
  "newsletter",
69
72
  "commerceSitemap",
70
73
  "productJsonLd",
@@ -109,6 +112,43 @@ export const STRUCTURED_DATA_NODE_TOKENS = Object.freeze([
109
112
  export const STRUCTURED_DATA_NAMED_TYPES = Object.freeze(["Product", "ProductGroup", "Article", "BlogPosting"]);
110
113
  export const STRUCTURED_DATA_NAME_SOURCES = Object.freeze(["documentTitle", "title"]);
111
114
 
115
+ /** The currency a store sells in when `.tot/config.json` declares none. */
116
+ export const DEFAULT_CURRENCY = "USD";
117
+
118
+ /** ISO 4217 codes a store may declare as its `currency`. */
119
+ export const CURRENCY_CODES = Object.freeze(
120
+ ("AED AFN ALL AMD ANG AOA ARS AUD AWG AZN BAM BBD BDT BGN BHD BIF BMD BND BOB BRL BSD BTN BWP BYN BZD CAD CDF CHF CLP CNY COP " +
121
+ "CRC CUP CVE CZK DJF DKK DOP DZD EGP ERN ETB EUR FJD FKP GBP GEL GHS GIP GMD GNF GTQ GYD HKD HNL HTG HUF IDR ILS INR IQD IRR " +
122
+ "ISK JMD JOD JPY KES KGS KHR KMF KPW KRW KWD KYD KZT LAK LBP LKR LRD LSL LYD MAD MDL MGA MKD MMK MNT MOP MRU MUR MVR MWK MXN " +
123
+ "MYR MZN NAD NGN NIO NOK NPR NZD OMR PAB PEN PGK PHP PKR PLN PYG QAR RON RSD RUB RWF SAR SBD SCR SDG SEK SGD SHP SLE SOS SRD " +
124
+ "SSP STN SVC SYP SZL THB TJS TMT TND TOP TRY TTD TWD TZS UAH UGX USD UYU UZS VES VND VUV WST XAF XCD XCG XOF XPF YER ZAR ZMW ZWG").split(" "),
125
+ );
126
+
127
+ /**
128
+ * The ONE place a store's currency is resolved. A declared ISO 4217 code wins; anything
129
+ * else (absent, wrong shape, lowercase, unknown code) is USD. Every surface that prices,
130
+ * signs or formats an amount takes the currency from here, never from a literal. Only the
131
+ * store owner's `.tot/config.json` can change it: a shopper's request never reaches this.
132
+ *
133
+ * @param {unknown} declared
134
+ * @returns {string}
135
+ */
136
+ export function resolveStoreCurrency(declared) {
137
+ return typeof declared === "string" && CURRENCY_CODES.includes(declared) ? declared : DEFAULT_CURRENCY;
138
+ }
139
+
140
+ /**
141
+ * What is wrong with a declared `currency`, one sentence; `[]` when it is valid.
142
+ *
143
+ * @param {unknown} currency
144
+ * @returns {string[]}
145
+ */
146
+ export function currencyDeclarationProblems(currency) {
147
+ return typeof currency === "string" && CURRENCY_CODES.includes(currency)
148
+ ? []
149
+ : [`\`currency\` is ${JSON.stringify(currency)} — must be an uppercase ISO 4217 code such as "USD" or "CAD"`];
150
+ }
151
+
112
152
  const SITE_TYPES = new Set(SITE_TYPES_LIST);
113
153
 
114
154
  const isPlainObject = (value) =>
@@ -202,6 +242,40 @@ export function seoDeclarationProblems(seo) {
202
242
  return problems;
203
243
  }
204
244
 
245
+ /** Every `shipping` key (ShippingPolicy, tenant.ts). */
246
+ export const SHIPPING_KEYS = Object.freeze(["flatRate", "freeAtOrAbove"]);
247
+
248
+ /** Largest shipping amount a declaration may state, in the cart's currency. */
249
+ export const MAX_SHIPPING_AMOUNT = 100000;
250
+
251
+ /** @param {unknown} value */
252
+ const isShippingAmount = (value) =>
253
+ typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= MAX_SHIPPING_AMOUNT
254
+ && Math.abs(value * 100 - Math.round(value * 100)) < 1e-6;
255
+
256
+ /**
257
+ * What is wrong with a declared `shipping` value, one sentence per problem; `[]` when it is
258
+ * valid. A policy with any problem is refused whole, so checkout never charges half of one.
259
+ *
260
+ * @param {unknown} shipping
261
+ * @returns {string[]}
262
+ */
263
+ export function shippingDeclarationProblems(shipping) {
264
+ if (!isPlainObject(shipping)) return ["`shipping` must be an object"];
265
+ const problems = [];
266
+ const source = /** @type {Record<string, unknown>} */ (shipping);
267
+ for (const key of Object.keys(source)) {
268
+ if (!SHIPPING_KEYS.includes(key)) problems.push(`\`shipping.${key}\` is not a shipping key (${SHIPPING_KEYS.join(", ")})`);
269
+ }
270
+ if (!isShippingAmount(source.flatRate)) {
271
+ problems.push(`\`shipping.flatRate\` must be an amount from 0 to ${MAX_SHIPPING_AMOUNT} with at most two decimal places`);
272
+ }
273
+ if (source.freeAtOrAbove !== undefined && !(isShippingAmount(source.freeAtOrAbove) && /** @type {number} */ (source.freeAtOrAbove) > 0)) {
274
+ problems.push(`\`shipping.freeAtOrAbove\` must be an amount above 0 and at most ${MAX_SHIPPING_AMOUNT} with at most two decimal places`);
275
+ }
276
+ return problems;
277
+ }
278
+
205
279
  /**
206
280
  * The declared TenantConfig fields from a parsed `.tot/config.json`. A field whose
207
281
  * value has the wrong shape is omitted, never coerced (`tot validate` reports it).
@@ -212,7 +286,7 @@ export function declaredTenantConfig(config) {
212
286
  /** @type {Record<string, unknown>} */
213
287
  const out = {};
214
288
  if (!isPlainObject(config)) return out;
215
- const { displayName, siteType, compliance, capabilities, features, embeds, seo } = /** @type {Record<string, unknown>} */ (config);
289
+ const { displayName, siteType, compliance, capabilities, features, embeds, seo, currency, shipping } = /** @type {Record<string, unknown>} */ (config);
216
290
  // The store's name as visitors read it: the `<title>` suffix, og:site_name,
217
291
  // Organization JSON-LD, breadcrumbs, the default blog heading, the copyright line.
218
292
  if (typeof displayName === "string" && displayName.trim()) out.displayName = displayName.trim();
@@ -223,6 +297,14 @@ export function declaredTenantConfig(config) {
223
297
  if (Array.isArray(embeds) && embeds.every((slug) => typeof slug === "string")) out.embeds = embeds;
224
298
  // URL form and the structured-data profile: refused whole when any part is malformed.
225
299
  if (seo !== undefined && seoDeclarationProblems(seo).length === 0) out.seo = seo;
300
+ // Wrong-shaped or unknown codes are omitted: the store sells in USD (`tot validate` reports it).
301
+ if (currency !== undefined && currencyDeclarationProblems(currency).length === 0) out.currency = currency;
302
+ // The shipping charge: refused whole when any part is malformed (`tot validate` and the
303
+ // reconcile report it), so checkout keeps charging no shipping rather than a guess.
304
+ if (shipping !== undefined && shippingDeclarationProblems(shipping).length === 0) {
305
+ const policy = /** @type {Record<string, number>} */ (shipping);
306
+ out.shipping = { flatRate: policy.flatRate, ...(policy.freeAtOrAbove === undefined ? {} : { freeAtOrAbove: policy.freeAtOrAbove }) };
307
+ }
226
308
  return out;
227
309
  }
228
310
 
@@ -4,21 +4,21 @@
4
4
  * Site-version manifests are immutable; successful `/_a/<sha>` rewrites are
5
5
  * stored as content-addressed artifact bytes plus this overlay map. Preview
6
6
  * reads prefer the overlay so candidate documents reference platform-owned URLs.
7
+ * The record and its key-level resolution live in `customization-runtime.ts`,
8
+ * which every versioned read goes through.
7
9
  */
8
- import { contentArtifactPath, customizationArtifactKey, themeArtifactPath } from "./customization-runtime.js";
10
+ import {
11
+ contentArtifactPath,
12
+ customizationArtifactKey,
13
+ readImagePreviewRewriteOverlay,
14
+ themeArtifactPath,
15
+ } from "./customization-runtime.js";
9
16
 
10
- export type ImagePreviewRewriteOverlay = {
11
- version: 1;
12
- revisionId: string;
13
- /** Capture-manifest digest bound at write time; mismatched rewrite loses CAS. */
14
- captureManifestDigest: string;
15
- /** Full artifact path → content hash (same keys as SiteVersionManifest.artifacts). */
16
- artifacts: Record<string, string>;
17
- };
18
-
19
- export function imagePreviewRewriteOverlayKey(tenantId: string, revisionId: string): string {
20
- return `cust:image-preview-rewrite:${tenantId}:${revisionId}`;
21
- }
17
+ export {
18
+ imagePreviewRewriteOverlayKey,
19
+ readImagePreviewRewriteOverlay,
20
+ type ImagePreviewRewriteOverlay,
21
+ } from "./customization-runtime.js";
22
22
 
23
23
  /** Map a repo-relative capture artifact path to the preview manifest artifact path. */
24
24
  export function previewManifestPathFor(tenantId: string, repoPath: string): string {
@@ -29,30 +29,6 @@ export function previewManifestPathFor(tenantId: string, repoPath: string): stri
29
29
  return `tenants/${tenantId}/${repoPath}`;
30
30
  }
31
31
 
32
- export async function readImagePreviewRewriteOverlay(
33
- get: (key: string) => Promise<string | null>,
34
- tenantId: string,
35
- revisionId: string,
36
- ): Promise<ImagePreviewRewriteOverlay | null> {
37
- try {
38
- const raw = await get(imagePreviewRewriteOverlayKey(tenantId, revisionId));
39
- if (!raw) return null;
40
- const parsed = JSON.parse(raw) as Partial<ImagePreviewRewriteOverlay>;
41
- if (parsed.version !== 1 || parsed.revisionId !== revisionId) return null;
42
- if (typeof parsed.captureManifestDigest !== "string") return null;
43
- if (!/^sha256:[0-9a-f]{64}$/.test(parsed.captureManifestDigest)) return null;
44
- if (!parsed.artifacts || typeof parsed.artifacts !== "object") return null;
45
- return {
46
- version: 1,
47
- revisionId,
48
- captureManifestDigest: parsed.captureManifestDigest,
49
- artifacts: { ...parsed.artifacts },
50
- };
51
- } catch {
52
- return null;
53
- }
54
- }
55
-
56
32
  /** Prefer overlay bytes for a full artifact path when present. */
57
33
  export async function readWithImagePreviewRewriteOverlay(
58
34
  get: (key: string) => Promise<string | null>,
@@ -278,6 +278,8 @@ export interface MemberSignedCartPayloadOptions {
278
278
  autopilotEnabled: boolean;
279
279
  /** Tenant id written onto the signed `tenant` field when present. */
280
280
  tenantId?: string;
281
+ /** The store's currency, signed into the cart (see {@link BuildVariantCartPayloadOptions.currency}). */
282
+ currency?: string;
281
283
  }
282
284
 
283
285
  /**
@@ -312,6 +314,7 @@ export async function buildMemberSignedCartPayload(
312
314
  signedPrice: effective.price,
313
315
  autopilotEnabled: opts.autopilotEnabled,
314
316
  tenantId: opts.tenantId,
317
+ currency: opts.currency,
315
318
  });
316
319
  }
317
320
 
@@ -90,6 +90,7 @@ export const PUBLICATION_SPACE_RESERVED_MOUNTS = [
90
90
  "/products",
91
91
  "/collections",
92
92
  "/search",
93
+ "/catalog-index.json",
93
94
  "/saved",
94
95
  "/robots.txt",
95
96
  "/shared",
@@ -12,7 +12,6 @@ import type { IntegrationDeclarationInput } from "./integration/resolve.js";
12
12
  import type { EuUkVisitorPolicy } from "./integration/plan.js";
13
13
  import type { PublicationSpaceManifestV1 } from "./publication-space.js";
14
14
  import type { HtmlCachePolicy } from "./html-cache-policy.js";
15
- import type { SyntheticCommerceAuthorityPolicy } from "./checkout-orders.js";
16
15
 
17
16
  /**
18
17
  * The shared semantic COLOR contract — the widget library's public API.
@@ -430,6 +429,8 @@ export interface TenantFeatures {
430
429
  savedItems: boolean;
431
430
  /** Product quick-view dialog island. */
432
431
  quickView: boolean;
432
+ /** Promotional product badges (Sale, New, Compliant) on cards and the PDP. Sold out always shows. */
433
+ productBadges: boolean;
433
434
  /** Newsletter capture forms (footer + homepage block). */
434
435
  newsletter: boolean;
435
436
  /** Product/collection URLs in the XML sitemap. */
@@ -445,6 +446,7 @@ const COMMERCE_FEATURES: TenantFeatures = {
445
446
  productSearch: true,
446
447
  savedItems: true,
447
448
  quickView: true,
449
+ productBadges: true,
448
450
  newsletter: true,
449
451
  commerceSitemap: true,
450
452
  productJsonLd: true,
@@ -456,6 +458,7 @@ const MARKETING_FEATURES: TenantFeatures = {
456
458
  productSearch: false,
457
459
  savedItems: false,
458
460
  quickView: false,
461
+ productBadges: false,
459
462
  newsletter: false,
460
463
  commerceSitemap: false,
461
464
  productJsonLd: false,
@@ -512,7 +515,11 @@ export interface TenantConfig {
512
515
  tot_data_scope: string;
513
516
  /** Per-tenant theme overrides; merged over the reference theme defaults. */
514
517
  theme_tokens: DeepPartial<ThemeTokens>;
515
- /** Currency for display formatting. */
518
+ /**
519
+ * The ISO 4217 code the store sells in: priced, signed into the cart and formatted. USD unless
520
+ * `.tot/config.json` declares another; read it through `resolveStoreCurrency`
521
+ * (declared-tenant-config.mjs), never as a literal.
522
+ */
516
523
  currency: string;
517
524
  locale: string;
518
525
  /** Explicit publication spaces for this tenant, including one canonical root. */
@@ -610,8 +617,6 @@ export interface TenantConfig {
610
617
  */
611
618
  commerce?: {
612
619
  checkout?: ManagedCheckoutConfig;
613
- /** Server-owned applicability for nonbillable synthetic fixtures; never waives regulated rules. */
614
- authorityPolicy?: SyntheticCommerceAuthorityPolicy;
615
620
  /**
616
621
  * Autopilot (subscription) program visibility. Absent → subscription
617
622
  * purchase options render wherever a product authors them (framework/dev
@@ -759,6 +764,25 @@ export interface TenantConfig {
759
764
  * canonical URL shape and the JSON-LD graph its rankings were earned under.
760
765
  */
761
766
  seo?: SeoDeclaration;
767
+ /**
768
+ * What checkout charges for shipping, declared in `.tot/config.json` (validated by
769
+ * `shippingDeclarationProblems`, declared-tenant-config.mjs). Absent ⇒ checkout charges no
770
+ * shipping. Amounts are in the cart's own currency.
771
+ */
772
+ shipping?: ShippingPolicy;
773
+ }
774
+
775
+ /**
776
+ * A store's shipping charge: one flat rate per order, waived when the order's item subtotal
777
+ * is at or above `freeAtOrAbove`. The cart carries it as the signed `shipping_policy` option;
778
+ * the checkout store charges it and the final-cart authority re-derives it from this
779
+ * declaration (checkout.ts `shippingMinorFor`).
780
+ */
781
+ export interface ShippingPolicy {
782
+ /** Charged once per order, in the cart's currency. `0` ships every order free. */
783
+ flatRate: number;
784
+ /** Item subtotal at or above which shipping is free, in the cart's currency. */
785
+ freeAtOrAbove?: number;
762
786
  }
763
787
 
764
788
  /** Page types a structured-data profile declares a graph for (STRUCTURED_DATA_PAGE_TYPES). */