@tokenoftrust/storefront-runner 3.7.2 → 3.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "3.7.2",
3
+ "version": "3.9.0",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
6
6
  "packageManager": "pnpm@11.9.0",
@@ -197,20 +197,38 @@ export function seoDeclarationProblems(seo) {
197
197
  }
198
198
 
199
199
  /** Every `shipping` key (mirrors packages/public-runtime/src/declared-tenant-config.mjs). */
200
- export const SHIPPING_KEYS = Object.freeze(["flatRate", "freeAtOrAbove"]);
200
+ export const SHIPPING_KEYS = Object.freeze(["tiers"]);
201
+
202
+ /** Every key of one `shipping.tiers` entry. */
203
+ export const SHIPPING_TIER_KEYS = Object.freeze(["atOrAbove", "rate", "label"]);
201
204
 
202
205
  /** Largest shipping amount a declaration may state, in the cart's currency. */
203
206
  export const MAX_SHIPPING_AMOUNT = 100000;
204
207
 
208
+ /** Most tiers one policy may declare. */
209
+ export const MAX_SHIPPING_TIERS = 10;
210
+
211
+ /** Longest shipping-line label, in characters. */
212
+ export const MAX_SHIPPING_LABEL_LENGTH = 100;
213
+
205
214
  /** @param {unknown} value */
206
215
  const isShippingAmount = (value) =>
207
216
  typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= MAX_SHIPPING_AMOUNT
208
217
  && Math.abs(value * 100 - Math.round(value * 100)) < 1e-6;
209
218
 
219
+ /** @param {unknown} value */
220
+ const isShippingLabel = (value) =>
221
+ typeof value === "string" && value.trim() === value && value.length > 0
222
+ && [...value].length <= MAX_SHIPPING_LABEL_LENGTH && !/[\u0000-\u001f\u007f]/.test(value);
223
+
210
224
  /**
211
225
  * What is wrong with a declared `shipping` value, one sentence per problem; `[]` when it is
212
226
  * valid. A policy with any problem is refused whole, so checkout never charges half of one.
213
227
  *
228
+ * Tiers are ordered by the item subtotal they start at. The first starts at 0 and states no
229
+ * `atOrAbove`; each later tier's `atOrAbove` is above the one before it and is inclusive, so an
230
+ * order whose subtotal equals it pays that tier's `rate`.
231
+ *
214
232
  * @param {unknown} shipping
215
233
  * @returns {string[]}
216
234
  */
