@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.
- package/agency-kit/tools/lib/public-binaries.mjs +111 -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/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 +4 -2
- 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 +10 -5
- 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
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Compliance COHERENCE: what a
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
*
|
|
48
|
-
*
|
|
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
|
-
* @
|
|
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<
|
|
139
|
+
export function complianceCoherenceFailures(compliance, obligations) {
|
|
140
|
+
/** @type {Partial<Record<CoherenceCheckId, { field: string, detail: string, hint: string }>>} */
|
|
55
141
|
const out = {};
|
|
56
|
-
|
|
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:
|
|
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 (
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
10
|
+
import {
|
|
11
|
+
contentArtifactPath,
|
|
12
|
+
customizationArtifactKey,
|
|
13
|
+
readImagePreviewRewriteOverlay,
|
|
14
|
+
themeArtifactPath,
|
|
15
|
+
} from "./customization-runtime.js";
|
|
9
16
|
|
|
10
|
-
export
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
|
@@ -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
|
-
/**
|
|
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). */
|