@tokenoftrust/storefront-runner 4.4.0 → 4.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/agency-kit/tools/lib/catalog-model/files.mjs +30 -3
  2. package/agency-kit/tools/lib/catalog-model/sku.d.mts +16 -0
  3. package/agency-kit/tools/lib/catalog-model/sku.mjs +146 -0
  4. package/apps/storefront/src/components/catalog/storefront/plp/FacetSidebar.astro +29 -13
  5. package/apps/storefront/src/components/content/storefront/chrome/SiteHeader.astro +20 -2
  6. package/apps/storefront/src/layouts/Layout.astro +20 -2
  7. package/apps/storefront/src/lib/checkout/commerce/finalCartAuthorization.ts +9 -0
  8. package/apps/storefront/src/lib/checkout/shared/lazyCheckoutLoader.ts +120 -29
  9. package/apps/storefront/src/lib/content/shared/rawChrome.ts +15 -2
  10. package/apps/storefront/src/lib/content/shared/rawMarketingHtml.ts +26 -0
  11. package/apps/storefront/src/lib/inventory/allocations.ts +3 -3
  12. package/apps/storefront/src/lib/inventory/mirrorStock.ts +3 -1
  13. package/apps/storefront/src/lib/orders/admin/orderSource.ts +342 -0
  14. package/apps/storefront/src/lib/orders/admin/services/orders.ts +1 -1
  15. package/apps/storefront/src/lib/orders/data/orderMapper.ts +10 -4
  16. package/apps/storefront/src/lib/orders/data/ordersStore.ts +48 -0
  17. package/apps/storefront/src/lib/publication/shared/url.ts +19 -0
  18. package/apps/storefront/src/middleware/renderChain.ts +3 -0
  19. package/apps/storefront/src/pages/api/admin/orders/[id].ts +15 -30
  20. package/apps/storefront/src/pages/api/admin/orders.ts +15 -32
  21. package/apps/storefront/src/pages/api/commerce/final-cart-authorization.ts +39 -15
  22. package/apps/storefront/src/pages/tenants/[id]/[...path].ts +12 -2
  23. package/package.json +1 -1
  24. package/packages/cli/src/declared-config.mjs +43 -7
  25. package/packages/public-runtime/package.json +2 -0
  26. package/packages/public-runtime/src/checkout.ts +13 -3
  27. package/packages/public-runtime/src/preview-url.d.mts +10 -0
  28. package/packages/public-runtime/src/preview-url.mjs +54 -0
  29. package/packages/public-runtime/src/sku.ts +19 -0
  30. package/packages/public-runtime/src/tenant.ts +3 -0
@@ -2,13 +2,14 @@
2
2
  import type { APIContext } from "astro";
3
3
  import { createLocalJWKSet, decodeProtectedHeader, jwtVerify, type JSONWebKeySet } from "jose";
4
4
  import {
5
- commerceTenantId, totalsMatch, shippingMinorFor, isContentVersionId, catalogPinDigestFinding, cartLineCode,
5
+ commerceTenantId, totalsMatch, shippingMinorFor, isContentVersionId, catalogPinDigestFinding, cartLineKey,
6
6
  type CallerToken, type CatalogProduct, type CatalogVariant, type CommerceEvidence, type CommerceEvidenceProvenance,
7
7
  type CommerceProviderCartSnapshot, type FinalCartBinding, type TenantConfig,
8
8
  } from "@tot/public-runtime";
9
+ import { skuFromToken } from "@tot/public-runtime/sku";
9
10
  import { fromD1 } from "@/lib/catalog/d1/catalog.js";
10
11
  import { readD1, readEnv, readKv } from "@/lib/publication/shared/env";
11
- import { evaluateFinalCart, type SeamView } from "@/lib/checkout/commerce/finalCartAuthorization.js";
12
+ import { evaluateFinalCart, type RefusedLine, type SeamView } from "@/lib/checkout/commerce/finalCartAuthorization.js";
12
13
  import { d1CommerceRunner, reserveAllocation } from "@/lib/inventory/allocations.js";