@@ -221,12 +239,38 @@ export function shippingDeclarationProblems(shipping) {
221
239
  for (const key of Object.keys(source)) {
222
240
  if (!SHIPPING_KEYS.includes(key)) problems.push(`\`shipping.${key}\` is not a shipping key (${SHIPPING_KEYS.join(", ")})`);
223
241
  }
224
- if (!isShippingAmount(source.flatRate)) {
225
- problems.push(`\`shipping.flatRate\` must be an amount from 0 to ${MAX_SHIPPING_AMOUNT} with at most two decimal places`);
226
- }
227
- if (source.freeAtOrAbove !== undefined && !(isShippingAmount(source.freeAtOrAbove) && /** @type {number} */ (source.freeAtOrAbove) > 0)) {
228
- problems.push(`\`shipping.freeAtOrAbove\` must be an amount above 0 and at most ${MAX_SHIPPING_AMOUNT} with at most two decimal places`);
242
+ const tiers = source.tiers;
243
+ if (!Array.isArray(tiers) || tiers.length === 0 || tiers.length > MAX_SHIPPING_TIERS) {
244
+ problems.push(`\`shipping.tiers\` must be a list of 1 to ${MAX_SHIPPING_TIERS} tiers`);
245
+ return problems;
229
246
  }
247
+ let previous = 0;
248
+ tiers.forEach((tier, index) => {
249
+ const at = `shipping.tiers[${index}]`;
250
+ if (!isPlainObject(tier)) {
251
+ problems.push(`\`${at}\` must be an object`);
252
+ return;
253
+ }
254
+ const entry = /** @type {Record<string, unknown>} */ (tier);
255
+ for (const key of Object.keys(entry)) {
256
+ if (!SHIPPING_TIER_KEYS.includes(key)) problems.push(`\`${at}.${key}\` is not a tier key (${SHIPPING_TIER_KEYS.join(", ")})`);
257
+ }
258
+ if (!isShippingAmount(entry.rate)) {
259
+ problems.push(`\`${at}.rate\` must be an amount from 0 to ${MAX_SHIPPING_AMOUNT} with at most two decimal places`);
260
+ }
261
+ if (entry.label !== undefined && !isShippingLabel(entry.label)) {
262
+ problems.push(`\`${at}.label\` must be text of 1 to ${MAX_SHIPPING_LABEL_LENGTH} characters with no surrounding spaces or line breaks`);
263
+ }
264
+ if (index === 0) {
265
+ if (entry.atOrAbove !== undefined) problems.push(`\`${at}\` starts at 0, so it states no \`atOrAbove\``);
266
+ return;
267
+ }
268
+ if (!(isShippingAmount(entry.atOrAbove) && /** @type {number} */ (entry.atOrAbove) > previous)) {
269
+ problems.push(`\`${at}.atOrAbove\` must be an amount above ${previous}, where the tier before it starts, and at most ${MAX_SHIPPING_AMOUNT} with at most two decimal places`);
270
+ return;
271
+ }
272
+ previous = /** @type {number} */ (entry.atOrAbove);
273
+ });
230
274
  return problems;
231
275
  }
232
276
 
@@ -702,7 +746,7 @@ export function validateDeclaredConfig(config, file = ".tot/config.json") {
702
746
  const problems = shippingDeclarationProblems(config.shipping);
703
747
  if (problems.length > 0) {
704
748
  out.push(mk(ERROR, "config-shipping", `${file} shipping`, shippingProblemMessage(problems),
705
- 'declare e.g. `"shipping": { "flatRate": 5.99, "freeAtOrAbove": 100 }`, or remove `shipping` to charge none'));
749
+ 'declare e.g. `"shipping": { "tiers": [ { "rate": 5.99 }, { "atOrAbove": 100, "rate": 0, "label": "Free shipping" } ] }`, or remove `shipping` to charge none'));
706
750
  }
707
751
  }
708
752
  return out;
@@ -224,12 +224,19 @@ export class ShippingPolicyUnavailableError extends Error {
224
224
  }
225
225
  }
226
226
 
227
- /** A shipping policy in the minor units of one currency. */
227
+ /** One shipping tier in the minor units of one currency. */
228
+ export interface ShippingTierMinor {
229
+ /** Item subtotal the tier starts at, inclusive; 0 for the first tier. */
230
+ fromMinor: number;
231
+ rateMinor: number;
232
+ label: string | null;
233
+ }
234
+
235
+ /** A shipping policy in the minor units of one currency, tiers ascending by `fromMinor`. */
228
236
  export interface ShippingPolicyMinor {
229
237
  currency: string;
230
238
  digits: number;
231
- flatMinor: number;
232
- freeAtOrAboveMinor: number | null;
239
+ tiers: ShippingTierMinor[];
233
240
  }
234
241
 
235
242
  function minorUnits(amount: number, digits: number): number {
@@ -255,27 +262,51 @@ export function shippingPolicyMinor(policy: ShippingPolicy, currency: string): S
255
262
  throw new ShippingPolicyUnavailableError();
256
263
  }
257
264
  if (digits === undefined) throw new ShippingPolicyUnavailableError();
