@tokenoftrust/storefront-runner 1.3.2 → 1.3.4-rc.2
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/apps/storefront/astro.config.mjs +59 -0
- package/apps/storefront/env.d.ts +24 -8
- package/apps/storefront/package.json +1 -1
- package/apps/storefront/src/components/admin/{AdminFoxyCartSettingsPanel.astro → AdminCheckoutSettingsPanel.astro} +50 -51
- package/apps/storefront/src/components/admin/AdminSettingsTab.astro +2 -2
- package/apps/storefront/src/components/admin/AdminSubscriptionsTab.astro +613 -0
- package/apps/storefront/src/components/admin/orders/OrdersWorkspace.astro +3 -3
- package/apps/storefront/src/components/chrome/SiteHeader.astro +3 -4
- package/apps/storefront/src/components/commerce/{FoxySidecartStyles.astro → CartDrawerStyles.astro} +13 -14
- package/apps/storefront/src/components/commerce/CheckoutLoader.astro +21 -0
- package/apps/storefront/src/components/islands/VariantSelector.tsx +101 -20
- package/apps/storefront/src/config/adminTargetTenant.ts +2 -2
- package/apps/storefront/src/config/devTenantSeed.ts +65 -16
- package/apps/storefront/src/config/resolver.ts +48 -1
- package/apps/storefront/src/layouts/Layout.astro +14 -12
- package/apps/storefront/src/lib/admin/README.md +2 -2
- package/apps/storefront/src/lib/admin/checkoutStyleInput.ts +5 -5
- package/apps/storefront/src/lib/admin/orderClient.ts +20 -20
- package/apps/storefront/src/lib/admin/services/mock/{foxyCartSettings.ts → checkoutSettings.ts} +21 -22
- package/apps/storefront/src/lib/admin/services/mock/index.ts +3 -2
- package/apps/storefront/src/lib/admin/services/mock/settings.ts +1 -1
- package/apps/storefront/src/lib/admin/services/mock/subscriptions.ts +398 -0
- package/apps/storefront/src/lib/admin/services/orders.ts +21 -21
- package/apps/storefront/src/lib/admin/viewmodels/subscriptions.ts +106 -0
- package/apps/storefront/src/lib/auth/loginGate.ts +14 -3
- package/apps/storefront/src/lib/{foxyCommerce.ts → checkoutCommerce.ts} +20 -18
- package/apps/storefront/src/lib/commerce/{foxyCartSettings.ts → checkoutSettings.ts} +65 -66
- package/apps/storefront/src/lib/dev/activityIngestToken.ts +9 -0
- package/apps/storefront/src/lib/dev/activityStore.ts +12 -0
- package/apps/storefront/src/lib/dev/cliSignInCode.ts +23 -4
- package/apps/storefront/src/lib/dev/runtimeStore.ts +193 -22
- package/apps/storefront/src/lib/edgeCache.ts +7 -6
- package/apps/storefront/src/lib/health.ts +53 -0
- package/apps/storefront/src/lib/privacy/emailHint.ts +33 -0
- package/apps/storefront/src/lib/rawMarketingHtml.ts +1 -0
- package/apps/storefront/src/lib/sections.ts +19 -23
- package/apps/storefront/src/lib/securityHeaders.ts +1 -1
- package/apps/storefront/src/lib/storyblok/content-model.ts +3 -2
- package/apps/storefront/src/middleware/index.ts +29 -12
- package/apps/storefront/src/pages/[...slug].astro +5 -6
- package/apps/storefront/src/pages/admin.astro +10 -3
- package/apps/storefront/src/pages/api/admin/orders/[id].ts +4 -4
- package/apps/storefront/src/pages/api/admin/orders.ts +5 -5
- package/apps/storefront/src/pages/api/auth/magic-exchange.ts +14 -7
- package/apps/storefront/src/pages/api/checkout-style.css.ts +5 -5
- package/apps/storefront/src/pages/auth/login.astro +4 -1
- package/apps/storefront/src/pages/auth/magic.astro +263 -43
- package/apps/storefront/src/pages/capabilities.astro +1 -1
- package/apps/storefront/src/pages/cockpit.astro +2365 -0
- package/apps/storefront/src/pages/dashboard/[appDomain]/team.astro +19 -0
- package/apps/storefront/src/pages/index.astro +23 -29
- package/apps/storefront/src/pages/products/[handle].astro +50 -20
- package/package.json +6 -2
- package/packages/public-runtime/src/capabilities.ts +97 -9
- package/packages/public-runtime/src/{foxy-orders.ts → checkout-orders.ts} +28 -27
- package/packages/public-runtime/src/checkout-style-publish.ts +25 -25
- package/packages/public-runtime/src/checkout-style.ts +13 -13
- package/packages/public-runtime/src/{foxy.ts → checkout.ts} +111 -34
- package/packages/public-runtime/src/csp.ts +11 -11
- package/packages/public-runtime/src/customization-scripts.ts +28 -0
- package/packages/public-runtime/src/extension-contract.ts +73 -0
- package/packages/public-runtime/src/index.ts +4 -2
- package/packages/public-runtime/src/product.ts +57 -0
- package/packages/public-runtime/src/raw-html-policy.ts +35 -0
- package/packages/public-runtime/src/tenant.ts +14 -11
- package/pnpm-workspace.yaml +1 -0
- package/scripts/dev/checkout-watch.mjs +9 -0
- package/scripts/dev/file-browser.mjs +5 -1
- package/scripts/dev/transient-files.mjs +34 -0
- package/scripts/tot-dev.mjs +16 -3
- package/apps/storefront/src/components/commerce/FoxyLoader.astro +0 -23
- package/apps/storefront/src/pages/api/dev/activity.ts +0 -124
- package/apps/storefront/src/pages/api/dev/cli-signin-code.ts +0 -73
- package/apps/storefront/src/pages/api/dev/diff.ts +0 -68
- package/apps/storefront/src/pages/api/dev/file.ts +0 -57
- package/apps/storefront/src/pages/api/dev/locate.ts +0 -125
- package/apps/storefront/src/pages/api/dev/publish.ts +0 -128
- package/apps/storefront/src/pages/api/dev/tree.ts +0 -70
- package/apps/storefront/src/pages/api/dev/write.ts +0 -116
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Checkout-styling data model + published-artifact read plumbing.
|
|
3
3
|
*
|
|
4
|
-
* The
|
|
4
|
+
* The hosted checkout is styled by CSS pushed through the commerce service's theme
|
|
5
5
|
* endpoint (U11). This module owns the *source of truth* for that styling: a
|
|
6
6
|
* small, structured, tenant-editable descriptor that later units render into
|
|
7
7
|
* ASCII-safe CSS. It sits on the SAME versioned publish spine as theme/content
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* Two colocated artifacts under `tenants/<id>/`:
|
|
13
13
|
* - `checkout-style.json` — the structured `CheckoutStyle` descriptor (below).
|
|
14
14
|
* - `checkout.css` — an optional raw-CSS escape hatch a merchant edits
|
|
15
|
-
* directly; hosted + pushed to
|
|
15
|
+
* directly; hosted + pushed to checkout as-is (U11).
|
|
16
16
|
*
|
|
17
17
|
* Pure + dependency-free (node-free, edge-safe): the caller injects the same
|
|
18
18
|
* `KvGet` used for theme/content, keeping this unit-testable and storage-agnostic.
|
|
@@ -27,7 +27,7 @@ import { readPublishedArtifact, type KvGet } from "./customization-runtime.js";
|
|
|
27
27
|
* subset of the full theme palette — the checkout is a focused conversion
|
|
28
28
|
* surface, not the whole store. All values are CSS colors; hex is validated for
|
|
29
29
|
* WCAG contrast downstream (U13 gate), non-hex (e.g. `rgb(...)`) is passed
|
|
30
|
-
* through. Keep values ASCII-safe —
|
|
30
|
+
* through. Keep values ASCII-safe — the checkout cache pass isn't Unicode-safe.
|
|
31
31
|
*/
|
|
32
32
|
export interface CheckoutStyleColors {
|
|
33
33
|
/** Page background behind the checkout card. */
|
|
@@ -46,7 +46,7 @@ export interface CheckoutStyleColors {
|
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
48
|
* Typography for the checkout page. `fontFamily` is a full CSS font-family stack
|
|
49
|
-
* (a web-safe stack, or a
|
|
49
|
+
* (a web-safe stack, or a provider-supported webfont name plus fallbacks) — kept as
|
|
50
50
|
* an opaque string so a merchant can bring their own stack without a schema bump.
|
|
51
51
|
*/
|
|
52
52
|
export interface CheckoutStyleTypography {
|
|
@@ -65,7 +65,7 @@ export interface CheckoutStyleButton {
|
|
|
65
65
|
/**
|
|
66
66
|
* The structured checkout-styling descriptor — the `checkout-style.json`
|
|
67
67
|
* artifact. This is the model a merchant edits (U12) and that U11 renders into
|
|
68
|
-
* the CSS pushed to
|
|
68
|
+
* the CSS pushed to checkout. A published artifact is a `DeepPartial` of this
|
|
69
69
|
* (a merchant may set one field without restating the rest); `resolveCheckout
|
|
70
70
|
* Style` merges it over `DEFAULT_CHECKOUT_STYLE` so the resolved value is always
|
|
71
71
|
* complete.
|
|
@@ -114,12 +114,12 @@ export const DEFAULT_CHECKOUT_STYLE: CheckoutStyle = {
|
|
|
114
114
|
* stack (the checkout is body copy + a CTA, not display type)
|
|
115
115
|
* - button.radius ← `shape.radius.md` (the standard control radius)
|
|
116
116
|
* - logoUrl ← `brand.logoLight`, but ONLY when it is an absolute https URL —
|
|
117
|
-
*
|
|
117
|
+
* Checkout fetches the template server-side, so a repo-relative logo path
|
|
118
118
|
* (e.g. `/brand/logo.svg`) can't resolve there and is dropped.
|
|
119
119
|
*
|
|
120
120
|
* Input is a `DeepPartial` (a tenant's `theme.json` override is sparse), so every
|
|
121
121
|
* field falls back to `DEFAULT_CHECKOUT_STYLE` when the theme omits it — the result
|
|
122
|
-
* is always a complete `CheckoutStyle`. ASCII discipline (
|
|
122
|
+
* is always a complete `CheckoutStyle`. ASCII discipline (the checkout cache is not
|
|
123
123
|
* Unicode-safe): any derived string leaf that isn't `isAsciiSafe` is dropped in
|
|
124
124
|
* favor of the default, so a theme can never produce a checkout-blanking value.
|
|
125
125
|
* Pure + node-free.
|
|
@@ -193,7 +193,7 @@ export function checkoutStyleArtifactPath(tenantId: string): string {
|
|
|
193
193
|
/**
|
|
194
194
|
* Canonical path for the optional raw-CSS override artifact — the escape hatch a
|
|
195
195
|
* merchant edits directly (or that advanced templates ship). Sibling to the
|
|
196
|
-
* structured descriptor; hosted + pushed to
|
|
196
|
+
* structured descriptor; hosted + pushed to checkout verbatim by U11.
|
|
197
197
|
*/
|
|
198
198
|
export function checkoutStyleCssPath(tenantId: string): string {
|
|
199
199
|
return `tenants/${tenantId}/checkout.css`;
|
|
@@ -289,11 +289,11 @@ export async function resolveCheckoutStyleCss(
|
|
|
289
289
|
}
|
|
290
290
|
|
|
291
291
|
// ---------------------------------------------------------------------------
|
|
292
|
-
// CSS serialization (the artifact pushed to
|
|
292
|
+
// CSS serialization (the artifact pushed to checkout) — U11
|
|
293
293
|
// ---------------------------------------------------------------------------
|
|
294
294
|
|
|
295
295
|
/**
|
|
296
|
-
* True iff every character is 7-bit ASCII (code point < 128).
|
|
296
|
+
* True iff every character is 7-bit ASCII (code point < 128). The checkout
|
|
297
297
|
* template cache pass is NOT Unicode-safe — a non-ASCII byte in the cached
|
|
298
298
|
* checkout content yields a BLANK page. So the publisher (U11) MUST gate both
|
|
299
299
|
* the generated token CSS and any raw override through this before pushing a
|
|
@@ -319,7 +319,7 @@ const CHECKOUT_COLOR_VARS: Record<keyof CheckoutStyleColors, string> = {
|
|
|
319
319
|
|
|
320
320
|
/**
|
|
321
321
|
* Serialize a resolved `CheckoutStyle` into a `:root { … }` block of CSS custom
|
|
322
|
-
* properties (`--tot-checkout-*`) that
|
|
322
|
+
* properties (`--tot-checkout-*`) that the hosted checkout template references.
|
|
323
323
|
* Deterministic (stable key order) so an unchanged style produces byte-identical
|
|
324
324
|
* output — the publisher can hash it to skip no-op pushes. The `logoUrl`, when
|
|
325
325
|
* set, is emitted as `--tot-checkout-logo: url("…")`.
|
|
@@ -348,10 +348,10 @@ export function checkoutStyleToCss(style: CheckoutStyle): string {
|
|
|
348
348
|
|
|
349
349
|
/**
|
|
350
350
|
* Assemble the full checkout CSS served at the stable storefront URL that becomes
|
|
351
|
-
*
|
|
351
|
+
* checkout's `content_url`: the generated token block (`checkoutStyleToCss` over
|
|
352
352
|
* the resolved `CheckoutStyle`) followed by the tenant's optional raw-CSS override.
|
|
353
353
|
*
|
|
354
|
-
* ASCII discipline (
|
|
354
|
+
* ASCII discipline (the checkout cache pass is not Unicode-safe → blank checkout): the
|
|
355
355
|
* raw override is appended ONLY when it is `isAsciiSafe` — a non-ASCII override is
|
|
356
356
|
* DROPPED here so a bad byte can never blank the live checkout (the authoritative
|
|
357
357
|
* reject-at-publish gate is U13's promote GateReport; this is defense-in-depth at
|
|
@@ -1,35 +1,39 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Managed checkout cart engine — renderer-safe helpers (Track A).
|
|
3
3
|
*
|
|
4
|
-
* The storefront's
|
|
5
|
-
* loader
|
|
6
|
-
* HMAC-signed with the store's
|
|
7
|
-
* tampered. This module builds the fields for a catalog variant and
|
|
8
|
-
*
|
|
9
|
-
* in both `astro dev` (Node 20) and the Cloudflare Worker runtime.
|
|
4
|
+
* The storefront's job is intentionally small: include the managed checkout
|
|
5
|
+
* loader, and emit add-to-cart links/forms whose product name/value pairs are
|
|
6
|
+
* HMAC-signed with the store's cart-validation secret so the price can't be
|
|
7
|
+
* tampered with. This module builds the fields for a catalog variant and signs
|
|
8
|
+
* them with WebCrypto HMAC-SHA256 in signed mode — pure, dependency-free, and
|
|
9
|
+
* safe in both `astro dev` (Node 20) and the Cloudflare Worker runtime.
|
|
10
10
|
*
|
|
11
|
-
* The excise-tax line is NOT computed here — it materializes inside the
|
|
12
|
-
* cart via the store's pre-cart webhook + `tot_excise_tax` coupon (Track B
|
|
13
|
-
* tot-foxycart service). See docs / the excise-tax-checkout recipe.
|
|
11
|
+
* The excise-tax line is NOT computed here — it materializes inside the managed
|
|
12
|
+
* cart via the store's pre-cart webhook + `tot_excise_tax` coupon (Track B).
|
|
14
13
|
*
|
|
15
|
-
* Signing reference (
|
|
14
|
+
* Signing reference (checkout HMAC product verification):
|
|
16
15
|
* hash = HMAC-SHA256( productcode + fieldname + fieldvalue , storeSecret )
|
|
17
16
|
* link/form field name becomes `fieldname||hash` (value unchanged).
|
|
18
17
|
* Editable fields (quantity) hash the value `--OPEN--` and emit
|
|
19
18
|
* `fieldname||hash||open`. Cart-level params (cart, coupon, output, …) and
|
|
20
19
|
* `h:`/`x:`/`__`/`utm_`-prefixed params are never signed.
|
|
21
20
|
*/
|
|
22
|
-
import type {
|
|
21
|
+
import type {
|
|
22
|
+
CatalogProduct,
|
|
23
|
+
CatalogVariant,
|
|
24
|
+
ProductPurchaseOption,
|
|
25
|
+
SubscriptionPurchaseOption,
|
|
26
|
+
} from "./product.js";
|
|
23
27
|
|
|
24
28
|
/**
|
|
25
|
-
* Per-tenant
|
|
29
|
+
* Per-tenant managed-checkout wiring. Public config only — the signing SECRET is never
|
|
26
30
|
* stored here (it's an env-injected Worker binding, resolved per request). In
|
|
27
31
|
* `unsigned-demo` mode there is no secret at all: links go to a demo store that
|
|
28
32
|
* has cart validation turned OFF, so a developer can run the full cart locally
|
|
29
33
|
* (and on our shared preview worker) with zero secrets.
|
|
30
34
|
*/
|
|
31
|
-
export interface
|
|
32
|
-
/**
|
|
35
|
+
export interface ManagedCheckoutConfig {
|
|
36
|
+
/** Checkout store subdomain, e.g. "tot-demo". */
|
|
33
37
|
storeSubDomain: string;
|
|
34
38
|
/**
|
|
35
39
|
* Full cart/checkout host. Defaults to `${storeSubDomain}.foxycart.com`; set
|
|
@@ -42,26 +46,25 @@ export interface FoxyCommerceConfig {
|
|
|
42
46
|
*/
|
|
43
47
|
mode: "signed" | "unsigned-demo";
|
|
44
48
|
/**
|
|
45
|
-
* Optional per-tenant CSS to brand
|
|
49
|
+
* Optional per-tenant CSS to brand the in-page cart drawer. The checkout loader
|
|
46
50
|
* renders into our own page DOM (root `#fc`, light DOM — no iframe/shadow), so
|
|
47
51
|
* it inherits the theme's CSS custom properties and can be restyled with plain
|
|
48
|
-
* CSS. `<
|
|
52
|
+
* CSS. `<CartDrawerStyles>` already emits a brand baseline derived from the
|
|
49
53
|
* theme tokens; this string is APPENDED after it for bespoke overrides. Scope
|
|
50
|
-
* every rule under `#fc` and use `!important` to beat
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* themed in the Foxy admin template.
|
|
54
|
+
* every rule under `#fc` and use `!important` to beat the checkout loader's
|
|
55
|
+
* responsive styles. NB: this styles the in-page cart drawer only — the hosted
|
|
56
|
+
* checkout page is a separate origin, themed through checkout settings.
|
|
54
57
|
*/
|
|
55
|
-
|
|
58
|
+
cartDrawerCss?: string;
|
|
56
59
|
}
|
|
57
60
|
|
|
58
61
|
/** Resolved, request-scoped commerce facts stashed on `Astro.locals`. */
|
|
59
|
-
export interface
|
|
62
|
+
export interface ResolvedManagedCheckout extends ManagedCheckoutConfig {
|
|
60
63
|
/** `https://<storeDomain>` — the loader + cart origin (for CSP + links). */
|
|
61
64
|
origin: string;
|
|
62
65
|
/** `https://<storeDomain>/cart` — the add-to-cart form action / cart link. */
|
|
63
66
|
cartUrl: string;
|
|
64
|
-
/**
|
|
67
|
+
/** Managed checkout loader script URL. */
|
|
65
68
|
loaderSrc: string;
|
|
66
69
|
}
|
|
67
70
|
|
|
@@ -88,14 +91,22 @@ export interface VariantCartPayload {
|
|
|
88
91
|
quantityName: string;
|
|
89
92
|
}
|
|
90
93
|
|
|
91
|
-
|
|
94
|
+
export interface BuildVariantCartPayloadOptions {
|
|
95
|
+
mode: ManagedCheckoutConfig["mode"];
|
|
96
|
+
secret?: string;
|
|
97
|
+
purchaseOption?: ProductPurchaseOption;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const DEFAULT_CHECKOUT_HOST_SUFFIX = ".foxycart.com";
|
|
101
|
+
const SUBSCRIPTION_FREQUENCY_RE = /^(?:\d+(?:\.\d+)?|\.\d+)[dwmy]$/;
|
|
92
102
|
|
|
93
103
|
/** Resolve the full request-scoped commerce facts from a tenant's config. */
|
|
94
|
-
export function
|
|
95
|
-
config:
|
|
96
|
-
):
|
|
104
|
+
export function resolveManagedCheckout(
|
|
105
|
+
config: ManagedCheckoutConfig,
|
|
106
|
+
): ResolvedManagedCheckout {
|
|
97
107
|
const storeDomain =
|
|
98
|
-
config.storeDomain ??
|
|
108
|
+
config.storeDomain ??
|
|
109
|
+
`${config.storeSubDomain}${DEFAULT_CHECKOUT_HOST_SUFFIX}`;
|
|
99
110
|
const origin = `https://${storeDomain}`;
|
|
100
111
|
return {
|
|
101
112
|
...config,
|
|
@@ -108,19 +119,21 @@ export function resolveFoxyCommerce(
|
|
|
108
119
|
|
|
109
120
|
/**
|
|
110
121
|
* The raw (unsigned) cart fields for a variant: name, price, code, image, the
|
|
111
|
-
* variant's options, and an editable quantity. `code` is the
|
|
122
|
+
* variant's options, and an editable quantity. `code` is the checkout product code
|
|
112
123
|
* — the salt every other field is hashed against — so it must be stable per SKU.
|
|
113
124
|
*/
|
|
114
125
|
export function cartFieldsForVariant(
|
|
115
126
|
product: CatalogProduct,
|
|
116
127
|
variant: CatalogVariant,
|
|
128
|
+
purchaseOption?: ProductPurchaseOption,
|
|
117
129
|
): { code: string; fields: CartField[] } {
|
|
118
130
|
const code = variant.sku && variant.sku.length ? variant.sku : variant.id;
|
|
119
131
|
const image = product.images.find((i) => i.id === variant.image_id)?.url ??
|
|
120
132
|
product.images[0]?.url;
|
|
133
|
+
const price = purchaseOptionPrice(variant.price, purchaseOption);
|
|
121
134
|
const fields: CartField[] = [
|
|
122
135
|
{ name: "name", value: variant.title || product.title },
|
|
123
|
-
{ name: "price", value:
|
|
136
|
+
{ name: "price", value: price.toFixed(2) },
|
|
124
137
|
{ name: "code", value: code },
|
|
125
138
|
{ name: "quantity", value: "1", open: true },
|
|
126
139
|
];
|
|
@@ -130,9 +143,69 @@ export function cartFieldsForVariant(
|
|
|
130
143
|
for (const [optName, optVal] of Object.entries(variant.option_values)) {
|
|
131
144
|
if (optName && optVal) fields.push({ name: optName, value: optVal });
|
|
132
145
|
}
|
|
146
|
+
if (purchaseOption?.kind === "subscription") {
|
|
147
|
+
fields.push(...subscriptionCartFields(purchaseOption));
|
|
148
|
+
}
|
|
133
149
|
return { code, fields };
|
|
134
150
|
}
|
|
135
151
|
|
|
152
|
+
export function purchaseOptionPrice(
|
|
153
|
+
basePrice: number,
|
|
154
|
+
purchaseOption?: ProductPurchaseOption,
|
|
155
|
+
): number {
|
|
156
|
+
if (purchaseOption?.kind !== "subscription") return basePrice;
|
|
157
|
+
const adjustment = purchaseOption.priceAdjustment;
|
|
158
|
+
if (!adjustment) return basePrice;
|
|
159
|
+
if (!Number.isFinite(adjustment.value) || adjustment.value < 0) {
|
|
160
|
+
throw new Error(
|
|
161
|
+
`purchase option "${purchaseOption.id}" has invalid price adjustment`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
if (adjustment.type === "percent_off" && adjustment.value > 100) {
|
|
165
|
+
throw new Error(
|
|
166
|
+
`purchase option "${purchaseOption.id}" has invalid percent discount`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
if (adjustment.type === "amount_off" && adjustment.value > basePrice) {
|
|
170
|
+
throw new Error(
|
|
171
|
+
`purchase option "${purchaseOption.id}" has invalid amount discount`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
const adjusted =
|
|
175
|
+
adjustment.type === "percent_off"
|
|
176
|
+
? basePrice * (1 - adjustment.value / 100)
|
|
177
|
+
: basePrice - adjustment.value;
|
|
178
|
+
return Number(adjusted.toFixed(2));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function isSubscriptionFrequency(value: string): boolean {
|
|
182
|
+
if (!SUBSCRIPTION_FREQUENCY_RE.test(value)) return false;
|
|
183
|
+
const amount = Number.parseFloat(value.slice(0, -1));
|
|
184
|
+
return Number.isFinite(amount) && amount > 0;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function subscriptionCartFields(
|
|
188
|
+
option: SubscriptionPurchaseOption,
|
|
189
|
+
): CartField[] {
|
|
190
|
+
if (!isSubscriptionFrequency(option.frequency)) {
|
|
191
|
+
throw new Error(
|
|
192
|
+
`subscription purchase option "${option.id}" has invalid frequency "${option.frequency}"`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const fields: CartField[] = [
|
|
197
|
+
{ name: "Purchase option", value: option.label },
|
|
198
|
+
{ name: "sub_frequency", value: option.frequency },
|
|
199
|
+
];
|
|
200
|
+
if (option.startDate) {
|
|
201
|
+
fields.push({ name: "sub_startdate", value: option.startDate });
|
|
202
|
+
}
|
|
203
|
+
if (option.endDate) {
|
|
204
|
+
fields.push({ name: "sub_enddate", value: option.endDate });
|
|
205
|
+
}
|
|
206
|
+
return fields;
|
|
207
|
+
}
|
|
208
|
+
|
|
136
209
|
/** Lowercase hex HMAC-SHA256(message) under `secret`, via WebCrypto. */
|
|
137
210
|
async function hmacHex(secret: string, message: string): Promise<string> {
|
|
138
211
|
const enc = new TextEncoder();
|
|
@@ -151,7 +224,7 @@ async function hmacHex(secret: string, message: string): Promise<string> {
|
|
|
151
224
|
|
|
152
225
|
/**
|
|
153
226
|
* Sign one field for product `code`. Editable fields hash the sentinel
|
|
154
|
-
* `--OPEN--` and gain the `||open` suffix so
|
|
227
|
+
* `--OPEN--` and gain the `||open` suffix so checkout accepts a user-entered value.
|
|
155
228
|
*/
|
|
156
229
|
async function signField(
|
|
157
230
|
secret: string,
|
|
@@ -174,9 +247,13 @@ async function signField(
|
|
|
174
247
|
export async function buildVariantCartPayload(
|
|
175
248
|
product: CatalogProduct,
|
|
176
249
|
variant: CatalogVariant,
|
|
177
|
-
opts:
|
|
250
|
+
opts: BuildVariantCartPayloadOptions,
|
|
178
251
|
): Promise<VariantCartPayload> {
|
|
179
|
-
const { code, fields } = cartFieldsForVariant(
|
|
252
|
+
const { code, fields } = cartFieldsForVariant(
|
|
253
|
+
product,
|
|
254
|
+
variant,
|
|
255
|
+
opts.purchaseOption,
|
|
256
|
+
);
|
|
180
257
|
const signed = opts.mode === "signed";
|
|
181
258
|
if (signed && !opts.secret) {
|
|
182
259
|
throw new Error(
|
|
@@ -22,8 +22,8 @@ export interface CspOptions {
|
|
|
22
22
|
/** Sandboxed-widget origins. */
|
|
23
23
|
frameSrc?: readonly string[];
|
|
24
24
|
/**
|
|
25
|
-
* Extra `form-action` targets. Default is `'self'`;
|
|
26
|
-
*
|
|
25
|
+
* Extra `form-action` targets. Default is `'self'`; add-to-cart forms POST
|
|
26
|
+
* cross-origin to the checkout store's `/cart`, so that origin is added here.
|
|
27
27
|
*/
|
|
28
28
|
formAction?: readonly string[];
|
|
29
29
|
/** Reporting group name for `report-to` (paired with a Reporting-Endpoints header). */
|
|
@@ -78,13 +78,13 @@ export const STOREFRONT_CSP_ALLOWLIST = {
|
|
|
78
78
|
} as const;
|
|
79
79
|
|
|
80
80
|
/**
|
|
81
|
-
* CSP additions for a
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
81
|
+
* CSP additions for a managed-checkout tenant. The loader comes from the checkout
|
|
82
|
+
* CDN; cart requests, frames, and add-to-cart form POSTs hit the store's own
|
|
83
|
+
* origin (`<store>.foxycart.com` or a branded checkout domain). Merge these into
|
|
84
|
+
* `buildCsp` per request when `locals.commerce` is present so the strict policy
|
|
85
|
+
* still passes.
|
|
86
86
|
*/
|
|
87
|
-
export function
|
|
87
|
+
export function checkoutCspAdditions(storeOrigin: string): {
|
|
88
88
|
scriptSrc: string[];
|
|
89
89
|
styleSrc: string[];
|
|
90
90
|
fontSrc: string[];
|
|
@@ -94,9 +94,9 @@ export function foxyCspAdditions(storeOrigin: string): {
|
|
|
94
94
|
imgSrc: string[];
|
|
95
95
|
} {
|
|
96
96
|
const cdn = "https://cdn.foxycart.com";
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
97
|
+
// The checkout loader pulls its own JS, CSS, and fonts from the CDN and renders
|
|
98
|
+
// cart/checkout from the store origin, so it needs script/style/font from the
|
|
99
|
+
// CDN and connect/frame/form to the store.
|
|
100
100
|
return {
|
|
101
101
|
scriptSrc: [cdn, storeOrigin],
|
|
102
102
|
styleSrc: [cdn, storeOrigin],
|
|
@@ -18,6 +18,34 @@ export type ScriptSlot = (typeof SCRIPT_SLOTS)[number];
|
|
|
18
18
|
export type ScriptStrategy = (typeof SCRIPT_STRATEGIES)[number];
|
|
19
19
|
export type ScriptIsolation = (typeof SCRIPT_ISOLATIONS)[number];
|
|
20
20
|
|
|
21
|
+
export interface ScriptSlotDescriptor {
|
|
22
|
+
slot: ScriptSlot;
|
|
23
|
+
label: string;
|
|
24
|
+
description: string;
|
|
25
|
+
defaultStrategy: ScriptStrategy;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const SCRIPT_SLOT_DESCRIPTORS: Record<ScriptSlot, ScriptSlotDescriptor> = {
|
|
29
|
+
"product.aside": {
|
|
30
|
+
slot: "product.aside",
|
|
31
|
+
label: "Product Aside",
|
|
32
|
+
description: "A sandboxed widget beside product purchase controls.",
|
|
33
|
+
defaultStrategy: "on-interaction",
|
|
34
|
+
},
|
|
35
|
+
"home.section": {
|
|
36
|
+
slot: "home.section",
|
|
37
|
+
label: "Home Section",
|
|
38
|
+
description: "A sandboxed widget between composed homepage sections.",
|
|
39
|
+
defaultStrategy: "on-idle",
|
|
40
|
+
},
|
|
41
|
+
"global.footer": {
|
|
42
|
+
slot: "global.footer",
|
|
43
|
+
label: "Global Footer",
|
|
44
|
+
description: "A sandboxed widget near the page footer on every route.",
|
|
45
|
+
defaultStrategy: "defer",
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
21
49
|
export interface ScriptRegistration {
|
|
22
50
|
src: string;
|
|
23
51
|
slot: ScriptSlot;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public storefront extension contract.
|
|
3
|
+
*
|
|
4
|
+
* Astro components remain platform-owned implementation detail; tenants compose
|
|
5
|
+
* with versioned JSON blocks and sources. This file is renderer-safe and can be
|
|
6
|
+
* used by docs, validators, CLI checks, and the storefront app itself.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const BLOCK_PALETTE_CURRENT_CONTRACT = "block-palette@3" as const;
|
|
10
|
+
export const BLOCK_PALETTE_SUPPORTED_CONTRACTS = [
|
|
11
|
+
"block-palette@1",
|
|
12
|
+
"block-palette@2",
|
|
13
|
+
BLOCK_PALETTE_CURRENT_CONTRACT,
|
|
14
|
+
] as const;
|
|
15
|
+
|
|
16
|
+
export type BlockPaletteContract =
|
|
17
|
+
(typeof BLOCK_PALETTE_SUPPORTED_CONTRACTS)[number];
|
|
18
|
+
|
|
19
|
+
export const FEATURED_PRODUCT_SOURCE_KEYS = [
|
|
20
|
+
"featured",
|
|
21
|
+
"newest",
|
|
22
|
+
"bestSelling",
|
|
23
|
+
"onSale",
|
|
24
|
+
] as const;
|
|
25
|
+
|
|
26
|
+
export type FeaturedProductSourceKey =
|
|
27
|
+
(typeof FEATURED_PRODUCT_SOURCE_KEYS)[number];
|
|
28
|
+
|
|
29
|
+
export type ProductSectionSource =
|
|
30
|
+
| FeaturedProductSourceKey
|
|
31
|
+
| { collection: string }
|
|
32
|
+
| { tag: string }
|
|
33
|
+
| { featured: true }
|
|
34
|
+
| { newest: true }
|
|
35
|
+
| { bestSelling: true }
|
|
36
|
+
| { onSale: true }
|
|
37
|
+
| { handles: string[] }
|
|
38
|
+
| { related: string }
|
|
39
|
+
| { recentlyViewed: true };
|
|
40
|
+
|
|
41
|
+
export function isFeaturedProductSourceKey(
|
|
42
|
+
value: unknown,
|
|
43
|
+
): value is FeaturedProductSourceKey {
|
|
44
|
+
return (
|
|
45
|
+
typeof value === "string" &&
|
|
46
|
+
(FEATURED_PRODUCT_SOURCE_KEYS as readonly string[]).includes(value)
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Normalize the authored shortcut syntax into the object shape consumed by
|
|
52
|
+
* section renderers. `handles` is accepted as a backwards-compatible sibling
|
|
53
|
+
* prop for existing `featured_products` blocks.
|
|
54
|
+
*/
|
|
55
|
+
export function normalizeProductSectionSource(
|
|
56
|
+
source?: ProductSectionSource,
|
|
57
|
+
handles?: string[],
|
|
58
|
+
): Exclude<ProductSectionSource, FeaturedProductSourceKey> {
|
|
59
|
+
if (handles?.length) return { handles };
|
|
60
|
+
if (!source) return { featured: true };
|
|
61
|
+
if (typeof source !== "string") return source;
|
|
62
|
+
switch (source) {
|
|
63
|
+
case "newest":
|
|
64
|
+
return { newest: true };
|
|
65
|
+
case "bestSelling":
|
|
66
|
+
return { bestSelling: true };
|
|
67
|
+
case "onSale":
|
|
68
|
+
return { onSale: true };
|
|
69
|
+
case "featured":
|
|
70
|
+
default:
|
|
71
|
+
return { featured: true };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
// preview / script types the renderer needs. Read/apply only.
|
|
16
16
|
export * from "./product.js";
|
|
17
17
|
export * from "./tenant.js";
|
|
18
|
-
export * from "./
|
|
19
|
-
export * from "./
|
|
18
|
+
export * from "./checkout.js";
|
|
19
|
+
export * from "./checkout-orders.js";
|
|
20
20
|
export * from "./capabilities.js";
|
|
21
21
|
export * from "./checkout-style.js";
|
|
22
22
|
export * from "./checkout-style-publish.js";
|
|
@@ -29,6 +29,8 @@ export * from "./customization-runtime.js";
|
|
|
29
29
|
export * from "./customization-versioning.js";
|
|
30
30
|
export * from "./customization-scripts.js";
|
|
31
31
|
export * from "./customization-reconcile.js";
|
|
32
|
+
export * from "./extension-contract.js";
|
|
33
|
+
export * from "./raw-html-policy.js";
|
|
32
34
|
export * from "./tenant-assets.js";
|
|
33
35
|
export * from "./binary-path.js";
|
|
34
36
|
export * from "./hot-standby-readiness.js";
|
|
@@ -43,6 +43,49 @@ export interface ProductOption {
|
|
|
43
43
|
values: string[];
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
export type PurchaseOptionKind = "one_time" | "subscription";
|
|
47
|
+
|
|
48
|
+
export interface OneTimePurchaseOption {
|
|
49
|
+
id: string;
|
|
50
|
+
kind: "one_time";
|
|
51
|
+
label: string;
|
|
52
|
+
description?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type PurchaseOptionPriceAdjustment =
|
|
56
|
+
| { type: "percent_off"; value: number }
|
|
57
|
+
| { type: "amount_off"; value: number };
|
|
58
|
+
|
|
59
|
+
export interface SubscriptionPurchaseOption {
|
|
60
|
+
id: string;
|
|
61
|
+
kind: "subscription";
|
|
62
|
+
label: string;
|
|
63
|
+
description?: string;
|
|
64
|
+
/** Shopper-facing savings copy, e.g. "Save 15%". Display only. */
|
|
65
|
+
savingsLabel?: string;
|
|
66
|
+
/** Optional signed-cart price adjustment, e.g. 15% off every recurring order. */
|
|
67
|
+
priceAdjustment?: PurchaseOptionPriceAdjustment;
|
|
68
|
+
/**
|
|
69
|
+
* Recurrence interval accepted by the checkout engine, e.g. "2w", "1m",
|
|
70
|
+
* "60d", "1y", ".5m".
|
|
71
|
+
*/
|
|
72
|
+
frequency: string;
|
|
73
|
+
/** Optional future start/free-trial date or fixed renewal day. */
|
|
74
|
+
startDate?: string;
|
|
75
|
+
/** Optional subscription/payment-plan stop date. */
|
|
76
|
+
endDate?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type ProductPurchaseOption =
|
|
80
|
+
| OneTimePurchaseOption
|
|
81
|
+
| SubscriptionPurchaseOption;
|
|
82
|
+
|
|
83
|
+
export const DEFAULT_ONE_TIME_PURCHASE_OPTION: OneTimePurchaseOption = {
|
|
84
|
+
id: "one_time",
|
|
85
|
+
kind: "one_time",
|
|
86
|
+
label: "One-time purchase",
|
|
87
|
+
};
|
|
88
|
+
|
|
46
89
|
export interface CatalogImage {
|
|
47
90
|
id: string;
|
|
48
91
|
/** Final URL after rehost (R2/Cloudflare Images) OR original Shopify CDN URL. */
|
|
@@ -111,6 +154,12 @@ export interface CatalogProduct {
|
|
|
111
154
|
options: ProductOption[];
|
|
112
155
|
images: CatalogImage[];
|
|
113
156
|
variants: CatalogVariant[];
|
|
157
|
+
/**
|
|
158
|
+
* Optional purchase modes for this product. Omitted/empty means a standard
|
|
159
|
+
* one-time purchase. Subscription options are authored as catalog data; the
|
|
160
|
+
* storefront converts them into provider-specific cart parameters at render.
|
|
161
|
+
*/
|
|
162
|
+
purchase_options?: ProductPurchaseOption[];
|
|
114
163
|
price_range: PriceRange;
|
|
115
164
|
/** Collection handles (or ids) this product belongs to. */
|
|
116
165
|
collections: string[];
|
|
@@ -122,6 +171,14 @@ export interface CatalogProduct {
|
|
|
122
171
|
source_checksum: string;
|
|
123
172
|
}
|
|
124
173
|
|
|
174
|
+
export function normalizePurchaseOptions(
|
|
175
|
+
product: Pick<CatalogProduct, "purchase_options">,
|
|
176
|
+
): ProductPurchaseOption[] {
|
|
177
|
+
return product.purchase_options?.length
|
|
178
|
+
? product.purchase_options
|
|
179
|
+
: [DEFAULT_ONE_TIME_PURCHASE_OPTION];
|
|
180
|
+
}
|
|
181
|
+
|
|
125
182
|
/**
|
|
126
183
|
* A product review (full content), pulled from the merchant's reviews app
|
|
127
184
|
* (e.g. Stamped.io public widget API) during migration. The aggregate
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Policy for the raw tenant-authored HTML path.
|
|
3
|
+
*
|
|
4
|
+
* Raw HTML is a useful migration/marketing bridge, but it bypasses Astro Layout
|
|
5
|
+
* composition. Regulated tenants must stay on a platform-composed path
|
|
6
|
+
* so age/identity verification, warnings, taxes, checkout signing, and publish
|
|
7
|
+
* gates remain non-removable.
|
|
8
|
+
*/
|
|
9
|
+
import type { TenantConfig } from "./tenant.js";
|
|
10
|
+
import { resolveComplianceObligations } from "./capabilities.js";
|
|
11
|
+
|
|
12
|
+
export interface RawHtmlPolicyDecision {
|
|
13
|
+
allowed: boolean;
|
|
14
|
+
regulated: boolean;
|
|
15
|
+
reason?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function rawHtmlPolicy(
|
|
19
|
+
tenant: Pick<TenantConfig, "siteType" | "compliance">,
|
|
20
|
+
): RawHtmlPolicyDecision {
|
|
21
|
+
const obligations = resolveComplianceObligations(tenant);
|
|
22
|
+
const regulated =
|
|
23
|
+
obligations.ageVerification.required || obligations.exciseTax.required;
|
|
24
|
+
|
|
25
|
+
if (regulated) {
|
|
26
|
+
return {
|
|
27
|
+
allowed: false,
|
|
28
|
+
regulated,
|
|
29
|
+
reason:
|
|
30
|
+
"Raw HTML bypasses platform Layout compliance; regulated commerce tenants must use block composition or an extracted runtime.",
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return { allowed: true, regulated };
|
|
35
|
+
}
|