13
14
  import { readMirrorBalance, readMirrorStock } from "@/lib/inventory/mirrorStock.js";
14
15
  import { admissionMatches, readCommerceAdmission, admissionSnapshotStatement } from "@/lib/checkout/commerce/commerceAdmission.js";
@@ -72,7 +73,7 @@ function readSnapshot(value: unknown): CommerceProviderCartSnapshot | null {
72
73
  typeof v.providerCartDigest !== "string" || !HEX.test(v.providerCartDigest) || typeof v.destinationDigest !== "string" || !HEX.test(v.destinationDigest) ||
73
74
  typeof v.tenantId !== "string" || !["preview", "publication"].includes(String(v.stage)) || typeof v.currency !== "string" || !/^[A-Z]{3}$/.test(v.currency) ||
74
75
  !Array.isArray(v.lines) || v.lines.length < 1 || v.lines.length > 50) return null;
75
- if (v.lines.some(line => !line || typeof line !== "object" || typeof line.sku !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,80}$/.test(line.sku) ||
76
+ if (v.lines.some(line => !line || typeof line !== "object" || skuFromToken(line.sku) === null ||
76
77
  !Number.isSafeInteger(line.quantity) || line.quantity < 1 || line.quantity > 99 || !Number.isSafeInteger(line.unitMinor) || line.unitMinor < 0 ||
77
78
  Object.keys(line).some(k => !["sku", "quantity", "unitMinor"].includes(k)))) return null;
78
79
  if (!v.totals || typeof v.totals !== "object" || Object.keys(v.totals).length !== 4 ||
@@ -84,6 +85,15 @@ function readSnapshot(value: unknown): CommerceProviderCartSnapshot | null {
84
85
  if (!totalsMatch(snapshot.totals) || snapshot.lines.reduce((sum, line) => sum + line.quantity * line.unitMinor, 0) !== snapshot.totals.subtotalMinor) return null;
85
86
  return snapshot;
86
87
  }
88
+ /**
89
+ * The snapshot with each line's checkout product code read back to its line key (the stored SKU,
90
+ * or the variant id for a variant with none). The provider digest covers the codes as the caller
91
+ * sent them; everything after it — catalog match, stock, holds, the admission record — is keyed
92
+ * by the line key, the same key inventory movements carry.
93
+ */
94
+ function keyedLines(wire: CommerceProviderCartSnapshot): CommerceProviderCartSnapshot {
95
+ return { ...wire, lines: wire.lines.map(line => ({ ...line, sku: skuFromToken(line.sku)! })) };
96
+ }
87
97
  /**
88
98
  * The compliance seam under the shared test store's policy. When verification is required the
89
99
  * cart's verification ref goes to preflight, whose live provider re-reads its real status
@@ -97,8 +107,8 @@ async function seam(context: APIContext, snapshot: CommerceProviderCartSnapshot,
97
107
  getProductByHandle: async (handle: string) => selected.get(handle) ?? null,
98
108
  } } } as APIContext, {
99
109
  lines: snapshot.lines.map(line => {
100
- const product = products.find(p => p.variants.some(v => cartLineCode(v) === line.sku))!;
101
- const variant = product.variants.find(v => cartLineCode(v) === line.sku)!;
110
+ const product = products.find(p => p.variants.some(v => cartLineKey(v) === line.sku))!;
111
+ const variant = product.variants.find(v => cartLineKey(v) === line.sku)!;
102
112
  return { handle: product.handle, variantId: variant.id, sku: line.sku, quantity: line.quantity };
103
113
  }),
104
114
  destination: snapshot.destination,
@@ -108,8 +118,19 @@ async function seam(context: APIContext, snapshot: CommerceProviderCartSnapshot,
108
118
  const blocks = decision.blocks;
109
119
  if (blocks.length === 0) return { status: "clear", verified };
110
120
  const flavors = [...new Set(blocks.flatMap(block => block.code === "flavored_prohibited" ? block.flavors ?? [] : []))];
121
+ const titleOf = (sku: string) => products.find(p => p.variants.some(v => v.sku === sku))?.title;
122
+ const seen = new Set<string>();
123
+ const lines: RefusedLine[] = [];
124
+ for (const block of blocks) {
125
+ if (block.failedClosed === true || typeof block.message !== "string" || !block.message) continue;
126
+ const key = `${block.code}\n${block.sku ?? ""}\n${block.message}`;
127
+ if (seen.has(key)) continue;
128
+ seen.add(key);
129
+ const title = block.sku ? titleOf(block.sku) : undefined;
130
+ lines.push({ code: block.code, message: block.message, ...(block.sku ? { sku: block.sku } : {}), ...(title ? { title } : {}) });
131
+ }
111
132
  return { status: "blocked", verified, failedClosed: blocks.some(block => block.failedClosed === true), codes: blocks.map(block => block.code),
112
- ...(flavors.length ? { flavors } : {}) };
133
+ ...(flavors.length ? { flavors } : {}), ...(lines.length ? { lines } : {}) };
113
134
  } catch { return { status: "absent", verified: false }; }
114
135
  }
115
136
  function fullCartProduct(value: unknown, handle: string, scope: string): value is CatalogProduct {
@@ -159,9 +180,10 @@ async function authorize(context: APIContext): Promise<Response> {
159
180
  if (Number(context.request.headers.get("content-length") ?? 0) > 65536) return deny("malformed-request");
160
181
  const raw = await context.request.text();
161
182
  if (raw.length > 65536) return deny("malformed-request");
162
- let snapshot: CommerceProviderCartSnapshot | null;
163
- try { snapshot = readSnapshot(JSON.parse(raw)); } catch { snapshot = null; }
164
- if (!snapshot) return deny("malformed-request");
183
+ let wire: CommerceProviderCartSnapshot | null;
184
+ try { wire = readSnapshot(JSON.parse(raw)); } catch { wire = null; }
185
+ if (!wire) return deny("malformed-request");
186
+ const snapshot = keyedLines(wire);
165
187
  const jwks = publicJwks(await readEnv("COMMERCE_CALLER_JWKS"));
166
188
  const issuers = list(await readEnv("COMMERCE_CALLER_ISSUERS"));
167
189
  const audience = await readEnv("COMMERCE_CALLER_AUDIENCE");
@@ -201,7 +223,7 @@ async function authorize(context: APIContext): Promise<Response> {
201
223
  : createContentProvider(tenant, { get: kvGet, env: contentEnv });
202
224
  const tot = await createToTClient(tenant, await readD1("CATALOG_DB"), content);
203
225
  const authorityContext = { ...context, locals: { ...context.locals, tenant, tot, content } } as APIContext;
204
- const catalogPin = await catalogPinDigestFinding(snapshot);
226
+ const catalogPin = await catalogPinDigestFinding(wire);
205
227
  if (catalogPin) return deny(catalogPin);
206
228
  let listing: CatalogProduct[];
207
229
  try { listing = await tot.getAllActiveProducts(); } catch { return deny("catalog-unavailable", 503); }
@@ -209,9 +231,9 @@ async function authorize(context: APIContext): Promise<Response> {
209
231
  const selected = new Map<string, CatalogProduct>();
210
232
  const mismatch = (check: string, line?: number) => refuse("catalog-cart-mismatch", line === undefined ? { check } : { check, line });
211
233
  if (snapshot.currency !== resolveStoreCurrency(tenant.currency)) return mismatch("store-currency");
212
- // A line's `sku` is the product code the page signed (`cartLineCode`): the SKU, or the
213
- // variant id for a variant with none.
214
- const coded = (line: { sku: string }) => (variant: CatalogVariant | null | undefined) => !!variant && cartLineCode(variant) === line.sku;
234
+ // A line's `sku` is the line key the page's signed product code encodes (`cartLineKey`): the
235
+ // stored SKU, or the variant id for a variant with none.
236
+ const coded = (line: { sku: string }) => (variant: CatalogVariant | null | undefined) => !!variant && cartLineKey(variant) === line.sku;
215
237
  for (const [index, line] of snapshot.lines.entries()) {
216
238
  const matches = listing.flatMap(product => product && product.status === "active" && (!product.visibility || product.visibility === "public") && product.tenant_id === tenant.tot_data_scope && Array.isArray(product.variants)
217
239
  ? product.variants.filter(coded(line)).map(variant => ({ product, variant })) : []);
@@ -306,8 +328,10 @@ async function authorize(context: APIContext): Promise<Response> {
306
328
  seam: seamView, catalogAvailable: true, browserConfirmed: false, token,
307
329
  trustedIssuers: issuers, expectedAudience: audience, activeKids, stockTracked, authorities });
308
330
  if (!decision.ok) {
309
- if (decision.finding === "verification-refused:flavored_prohibited" && seamView.flavors) {
310
- return json({ ok: false, finding: decision.finding, flavors: seamView.flavors }, 409);
331
+ if (decision.finding.startsWith("verification-refused") && (seamView.flavors || seamView.lines)) {
332
+ return json({ ok: false, finding: decision.finding,
333
+ ...(decision.finding === "verification-refused:flavored_prohibited" && seamView.flavors ? { flavors: seamView.flavors } : {}),
334
+ ...(seamView.lines ? { lines: seamView.lines } : {}) }, 409);
311
335
  }
312
336
  return deny(decision.finding, 409, decision.owner);
313
337
  }
@@ -158,16 +158,26 @@ export async function GET(context: APIContext): Promise<Response> {
158
158
  const previewVersionId = pinnedVersionId ?? sharedPreview.versionId;
159
159
 
160
160
  const pathname = url.pathname;
161
+ const publicationChannel =
162
+ sharedPreview.preview || context.locals.customizationEnv === "test" ? "preview" : "live";
161
163
  const fromPublication = await tryServePublicationRootAsset({
162
164
  tenant: context.locals.tenant,
163
165
  bucket: bucket as unknown as PublicationArtifactReadBucketV1,
164
166
  kvGet: (key) => kv.get(key),
165
167
  tenantId: urlTenant,
166
168
  pathname,
167
- channel: sharedPreview.preview || context.locals.customizationEnv === "test" ? "preview" : "live",
169
+ channel: publicationChannel,
168
170
  ifNoneMatch: context.request.headers.get("if-none-match"),
169
171
  });
170
- if (fromPublication) return fromPublication;
172
+ if (fromPublication) {
173
+ // Preview bytes answer a tokenless URL (the credential rides in a cookie), so
174
+ // they must never be cached where the next visitor of that URL would get them.
175
+ if (publicationChannel === "preview") {
176
+ fromPublication.headers.set("cache-control", "no-store");
177
+ fromPublication.headers.set("x-robots-tag", "noindex, nofollow");
178
+ }
179
+ return fromPublication;
180
+ }
171
181
 
172
182
  if (previewVersionId) {
173
183
  const overlay = await tryServePreviewRewriteOverlay({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "4.4.0",
3
+ "version": "4.6.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",
@@ -782,6 +782,16 @@ export const CONSPICUITY_FLOOR = Object.freeze({ contrast: 7, fontSizePx: 12, fo
782
782
  /** Bounds on the size a custom style may declare, so the bar cannot swallow the header. */
783
783
  const MAX_FONT_SIZE_PX = 24;
784
784
 
785
+ /** Bounds on the optional box and spacing keys; an unset key renders the preset's own frame. */
786
+ export const NICOTINE_WARNING_BOX_BOUNDS = Object.freeze({
787
+ borderWidthPx: Object.freeze({ min: 0, max: 8, integer: true }),
788
+ paddingYPx: Object.freeze({ min: 0, max: 24, integer: true }),
789
+ letterSpacingPx: Object.freeze({ min: 0, max: 4, integer: false }),
790
+ });
791
+
792
+ /** Weight 500 is allowed only on a boxed bar that is at least this large, contrasty and bordered. */
793
+ export const MEDIUM_WEIGHT_REQUIREMENTS = Object.freeze({ fontSizePx: 16, contrast: 7, borderWidthPx: 2 });
794
+
785
795
  export const DEFAULT_NICOTINE_WARNING_PRESET = "dark";
786
796
 
787
797
  /**
@@ -816,6 +826,9 @@ export const NICOTINE_WARNING_PRESETS = Object.freeze({
816
826
  * textTransform: "uppercase" | "none",
817
827
  * fontWeight: number,
818
828
  * fontSizePx: number,
829
+ * borderWidthPx?: number,
830
+ * paddingYPx?: number,
831
+ * letterSpacingPx?: number,
819
832
  * }} NicotineWarningPresentation
820
833
  */
821
834
 
@@ -824,9 +837,10 @@ export const NICOTINE_WARNING_KEYS = Object.freeze(["preset", "style", "checkWid
824
837
 
825
838
  export const NICOTINE_WARNING_STYLE_KEYS = Object.freeze([
826
839
  "background", "text", "border", "textTransform", "fontWeight", "fontSizePx",
840
+ "borderWidthPx", "paddingYPx", "letterSpacingPx",
827
841
  ]);
828
842
  const TEXT_TRANSFORMS = ["uppercase", "none"];
829
- const FONT_WEIGHTS = [600, 700, 800, 900];
843
+ const FONT_WEIGHTS = [500, 600, 700, 800, 900];
830
844
  const HEX = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
831
845
 
832
846
  /** `#abc` / `#AABBCC` → `#aabbcc`. */
@@ -865,7 +879,15 @@ export function conspicuityProblems(p) {
865
879
  out.push(`font size ${p.fontSizePx}px is below the ${CONSPICUITY_FLOOR.fontSizePx}px floor`);
866
880
  }
867
881
  if (p.fontWeight < CONSPICUITY_FLOOR.fontWeight) {
868
- out.push(`font weight ${p.fontWeight} is below the ${CONSPICUITY_FLOOR.fontWeight} floor`);
882
+ const need = MEDIUM_WEIGHT_REQUIREMENTS;
883
+ const medium = p.fontWeight === 500 && p.fontSizePx >= need.fontSizePx && ratio >= need.contrast
884
+ && (p.borderWidthPx ?? 0) >= need.borderWidthPx;
885
+ if (!medium) {
886
+ out.push(`font weight ${p.fontWeight} is below the ${CONSPICUITY_FLOOR.fontWeight} floor` +
887
+ (p.fontWeight === 500
888
+ ? ` (500 needs font size >= ${need.fontSizePx}px, contrast >= ${need.contrast}:1 and borderWidthPx >= ${need.borderWidthPx})`
889
+ : ""));
890
+ }
869
891
  }
870
892
  return out;
871
893
  }
@@ -885,6 +907,14 @@ function styleValueProblem(key, value) {
885
907
  return typeof value === "number" && Number.isFinite(value) && value >= CONSPICUITY_FLOOR.fontSizePx && value <= MAX_FONT_SIZE_PX
886
908
  ? null
887
909
  : `\`fontSizePx\` must be a number from ${CONSPICUITY_FLOOR.fontSizePx} to ${MAX_FONT_SIZE_PX}`;
910
+ case "borderWidthPx":
911
+ case "paddingYPx":
912
+ case "letterSpacingPx": {
913
+ const { min, max, integer } = NICOTINE_WARNING_BOX_BOUNDS[key];
914
+ return typeof value === "number" && Number.isFinite(value) && value >= min && value <= max && (!integer || Number.isInteger(value))
915
+ ? null
916
+ : `\`${key}\` must be ${integer ? "a whole number" : "a number"} from ${min} to ${max}`;
917
+ }
888
918
  default:
889
919
  return `\`${key}\` is not a nicotine-warning style key (allowed: ${NICOTINE_WARNING_STYLE_KEYS.join(", ")})`;
890
920
  }
@@ -993,7 +1023,10 @@ export function nicotineWarningCheckWidths(declaration) {
993
1023
  */
994
1024
  export function describeNicotineWarningPresentation(p) {
995
1025
  return `background ${p.background}, text ${p.text}, border ${p.border}, ` +
996
- `${p.textTransform === "uppercase" ? "uppercase" : "normal case"}, weight ${p.fontWeight}, ${p.fontSizePx}px`;
1026
+ `${p.textTransform === "uppercase" ? "uppercase" : "normal case"}, weight ${p.fontWeight}, ${p.fontSizePx}px` +
1027
+ (p.borderWidthPx === undefined ? "" : `, border width ${p.borderWidthPx}px`) +
1028
+ (p.paddingYPx === undefined ? "" : `, vertical padding ${p.paddingYPx}px`) +
1029
+ (p.letterSpacingPx === undefined ? "" : `, letter spacing ${p.letterSpacingPx}px`);
997
1030
  }
998
1031
 
999
1032
  /**
@@ -1017,11 +1050,12 @@ export function describeNicotineWarningPresentation(p) {
1017
1050
  */
1018
1051
  export function nicotineWarningHtml(declaration, variant) {
1019
1052
  const { presentation, preset } = resolveNicotineWarning(declaration);
1053
+ const barPadding = presentation.paddingYPx === undefined ? "0.5rem" : `${presentation.paddingYPx}px`;
1020
1054
  const attrs = `style="${nicotineWarningStyle(presentation, variant)}" role="note" aria-label="Health warning" ` +
1021
1055
  `data-compliance="nicotine-warning" data-nicotine-warning-preset="${preset ?? "custom"}"`;
1022
1056
  const text = NICOTINE_WARNING_TEXT_LOCK;
1023
1057
  return variant === "bar"
1024
- ? `<div ${attrs}><p class="container-prose" style="margin-block:0;padding-block:0.5rem;text-align:center;${text}">${NICOTINE_WARNING_TEXT}</p></div>`
1058
+ ? `<div ${attrs}><p class="container-prose" style="margin-block:0;padding-block:${barPadding};text-align:center;${text}">${NICOTINE_WARNING_TEXT}</p></div>`
1025
1059
  : `<div ${attrs}><p style="margin:0;${text}">${NICOTINE_WARNING_TEXT}</p></div>`;
1026
1060
  }
1027
1061
 
@@ -1061,15 +1095,17 @@ const NICOTINE_WARNING_TEXT_LOCK = [
1061
1095
  */
1062
1096
  export function nicotineWarningStyle(p, variant) {
1063
1097
  const size = variant === "block" ? Math.max(p.fontSizePx, 14) : p.fontSizePx;
1098
+ const boxed = `${p.borderWidthPx ?? 2}px`;
1064
1099
  const frame = variant === "bar"
1065
- ? [`border-bottom:2px solid ${p.border}`]
1066
- : [`border:2px solid ${p.border}`, "border-radius:var(--radius-md)", "padding:0.75rem 1rem"];
1100
+ ? [p.borderWidthPx === undefined ? `border-bottom:2px solid ${p.border}` : `border:${boxed} solid ${p.border}`]
1101
+ : [`border:${boxed} solid ${p.border}`, "border-radius:var(--radius-md)",
1102
+ `padding:${p.paddingYPx === undefined ? "0.75rem" : `${p.paddingYPx}px`} 1rem`];
1067
1103
  return [
1068
1104
  ...frame,
1069
1105
  `background-color:${p.background}`,
1070
1106
  `color:${p.text}`,
1071
1107
  `text-transform:${p.textTransform}`,
1072
- `letter-spacing:${p.textTransform === "uppercase" ? "0.06em" : "normal"}`,
1108
+ `letter-spacing:${p.letterSpacingPx !== undefined ? `${p.letterSpacingPx}px` : p.textTransform === "uppercase" ? "0.06em" : "normal"}`,
1073
1109
  `font-weight:${p.fontWeight}`,
1074
1110
  `font-size:${size}px`,
1075
1111
  "line-height:1.4",
@@ -10,6 +10,7 @@
10
10
  "exports": {
11
11
  ".": "./src/index.ts",
12
12
  "./blog-visibility": "./src/blog-visibility.mjs",
13
+ "./preview-url": "./src/preview-url.mjs",
13
14
  "./publication-redirects": "./src/publication-redirects.mjs",
14
15
  "./image-dependencies": "./src/image-dependencies.ts",
15
16
  "./image-finding-dependencies": "./src/image-finding-dependencies.ts",
@@ -25,6 +26,7 @@
25
26
  "./image-release-gate": "./src/image-release-gate.ts",
26
27
  "./pool-asset-headers": "./src/pool-asset-headers.ts",
27
28
  "./catalog-files": "./src/catalog-files.ts",
29
+ "./sku": "./src/sku.ts",
28
30
  "./embed-catalog": "./src/embed-catalog.mjs",
29
31
  "./retention-holds": "./src/retention-holds.ts",
30
32
  "./publication-content-redirects": "./src/publication-content-redirects.ts",
@@ -28,6 +28,7 @@ import { resolveStoreCurrency } from "./declared-tenant-config.mjs";
28
28
  import { DEFAULT_ONE_TIME_PURCHASE_OPTION } from "./product.js";
29
29
  import type { ShippingPolicy, TenantConfig } from "./tenant.js";
30
30
  import { isContentVersionId } from "./checkout-orders.js";
31
+ import { skuToken } from "./sku.js";
31
32
 
32
33
  /** Stable commerce scope shared by signed carts, provider callers and operator authorization. */
33
34
  export function commerceTenantId(tenant: Pick<TenantConfig, "appDomain">): string {
@@ -501,13 +502,22 @@ export function resolveManagedCheckout(
501
502
  }
502
503
 
503
504
  /**
504
- * The checkout product code for a variant: its SKU, or its id when it has none. Every field
505
- * is hashed against it, and the final-cart authority finds a cart line's variant by it.
505
+ * The line identity of a variant across commerce: its stored SKU, or its id when it has none.
506
+ * Cart lines, holds, allocations and inventory movements are all keyed by it.
506
507
  */
507
- export function cartLineCode(variant: Pick<CatalogVariant, "id" | "sku">): string {
508
+ export function cartLineKey(variant: Pick<CatalogVariant, "id" | "sku">): string {
508
509
  return variant.sku && variant.sku.length ? variant.sku : variant.id;
509
510
  }
510
511
 
512
+ /**
513
+ * The checkout product code for a variant: its {@link cartLineKey} as a `skuToken`, so a SKU with
514
+ * a space or any other character still makes a token-safe code. Every field is hashed against it;
515
+ * the final-cart authority reads it back with `skuFromToken`.
516
+ */
517
+ export function cartLineCode(variant: Pick<CatalogVariant, "id" | "sku">): string {
518
+ return skuToken(cartLineKey(variant));
519
+ }
520
+
511
521
  /**
512
522
  * The raw (unsigned) cart fields for a variant: name, price, code, image, the
513
523
  * variant's options, and an editable quantity. `code` is the checkout product code
@@ -0,0 +1,10 @@
1
+ export const PREVIEW_URL_SHAPE: string;
2
+ export function previewPath(store: string, subPath?: string): string;
3
+ export function previewUrl(origin: string, store: string): string;
4
+ export function previewPrUrl(origin: string, store: string, prNumber: number | string): string | null;
5
+ export function previewRevUrl(origin: string, store: string, versionId: string): string;
6
+ export function legacyPreviewLocation(
7
+ pathname: string,
8
+ search: string | undefined,
9
+ isStore: (segment: string) => boolean,
10
+ ): string | null;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The one builder of preview URLs. A store's preview lives under the store, never under a
3
+ * `/preview/<store>` prefix:
4
+ *
5
+ * <origin>/<store>/preview the store's current shared preview
6
+ * <origin>/<store>/preview/pr/<n> candidate PR n
7
+ * <origin>/<store>/preview/rev/<sha>/ an immutable revision
8
+ *
9
+ * Every producer (Worker, CLI) calls these; `apps/storefront/src/pages/preview/preview-url-routes.test.ts`
10
+ * resolves each shape against the page route table. `packages/cli/src/preview-url.mjs` is a
11
+ * byte-identical copy (a CLI test pins it).
12
+ */
13
+
14
+ /** The canonical shapes, for messages that name the right URL. */
15
+ export const PREVIEW_URL_SHAPE = "/<store>/preview[/pr/<n>]";
16
+
17
+ function trimOrigin(origin) {
18
+ return String(origin).trim().replace(/\/+$/, "");
19
+ }
20
+
21
+ /** `/<store>/preview` with an optional sub-path (`/pr/5`). */
22
+ export function previewPath(store, subPath = "") {
23
+ return `/${encodeURIComponent(store)}/preview${subPath}`;
24
+ }
25
+
26
+ export function previewUrl(origin, store) {
27
+ return `${trimOrigin(origin)}${previewPath(store)}`;
28
+ }
29
+
30
+ /** `null` when `prNumber` is not a positive integer. */
31
+ export function previewPrUrl(origin, store, prNumber) {
32
+ const n = Number(prNumber);
33
+ if (!Number.isInteger(n) || n < 1) return null;
34
+ return `${trimOrigin(origin)}${previewPath(store, `/pr/${n}`)}`;
35
+ }
36
+
37
+ export function previewRevUrl(origin, store, versionId) {
38
+ return `${trimOrigin(origin)}${previewPath(store, `/rev/${encodeURIComponent(versionId)}/`)}`;
39
+ }
40
+
41
+ /**
42
+ * The canonical location for a legacy `/preview/<store>[/pr/<n>[/...]]` request, or `null` when
43
+ * the path is not that shape. `isStore` decides whether the first segment names a store
44
+ * (the caller owns what a store id looks like), so `/preview/pr/5`, `/preview/rev/...` and the
45
+ * other first-segment routes are never read as a store.
46
+ */
47
+ export function legacyPreviewLocation(pathname, search, isStore) {
48
+ const m = /^\/preview\/([^/]+)(\/.*)?$/.exec(pathname);
49
+ if (!m) return null;
50
+ const store = m[1];
51
+ if (!/^[A-Za-z0-9.-]+$/.test(store) || !isStore(store)) return null;
52
+ const rest = (m[2] ?? "").replace(/\/+$/, "");
53
+ return `/${store}/preview${rest}${search ?? ""}`;
54
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The SKU contract for the platform. It lives once, in the agency kit's pure catalog module
3
+ * (`agency-kit/tools/lib/catalog-model/sku.mjs`), which the catalog file reader, the kit's tools
4
+ * and this runtime all import.
5
+ */
6
+ export {
7
+ CHECKOUT_CODE_MAX_LENGTH,
8
+ SKU_MAX_LENGTH,
9
+ SKU_TOKEN_MAX_LENGTH,
10
+ isStoredSku,
11
+ parseSku,
12
+ skuCollisions,
13
+ skuFindings,
14
+ skuFromToken,
15
+ skuKey,
16
+ skuToken,
17
+ type SkuFinding,
18
+ type SkuRefusal,
19
+ } from "../../../agency-kit/tools/lib/catalog-model/sku.mjs";
@@ -296,6 +296,9 @@ export interface NicotineWarningDeclaration {
296
296
  textTransform?: "uppercase" | "none";
297
297
  fontWeight?: number;
298
298
  fontSizePx?: number;
299
+ borderWidthPx?: number;
300
+ paddingYPx?: number;
301
+ letterSpacingPx?: number;
299
302
  };
300
303
  }
301
304