258
- const freeAtOrAboveMinor = policy.freeAtOrAbove === undefined ? null : minorUnits(policy.freeAtOrAbove, digits);
259
- if (freeAtOrAboveMinor === 0) throw new ShippingPolicyUnavailableError();
260
- return { currency, digits, flatMinor: minorUnits(policy.flatRate, digits), freeAtOrAboveMinor };
265
+ const declared = Array.isArray(policy?.tiers) ? policy.tiers : [];
266
+ if (declared.length === 0) throw new ShippingPolicyUnavailableError();
267
+ const tiers = declared.map((tier, index): ShippingTierMinor => {
268
+ if ((index === 0) !== (tier.atOrAbove === undefined)) throw new ShippingPolicyUnavailableError();
269
+ return {
270
+ fromMinor: index === 0 ? 0 : minorUnits(tier.atOrAbove!, digits),
271
+ rateMinor: minorUnits(tier.rate, digits),
272
+ label: tier.label ?? null,
273
+ };
274
+ });
275
+ // Tiers that collapse onto one minor-unit start (or run backwards) cannot say which applies.
276
+ if (tiers.some((tier, index) => index > 0 && tier.fromMinor <= tiers[index - 1]!.fromMinor)) {
277
+ throw new ShippingPolicyUnavailableError();
278
+ }
279
+ return { currency, digits, tiers };
261
280
  }
262
281
 
263
- /** Shipping owed on an order whose item subtotal is `subtotalMinor`: the flat rate, or 0 at or above the free threshold. */
282
+ /**
283
+ * The tier an order whose item subtotal is `subtotalMinor` pays: the last one whose start it
284
+ * reaches. A subtotal exactly at a tier's `atOrAbove` pays that tier.
285
+ */
286
+ export function shippingTierFor(policy: ShippingPolicy, currency: string, subtotalMinor: number): ShippingTierMinor {
287
+ const { tiers } = shippingPolicyMinor(policy, currency);
288
+ return tiers.reduce((paid, tier) => (subtotalMinor >= tier.fromMinor ? tier : paid), tiers[0]!);
289
+ }
290
+
291
+ /** Shipping owed, in minor units, on an order whose item subtotal is `subtotalMinor`. */
264
292
  export function shippingMinorFor(policy: ShippingPolicy, currency: string, subtotalMinor: number): number {
265
- const minor = shippingPolicyMinor(policy, currency);
266
- return minor.freeAtOrAboveMinor !== null && subtotalMinor >= minor.freeAtOrAboveMinor ? 0 : minor.flatMinor;
293
+ return shippingTierFor(policy, currency, subtotalMinor).rateMinor;
267
294
  }
268
295
 
269
296
  /**
270
- * The `shipping_policy` option value, e.g. `currency=USD;flat=5.99;free_at=100.00` (`free_at`
271
- * only when declared). Amounts carry exactly the currency's minor-unit digits; the checkout
272
- * store's shipping code parses this shape and refuses any other.
297
+ * The `shipping_policy` option value: `currency=<code>` then one `tier=<from>,<rate>[,<label>]`
298
+ * per tier, ascending, e.g.
299
+ * `currency=USD;tier=0.00,14.99,Flat%20rate%20shipping;tier=100.00,7.00,FREE%20shipping`.
300
+ * Amounts carry exactly the currency's minor-unit digits; a label is `encodeURIComponent`-encoded
301
+ * so it cannot contain `,` or `;`. The checkout store's shipping code parses this shape and
302
+ * refuses any other.
273
303
  */
274
304
  export function shippingPolicyOptionValue(policy: ShippingPolicy, currency: string): string {
275
305
  const minor = shippingPolicyMinor(policy, currency);
276
306
  const amount = (value: number) => (value / 10 ** minor.digits).toFixed(minor.digits);
277
- return [`currency=${currency}`, `flat=${amount(minor.flatMinor)}`,
278
- ...(minor.freeAtOrAboveMinor === null ? [] : [`free_at=${amount(minor.freeAtOrAboveMinor)}`])].join(";");
307
+ return [`currency=${currency}`, ...minor.tiers.map((tier) =>
308
+ `tier=${[amount(tier.fromMinor), amount(tier.rateMinor), ...(tier.label === null ? [] : [encodeURIComponent(tier.label)])].join(",")}`)]
309
+ .join(";");
279
310
  }
280
311
 
281
312
  /** A verification correlation handle as a cart may carry it (the commerce adapter's shape). */
@@ -298,16 +329,46 @@ export const VISIBLE_CART_FIELDS = ["Purchase option"] as const;
298
329
 
299
330
  export type CartFieldVisibility = "native" | "internal" | "visible" | "variant-option" | "undeclared";
300
331
 
332
+ /**
333
+ * Characters the checkout store does not keep in a field name, each mapped to a look-alike it
334
+ * keeps. The store validates the name as it rewrites it, not as it was signed: ASCII space, `.`
335
+ * and `[` become `_` (a `[…]` pair turns the field into an array), `||` ends the name, and a name
336
+ * holding `&` or `"` is dropped from validation. Measured 2026-10-23 against tot-preview.foxycart.com
337
+ * by submitting each name with a non-matching hash and reading the name back from its
338
+ * "Cart Validation Error: <name>" refusal. Every other character (NBSP, `/`, `'`, `+`, `-`,
339
+ * non-ASCII) came back unchanged.
340
+ */
341
+ const CHECKOUT_NAME_LOOKALIKES: Readonly<Record<string, string>> = {
342
+ " ": " ",
343
+ ".": "․",
344
+ "[": "(",
345
+ "]": ")",
346
+ "&": "&",
347
+ '"': "”",
348
+ "|": "¦",
349
+ };
350
+ const CHECKOUT_NAME_REWRITTEN = /[ .[\]&"|]/g;
351
+
352
+ /**
353
+ * The name a cart field is emitted AND signed under. The checkout store validates this exact
354
+ * string, and the shopper's cart line shows it, so "Nic Strength" stays readable as
355
+ * "Nic Strength" (with a no-break space) instead of failing as `Nic_Strength`.
356
+ */
357
+ export function checkoutFieldName(name: string): string {
358
+ return name.trim().replace(CHECKOUT_NAME_REWRITTEN, (ch) => CHECKOUT_NAME_LOOKALIKES[ch] ?? ch);
359
+ }
360
+
301
361
  /**
302
362
  * How a field emitted by `cartFieldsForVariant` reaches the shopper. A field
303
363
  * that is none of native, internal, declared-visible, or one of the variant's
304
364
  * own options is `undeclared`: it would render on the cart line unreviewed.
365
+ * `name` is the emitted name (`checkoutFieldName`).
305
366
  */
306
367
  export function cartFieldVisibility(name: string, variant: Pick<CatalogVariant, "option_values">): CartFieldVisibility {
307
368
  if ((NATIVE_CART_FIELDS as readonly string[]).includes(name)) return "native";
308
369
  if ((INTERNAL_CART_FIELDS as readonly string[]).includes(name)) return "internal";
309
- if ((VISIBLE_CART_FIELDS as readonly string[]).includes(name)) return "visible";
310
- if (Object.prototype.hasOwnProperty.call(variant.option_values, name)) return "variant-option";
370
+ if (VISIBLE_CART_FIELDS.some((visible) => checkoutFieldName(visible) === name)) return "visible";
371
+ if (Object.keys(variant.option_values).some((option) => checkoutFieldName(option) === name)) return "variant-option";
311
372
  return "undeclared";
312
373
  }
313
374
 
@@ -445,7 +506,9 @@ export function cartFieldsForVariant(
445
506
  // Variant options (Flavor, Nicotine strength, …) travel as product options so
446
507
  // the cart line reflects the exact variant the shopper picked.
447
508
  for (const [optName, optVal] of Object.entries(variant.option_values)) {
448
- if (optName && optVal) fields.push({ name: optName, value: optVal });
509
+ const name = checkoutFieldName(optName);
510
+ const value = optVal.trim();
511
+ if (name && value) fields.push({ name, value });
449
512
  }
450
513
  // Bind the SELECTED purchase option into the signed payload so a shopper can't
451
514
  // switch one-time↔subscription (or between plans) after the form is signed —
@@ -455,7 +518,7 @@ export function cartFieldsForVariant(
455
518
  // on every non-subscription cart. Any authored option (a real one-time plan or
456
519
  // a subscription) is emitted and signed → modifying it fails validation.
457
520
  if (purchaseOption && !isDefaultOneTimeOption(purchaseOption)) {
458
- fields.push({ name: "Purchase option", value: purchaseOption.label });
521
+ fields.push({ name: checkoutFieldName("Purchase option"), value: purchaseOption.label });
459
522
  }
460
523
  if (purchaseOption?.kind === "subscription") {
461
524
  fields.push(...subscriptionCartFields(purchaseOption));
@@ -586,8 +649,14 @@ function constantTimeHexEqual(expected: string, presented: string): boolean {
586
649
  return acc === 0;
587
650
  }
588
651
 
652
+ /** A field as the checkout receives it unsigned: under its `checkoutFieldName`. */
653
+ function unsignedField(field: CartField): RenderedCartField {
654
+ return { name: checkoutFieldName(field.name), value: field.value };
655
+ }
656
+
589
657
  /**
590
- * Sign one field for product `code`. Editable fields hash the sentinel
658
+ * Sign one field for product `code`. The hashed name and the emitted name are the
659
+ * same `checkoutFieldName`, so they cannot diverge. Editable fields hash the sentinel
591
660
  * `--OPEN--` and gain the `||open` suffix so checkout accepts a user-entered value.
592
661
  */
593
662
  async function signField(
@@ -595,11 +664,12 @@ async function signField(
595
664
  code: string,
596
665
  field: CartField,
597
666
  ): Promise<RenderedCartField> {
667
+ const fieldName = checkoutFieldName(field.name);
598
668
  const valueToHash = field.open ? "--OPEN--" : field.value;
599
- const hash = await hmacHex(secret, code + field.name + valueToHash);
669
+ const hash = await hmacHex(secret, code + fieldName + valueToHash);
600
670
  const name = field.open
601
- ? `${field.name}||${hash}||open`
602
- : `${field.name}||${hash}`;
671
+ ? `${fieldName}||${hash}||open`
672
+ : `${fieldName}||${hash}`;
603
673
  return { name, value: field.value };
604
674
  }
605
675
 
@@ -642,7 +712,7 @@ export async function buildVariantCartPayload(
642
712
 
643
713
  const rendered: RenderedCartField[] = signed
644
714
  ? await Promise.all(fields.map((f) => signField(opts.secret!, code, f)))
645
- : fields.map((f) => ({ name: f.name, value: f.value }));
715
+ : fields.map(unsignedField);
646
716
 
647
717
  // Split the quantity field out: the island puts it on the visible stepper.
648
718
  const qtyIdx = fields.findIndex((f) => f.name === "quantity");
@@ -830,7 +900,7 @@ export async function withVerificationRef(
830
900
  const code = fields.find((field) => field.name.split("||")[0] === "code")?.value;
831
901
  if (!code) return [...fields];
832
902
  const field: CartField = { name: "verification", value: opts.ref };
833
- if (opts.mode !== "signed") return [...fields, field];
903
+ if (opts.mode !== "signed") return [...fields, unsignedField(field)];
834
904
  if (!opts.secret) throw new CartSigningUnavailableError();
835
905
  return [...fields, await signField(opts.secret, code, field)];
836
906
  }
@@ -852,7 +922,7 @@ export async function withShippingPolicy(
852
922
  const currency = kept.find((field) => field.name.split("||")[0] === "currency")?.value;
853
923
  if (!code || !currency) throw new ShippingPolicyUnavailableError();
854
924
  const field: CartField = { name: SHIPPING_POLICY_CART_FIELD, value: shippingPolicyOptionValue(opts.policy, currency) };
855
- if (opts.mode !== "signed") return [...kept, field];
925
+ if (opts.mode !== "signed") return [...kept, unsignedField(field)];
856
926
  if (!opts.secret) throw new CartSigningUnavailableError();
857
927
  return [...kept, await signField(opts.secret, code, field)];
858
928
  }
@@ -21,7 +21,10 @@ export const STRUCTURED_DATA_NAMED_TYPES: readonly string[];
21
21
  export const STRUCTURED_DATA_NAME_SOURCES: readonly string[];
22
22
  export function seoDeclarationProblems(seo: unknown): string[];
23
23
  export const SHIPPING_KEYS: readonly string[];
24
+ export const SHIPPING_TIER_KEYS: readonly string[];
24
25
  export const MAX_SHIPPING_AMOUNT: number;
26
+ export const MAX_SHIPPING_TIERS: number;
27
+ export const MAX_SHIPPING_LABEL_LENGTH: number;
25
28
  export function shippingDeclarationProblems(shipping: unknown): string[];
26
29
  export function declaredTenantConfig(config: unknown): DeclaredTenantConfig;
27
30
  export function isEmptyDeclaredTenantConfig(declared: DeclaredTenantConfig | null | undefined): boolean;
@@ -243,20 +243,38 @@ export function seoDeclarationProblems(seo) {
243
243
  }
244
244
 
245
245
  /** Every `shipping` key (ShippingPolicy, tenant.ts). */
246
- export const SHIPPING_KEYS = Object.freeze(["flatRate", "freeAtOrAbove"]);
246
+ export const SHIPPING_KEYS = Object.freeze(["tiers"]);
247
+
248
+ /** Every key of one `shipping.tiers` entry (ShippingTier, tenant.ts). */
249
+ export const SHIPPING_TIER_KEYS = Object.freeze(["atOrAbove", "rate", "label"]);
247
250
 
248
251
  /** Largest shipping amount a declaration may state, in the cart's currency. */
249
252
  export const MAX_SHIPPING_AMOUNT = 100000;
250
253
 
254
+ /** Most tiers one policy may declare. */
255
+ export const MAX_SHIPPING_TIERS = 10;
256
+
257
+ /** Longest shipping-line label, in characters. */
258
+ export const MAX_SHIPPING_LABEL_LENGTH = 100;
259
+
251
260
  /** @param {unknown} value */
252
261
  const isShippingAmount = (value) =>
253
262
  typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= MAX_SHIPPING_AMOUNT
254
263
  && Math.abs(value * 100 - Math.round(value * 100)) < 1e-6;
255
264
 
265
+ /** @param {unknown} value */
266
+ const isShippingLabel = (value) =>
267
+ typeof value === "string" && value.trim() === value && value.length > 0
268
+ && [...value].length <= MAX_SHIPPING_LABEL_LENGTH && !/[\u0000-\u001f\u007f]/.test(value);
269
+
256
270
  /**
257
271
  * What is wrong with a declared `shipping` value, one sentence per problem; `[]` when it is
258
272
  * valid. A policy with any problem is refused whole, so checkout never charges half of one.
259
273
  *
274
+ * Tiers are ordered by the item subtotal they start at. The first starts at 0 and states no
275
+ * `atOrAbove`; each later tier's `atOrAbove` is above the one before it and is inclusive, so an
276
+ * order whose subtotal equals it pays that tier's `rate`.
277
+ *
260
278
  * @param {unknown} shipping
261
279
  * @returns {string[]}
262
280
  */
@@ -267,15 +285,54 @@ export function shippingDeclarationProblems(shipping) {
267
285
  for (const key of Object.keys(source)) {
268
286
  if (!SHIPPING_KEYS.includes(key)) problems.push(`\`shipping.${key}\` is not a shipping key (${SHIPPING_KEYS.join(", ")})`);
269
287
  }
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`);
288
+ const tiers = source.tiers;
289
+ if (!Array.isArray(tiers) || tiers.length === 0 || tiers.length > MAX_SHIPPING_TIERS) {
290
+ problems.push(`\`shipping.tiers\` must be a list of 1 to ${MAX_SHIPPING_TIERS} tiers`);
291
+ return problems;
275
292
  }
293
+ let previous = 0;
294
+ tiers.forEach((tier, index) => {
295
+ const at = `shipping.tiers[${index}]`;
296
+ if (!isPlainObject(tier)) {
297
+ problems.push(`\`${at}\` must be an object`);
298
+ return;
299
+ }
300
+ const entry = /** @type {Record<string, unknown>} */ (tier);
301
+ for (const key of Object.keys(entry)) {
302
+ if (!SHIPPING_TIER_KEYS.includes(key)) problems.push(`\`${at}.${key}\` is not a tier key (${SHIPPING_TIER_KEYS.join(", ")})`);
303
+ }
304
+ if (!isShippingAmount(entry.rate)) {
305
+ problems.push(`\`${at}.rate\` must be an amount from 0 to ${MAX_SHIPPING_AMOUNT} with at most two decimal places`);
306
+ }
307
+ if (entry.label !== undefined && !isShippingLabel(entry.label)) {
308
+ problems.push(`\`${at}.label\` must be text of 1 to ${MAX_SHIPPING_LABEL_LENGTH} characters with no surrounding spaces or line breaks`);
309
+ }
310
+ if (index === 0) {
311
+ if (entry.atOrAbove !== undefined) problems.push(`\`${at}\` starts at 0, so it states no \`atOrAbove\``);
312
+ return;
313
+ }
314
+ if (!(isShippingAmount(entry.atOrAbove) && /** @type {number} */ (entry.atOrAbove) > previous)) {
315
+ problems.push(`\`${at}.atOrAbove\` must be an amount above ${previous}, where the tier before it starts, and at most ${MAX_SHIPPING_AMOUNT} with at most two decimal places`);
316
+ return;
317
+ }
318
+ previous = /** @type {number} */ (entry.atOrAbove);
319
+ });
276
320
  return problems;
277
321
  }
278
322
 
323
+ /**
324
+ * A valid declaration copied to exactly its declared keys.
325
+ *
326
+ * @param {{ tiers: Array<Record<string, unknown>> }} policy
327
+ */
328
+ const shippingPolicyOf = (policy) => ({
329
+ tiers: policy.tiers.map((tier) => ({
330
+ ...(tier.atOrAbove === undefined ? {} : { atOrAbove: tier.atOrAbove }),
331
+ rate: tier.rate,
332
+ ...(tier.label === undefined ? {} : { label: tier.label }),
333
+ })),
334
+ });
335
+
279
336
  /**
280
337
  * The declared TenantConfig fields from a parsed `.tot/config.json`. A field whose
281
338
  * value has the wrong shape is omitted, never coerced (`tot validate` reports it).
@@ -302,8 +359,7 @@ export function declaredTenantConfig(config) {
302
359
  // The shipping charge: refused whole when any part is malformed (`tot validate` and the
303
360
  // reconcile report it), so checkout keeps charging no shipping rather than a guess.
304
361
  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 }) };
362
+ out.shipping = shippingPolicyOf(/** @type {{ tiers: Array<Record<string, unknown>> }} */ (shipping));
307
363
  }
308
364
  return out;
309
365
  }
@@ -773,16 +773,27 @@ export interface TenantConfig {
773
773
  }
774
774
 
775
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`).
776
+ * A store's shipping charge: one rate per order, chosen by the order's item subtotal from
777
+ * `tiers`. The cart carries it as the signed `shipping_policy` option; the checkout store
778
+ * charges it under the tier's label, and the final-cart authority re-derives the amount from
779
+ * this declaration (checkout.ts `shippingMinorFor`).
780
780
  */
781
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;
782
+ /**
783
+ * Ascending by `atOrAbove`. The first tier starts at 0 and states no `atOrAbove`; an order
784
+ * pays the last tier whose start its item subtotal reaches.
785
+ */
786
+ tiers: ShippingTier[];
787
+ }
788
+
789
+ /** One band of item subtotals and what an order in it pays for shipping. */
790
+ export interface ShippingTier {
791
+ /** Item subtotal this tier starts at, inclusive, in the cart's currency. Absent on the first tier. */
792
+ atOrAbove?: number;
793
+ /** Charged once per order, in the cart's currency. `0` ships free. */
794
+ rate: number;
795
+ /** The shipping line the shopper reads at checkout. Absent ⇒ "Free shipping" or "Shipping". */
796
+ label?: string;
786
797
  }
787
798
 
788
799
  /** Page types a structured-data profile declares a graph for (STRUCTURED_DATA_PAGE_TYPES). */