@tokenoftrust/storefront-runner 2.5.6 → 3.0.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 (79) hide show
  1. package/agency-kit/tools/lib/public-binaries.mjs +95 -0
  2. package/apps/storefront/astro.config.mjs +7 -0
  3. package/apps/storefront/env.d.ts +3 -0
  4. package/apps/storefront/src/components/catalog/storefront/ProductCard.astro +5 -4
  5. package/apps/storefront/src/components/catalog/storefront/commerce/PriceDisplay.astro +2 -1
  6. package/apps/storefront/src/components/catalog/storefront/islands/SearchTypeahead.tsx +17 -20
  7. package/apps/storefront/src/components/catalog/storefront/islands/VariantSelector.tsx +13 -21
  8. package/apps/storefront/src/components/catalog/storefront/plp/Pagination.astro +9 -5
  9. package/apps/storefront/src/components/checkout/storefront/commerce/CheckoutLoader.astro +18 -6
  10. package/apps/storefront/src/components/compliance/storefront/islands/CheckoutComplianceGate.tsx +19 -4
  11. package/apps/storefront/src/components/content/storefront/Badge.astro +1 -0
  12. package/apps/storefront/src/components/content/storefront/blog/BlogIndexView.astro +10 -1
  13. package/apps/storefront/src/components/publication/storefront/islands/CatalogQueryView.tsx +321 -0
  14. package/apps/storefront/src/config/declaredTenantConfig.ts +59 -27
  15. package/apps/storefront/src/config/devTenantSeed.ts +2 -1
  16. package/apps/storefront/src/layouts/Layout.astro +1 -1
  17. package/apps/storefront/src/lib/access/auth/gatePage.ts +29 -14
  18. package/apps/storefront/src/lib/access/shared/publicAccess.ts +95 -0
  19. package/apps/storefront/src/lib/access/shared/tenantRouting.ts +19 -0
  20. package/apps/storefront/src/lib/catalog/shared/catalogIndex.ts +109 -0
  21. package/apps/storefront/src/lib/catalog/shared/catalogQueryView.ts +60 -0
  22. package/apps/storefront/src/lib/catalog/shared/collectionPaging.ts +78 -0
  23. package/apps/storefront/src/lib/catalog/shared/stockStatus.ts +23 -0
  24. package/apps/storefront/src/lib/catalog/tot/query.ts +8 -0
  25. package/apps/storefront/src/lib/checkout/browser/checkoutRequirementsForm.ts +54 -0
  26. package/apps/storefront/src/lib/checkout/commerce/checkoutHandoffRoute.ts +103 -35
  27. package/apps/storefront/src/lib/checkout/commerce/checkoutRequirements.ts +88 -0
  28. package/apps/storefront/src/lib/checkout/commerce/finalCartAuthorization.ts +2 -0
  29. package/apps/storefront/src/lib/checkout/commerce/sharedStoreAdmission.ts +95 -0
  30. package/apps/storefront/src/lib/checkout/shared/lazyCheckoutLoader.ts +191 -0
  31. package/apps/storefront/src/lib/compliance/browser/checkoutVerificationFlow.ts +9 -0
  32. package/apps/storefront/src/lib/compliance/enforcement.ts +7 -5
  33. package/apps/storefront/src/lib/content/seo/structuredData.ts +12 -3
  34. package/apps/storefront/src/lib/content/storyblok/provider.ts +4 -2
  35. package/apps/storefront/src/lib/observability/shared/serverTiming.ts +110 -0
  36. package/apps/storefront/src/lib/publication/shared/edgeCache.ts +3 -4
  37. package/apps/storefront/src/lib/publication/shared/staticQuery.ts +64 -0
  38. package/apps/storefront/src/middleware/controlPlane.ts +14 -11
  39. package/apps/storefront/src/middleware/index.ts +33 -12
  40. package/apps/storefront/src/middleware/renderChain.ts +52 -26
  41. package/apps/storefront/src/pages/[...slug].astro +3 -0
  42. package/apps/storefront/src/pages/api/commerce/final-cart-authorization.ts +112 -42
  43. package/apps/storefront/src/pages/api/compliance/preflight.ts +61 -13
  44. package/apps/storefront/src/pages/blog/author/[author].astro +1 -1
  45. package/apps/storefront/src/pages/blog/category/[category].astro +1 -1
  46. package/apps/storefront/src/pages/blog/tag/[tag].astro +1 -1
  47. package/apps/storefront/src/pages/catalog-index.json.ts +20 -0
  48. package/apps/storefront/src/pages/collections/[handle]/page/[n].astro +28 -0
  49. package/apps/storefront/src/pages/collections/[handle].astro +13 -198
  50. package/apps/storefront/src/pages/collections/_CollectionView.astro +251 -0
  51. package/apps/storefront/src/pages/collections/index.astro +1 -1
  52. package/apps/storefront/src/pages/products/[handle].astro +10 -5
  53. package/apps/storefront/src/pages/search.astro +33 -33
  54. package/apps/storefront/src/pages/sitemap.xml.ts +7 -1
  55. package/apps/storefront/vitest.config.cts +3 -0
  56. package/package.json +1 -1
  57. package/packages/cli/src/declared-config.mjs +318 -32
  58. package/packages/cli/src/publication-spaces.mjs +1 -1
  59. package/packages/public-runtime/package.json +1 -0
  60. package/packages/public-runtime/schemas/publication-space/v1/manifest.schema.json +1 -1
  61. package/packages/public-runtime/schemas/publication-space/v1/normalized-mount.schema.json +1 -1
  62. package/packages/public-runtime/src/activity/catalog.ts +8 -1
  63. package/packages/public-runtime/src/candidate-index.ts +5 -0
  64. package/packages/public-runtime/src/capabilities.ts +12 -9
  65. package/packages/public-runtime/src/capture-rewrites.ts +687 -0
  66. package/packages/public-runtime/src/checkout-orders.ts +6 -7
  67. package/packages/public-runtime/src/checkout.ts +190 -6
  68. package/packages/public-runtime/src/compliance/evaluate.ts +7 -3
  69. package/packages/public-runtime/src/compliance/profile.ts +90 -14
  70. package/packages/public-runtime/src/compliance-coherence.d.mts +10 -1
  71. package/packages/public-runtime/src/compliance-coherence.mjs +129 -29
  72. package/packages/public-runtime/src/customization-runtime.ts +64 -2
  73. package/packages/public-runtime/src/declared-tenant-config.d.mts +13 -2
  74. package/packages/public-runtime/src/declared-tenant-config.mjs +83 -1
  75. package/packages/public-runtime/src/image-preview-rewrite-overlay.ts +13 -37
  76. package/packages/public-runtime/src/membership.ts +3 -0
  77. package/packages/public-runtime/src/publication-space.ts +1 -0
  78. package/packages/public-runtime/src/tenant.ts +28 -4
  79. package/scripts/tenant/validate.mjs +17 -8
@@ -12,12 +12,14 @@
12
12
  *
13
13
  * Mirrors packages/public-runtime/src/declared-tenant-config.mjs (this package ships
14
14
  * to npm on its own); test/declared-config-parity.test.mjs pins the two together.
15
- * Dependency-free: the storefront runner copies this file as-is.
15
+ * Dependency-free: the storefront runner copies this file as-is, and the devkit page checks carry
16
+ * a byte-identical copy (docs/devkit-staging/tools/lib/declared-config.mjs, pinned by
17
+ * test/declared-config-devkit-copy.test.mjs) so their gates read a store's declaration the same way.
16
18
  */
17
19
 
18
20
  export const TOT_CONFIG_KEYS = [
19
21
  "$schema", "tenant", "scope", "mappings", "hostPlatform", "sample", "seed",
20
- "displayName", "siteType", "compliance", "capabilities", "features", "embeds", "seo",
22
+ "displayName", "siteType", "compliance", "capabilities", "features", "embeds", "seo", "currency", "shipping",
21
23
  ];
22
24
  // Keys other tooling has written that the platform does NOT read, with the key it does.
23
25
  const RENAMED_CONFIG_KEYS = { appDomain: "scope", tenantId: "tenant" };
@@ -26,10 +28,48 @@ export const COMPLIANCE_KEYS = [
26
28
  "adultSignature", "prop65", "purchaseLimit", "stateEligibility", "exciseTax",
27
29
  ];
28
30
  export const FEATURE_KEYS = [
29
- "catalog", "productSearch", "savedItems", "quickView", "newsletter", "commerceSitemap", "productJsonLd", "ageGate",
31
+ "catalog", "productSearch", "savedItems", "quickView", "productBadges", "newsletter", "commerceSitemap", "productJsonLd", "ageGate",
30
32
  ];
31
33
  export const SITE_TYPES = ["commerce", "marketing"];
32
34
 
35
+ /** The currency a store sells in when `.tot/config.json` declares none. */
36
+ export const DEFAULT_CURRENCY = "USD";
37
+
38
+ /** ISO 4217 codes a store may declare as its `currency`. */
39
+ export const CURRENCY_CODES = Object.freeze(
40
+ ("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 " +
41
+ "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 " +
42
+ "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 " +
43
+ "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 " +
44
+ "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(" "),
45
+ );
46
+
47
+ /**
48
+ * The ONE place a store's currency is resolved. A declared ISO 4217 code wins; anything
49
+ * else (absent, wrong shape, lowercase, unknown code) is USD. Every surface that prices,
50
+ * signs or formats an amount takes the currency from here, never from a literal. Only the
51
+ * store owner's `.tot/config.json` can change it: a shopper's request never reaches this.
52
+ *
53
+ * @param {unknown} declared
54
+ * @returns {string}
55
+ */
56
+ export function resolveStoreCurrency(declared) {
57
+ return typeof declared === "string" && CURRENCY_CODES.includes(declared) ? declared : DEFAULT_CURRENCY;
58
+ }
59
+
60
+ /**
61
+ * What is wrong with a declared `currency`, one sentence; `[]` when it is valid.
62
+ *
63
+ * @param {unknown} currency
64
+ * @returns {string[]}
65
+ */
66
+ export function currencyDeclarationProblems(currency) {
67
+ return typeof currency === "string" && CURRENCY_CODES.includes(currency)
68
+ ? []
69
+ : [`\`currency\` is ${JSON.stringify(currency)} — must be an uppercase ISO 4217 code such as "USD" or "CAD"`];
70
+ }
71
+
72
+
33
73
  /** Every `seo` key (mirrors packages/public-runtime/src/declared-tenant-config.mjs). */
34
74
  export const SEO_KEYS = Object.freeze(["trailingSlash", "verbatimSeoTitles", "structuredData"]);
35
75
 
@@ -156,6 +196,46 @@ export function seoDeclarationProblems(seo) {
156
196
  return problems;
157
197
  }
158
198
 
199
+ /** Every `shipping` key (mirrors packages/public-runtime/src/declared-tenant-config.mjs). */
200
+ export const SHIPPING_KEYS = Object.freeze(["flatRate", "freeAtOrAbove"]);
201
+
202
+ /** Largest shipping amount a declaration may state, in the cart's currency. */
203
+ export const MAX_SHIPPING_AMOUNT = 100000;
204
+
205
+ /** @param {unknown} value */
206
+ const isShippingAmount = (value) =>
207
+ typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= MAX_SHIPPING_AMOUNT
208
+ && Math.abs(value * 100 - Math.round(value * 100)) < 1e-6;
209
+
210
+ /**
211
+ * What is wrong with a declared `shipping` value, one sentence per problem; `[]` when it is
212
+ * valid. A policy with any problem is refused whole, so checkout never charges half of one.
213
+ *
214
+ * @param {unknown} shipping
215
+ * @returns {string[]}
216
+ */
217
+ export function shippingDeclarationProblems(shipping) {
218
+ if (!isPlainObject(shipping)) return ["`shipping` must be an object"];
219
+ const problems = [];
220
+ const source = /** @type {Record<string, unknown>} */ (shipping);
221
+ for (const key of Object.keys(source)) {
222
+ if (!SHIPPING_KEYS.includes(key)) problems.push(`\`shipping.${key}\` is not a shipping key (${SHIPPING_KEYS.join(", ")})`);
223
+ }
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`);
229
+ }
230
+ return problems;
231
+ }
232
+
233
+ /** The owner-facing sentence for an invalid `shipping` declaration. */
234
+ function shippingProblemMessage(problems) {
235
+ return `\`shipping\` is not a valid shipping policy (${problems.join("; ")}). ` +
236
+ "Checkout charges no shipping until it is fixed.";
237
+ }
238
+
159
239
 
160
240
  /** What each compliance key puts on the page — the thing that is missing when it is. */
161
241
  export const COMPLIANCE_NOTICES = {
@@ -184,8 +264,43 @@ export const MANDATED_NOTICE_KEYS = [
184
264
 
185
265
  const ERROR = "error";
186
266
  const WARN = "warn";
267
+ /** @type {(v: unknown) => v is Record<string, any>} */
187
268
  const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
188
269
 
270
+ /**
271
+ * The platform's compliance obligations — the registry in
272
+ * packages/public-runtime/src/compliance-obligations.ts, mirrored here because this file
273
+ * is dependency-free: each duty's id, its compliance-authority surface, and the declared
274
+ * `compliance.*` keys that switch it on. packages/private-controlplane/tests/
275
+ * compliance-obligation-checks.test.ts pins this list to the registry, key for key.
276
+ */
277
+ export const COMPLIANCE_OBLIGATIONS = Object.freeze([
278
+ { id: "age_gate", surface: "regulated", keys: ["minAge"] },
279
+ { id: "excise", surface: "regulated", keys: ["nicotineWarning", "exciseTax"] },
280
+ { id: "nicotine_warning", surface: "regulated", keys: ["nicotineWarning"] },
281
+ { id: "pact_act", surface: "regulated", keys: ["pactAct"] },
282
+ { id: "adult_signature", surface: "regulated", keys: ["adultSignature"] },
283
+ { id: "prop65", surface: "commerce", keys: ["prop65"] },
284
+ { id: "jurisdiction", surface: "commerce", keys: ["stateEligibility", "shippingRestriction"] },
285
+ ]);
286
+
287
+ /**
288
+ * The obligations a declared compliance block switches on, as `resolveObligations` returns
289
+ * them (id + surface), in registry order.
290
+ * @param {Record<string, any> | null | undefined} compliance
291
+ * @returns {{ id: string, surface: string }[]}
292
+ */
293
+ export function declaredObligations(compliance) {
294
+ return COMPLIANCE_OBLIGATIONS
295
+ .filter((o) => o.keys.some((key) => !!compliance?.[key]))
296
+ .map(({ id, surface }) => ({ id, surface }));
297
+ }
298
+
299
+ /** True when the declared block puts the store on the regulated surface. */
300
+ export function isRegulatedCompliance(compliance) {
301
+ return isRegulatedObligationSet(declaredObligations(compliance));
302
+ }
303
+
189
304
  /**
190
305
  * Compliance coherence — the declared-config half of the hosted preview's `age_gate`,
191
306
  * `excise` and `jurisdiction` evidence checks, so `tot validate` fails locally on
@@ -193,9 +308,42 @@ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v)
193
308
  * packages/public-runtime/src/compliance-coherence.mjs (the platform's single source);
194
309
  * test/declared-config-parity.test.mjs pins the two together.
195
310
  */
196
- export function isRegulatedCompliance(compliance) {
197
- const minAge = compliance?.minAge;
198
- return (typeof minAge === "number" && minAge > 0) || !!compliance?.nicotineWarning;
311
+ export const COHERENCE_CHECK_IDS = Object.freeze(/** @type {const} */ (["age_gate", "excise", "jurisdiction"]));
312
+
313
+ export const OBLIGATION_EVIDENCE_CHECK = Object.freeze({
314
+ age_gate: "age_gate",
315
+ excise: "excise",
316
+ nicotine_warning: "excise",
317
+ pact_act: "jurisdiction",
318
+ adult_signature: "jurisdiction",
319
+ jurisdiction: "jurisdiction",
320
+ prop65: null,
321
+ });
322
+
323
+ /** @param {readonly { id: string, surface: string }[]} obligations */
324
+ export function isRegulatedObligationSet(obligations) {
325
+ return obligations.some((o) => o.surface === "regulated");
326
+ }
327
+
328
+ /**
329
+ * @param {readonly { id: string, surface: string }[]} obligations
330
+ * @returns {("age_gate" | "excise" | "jurisdiction")[]}
331
+ */
332
+ export function coherenceChecksFor(obligations) {
333
+ const out = new Set();
334
+ if (isRegulatedObligationSet(obligations)) {
335
+ out.add("age_gate");
336
+ out.add("jurisdiction");
337
+ }
338
+ for (const { id } of obligations) {
339
+ if (!Object.hasOwn(OBLIGATION_EVIDENCE_CHECK, id)) {
340
+ for (const check of COHERENCE_CHECK_IDS) out.add(check);
341
+ continue;
342
+ }
343
+ const check = OBLIGATION_EVIDENCE_CHECK[/** @type {keyof typeof OBLIGATION_EVIDENCE_CHECK} */ (id)];
344
+ if (check) out.add(check);
345
+ }
346
+ return COHERENCE_CHECK_IDS.filter((c) => out.has(c));
199
347
  }
200
348
 
201
349
  export function declaresJurisdictionNotice(compliance) {
@@ -203,19 +351,48 @@ export function declaresJurisdictionNotice(compliance) {
203
351
  return compliance?.pactAct === true || (typeof text === "string" && text.trim().length > 0);
204
352
  }
205
353
 
354
+ /** @param {unknown} declaration */
355
+ function stateEligibilityHasContent(declaration) {
356
+ if (declaration === null || typeof declaration !== "object" || Array.isArray(declaration)) return false;
357
+ const d = /** @type {Record<string, unknown>} */ (declaration);
358
+ const listed = (v) => Array.isArray(v) && v.some((s) => typeof s === "string" && s.trim().length > 0);
359
+ return listed(d.restrictedStates) || listed(d.eligibleStates) || (typeof d.note === "string" && d.note.trim().length > 0);
360
+ }
361
+
362
+ const REGULATED_KEY_FOR = Object.freeze({
363
+ age_gate: "minAge",
364
+ excise: "exciseTax",
365
+ nicotine_warning: "nicotineWarning",
366
+ pact_act: "pactAct",
367
+ adult_signature: "adultSignature",
368
+ });
369
+
370
+ function regulatedBy(compliance, obligations) {
371
+ const keys = new Set();
372
+ for (const o of obligations) {
373
+ if (o.surface !== "regulated") continue;
374
+ const key = REGULATED_KEY_FOR[/** @type {keyof typeof REGULATED_KEY_FOR} */ (o.id)];
375
+ if (o.id === "excise" && !compliance?.exciseTax) continue;
376
+ if (key) keys.add(key);
377
+ }
378
+ return [...keys].map((k) => `\`compliance.${k}\``).join(", ") || "a regulated obligation";
379
+ }
380
+
206
381
  /**
207
382
  * @param {Record<string, any> | null | undefined} compliance
383
+ * @param {readonly { id: string, surface: string }[]} [obligations] defaults to what the block declares
208
384
  * @returns {Partial<Record<"age_gate" | "excise" | "jurisdiction", { field: string, detail: string, hint: string }>>}
209
385
  */
210
- export function complianceCoherenceFailures(compliance) {
386
+ export function complianceCoherenceFailures(compliance, obligations = declaredObligations(compliance)) {
211
387
  /** @type {Partial<Record<"age_gate" | "excise" | "jurisdiction", { field: string, detail: string, hint: string }>>} */
212
388
  const out = {};
213
- if (!isRegulatedCompliance(compliance)) return out;
389
+ const checks = coherenceChecksFor(obligations);
390
+ const regulated = isRegulatedObligationSet(obligations);
214
391
  const minAge = typeof compliance?.minAge === "number" ? compliance.minAge : null;
215
- if (minAge == null || minAge < 18) {
392
+ if (checks.includes("age_gate") && regulated && (minAge == null || minAge < 18)) {
216
393
  out.age_gate = {
217
394
  field: "compliance.minAge",
218
- detail: "Age-restricted/nicotine products require `compliance.minAge` (18 or higher) — none is set.",
395
+ detail: `Age-restricted products (${regulatedBy(compliance, obligations)}) require \`compliance.minAge\` (18 or higher) — none is set.`,
219
396
  hint: "Set `compliance.minAge` (18+) in .tot/config.json.",
220
397
  };
221
398
  }
@@ -226,16 +403,29 @@ export function complianceCoherenceFailures(compliance) {
226
403
  hint: "Set `compliance.minAge` to 21 in .tot/config.json (nicotine requires 21+, not just 18+).",
227
404
  };
228
405
  }
229
- if (!declaresJurisdictionNotice(compliance)) {
230
- out.jurisdiction = {
231
- field: "compliance.pactAct",
232
- detail:
233
- "Regulated products require a shipping/jurisdiction notice: set `compliance.pactAct: true` " +
234
- "or `compliance.shippingRestriction` — neither is set.",
235
- hint:
236
- "Set `compliance.pactAct: true` for the platform's PACT Act notice, or " +
237
- "`compliance.shippingRestriction` to your own restriction text, in .tot/config.json.",
238
- };
406
+ if (checks.includes("jurisdiction")) {
407
+ if (regulated && !declaresJurisdictionNotice(compliance)) {
408
+ out.jurisdiction = {
409
+ field: "compliance.pactAct",
410
+ detail:
411
+ `Regulated products (${regulatedBy(compliance, obligations)}) require a shipping/jurisdiction notice: ` +
412
+ "set `compliance.pactAct: true` or `compliance.shippingRestriction` — neither is set.",
413
+ hint:
414
+ "Set `compliance.pactAct: true` for the platform's PACT Act notice, or " +
415
+ "`compliance.shippingRestriction` to your own restriction text, in .tot/config.json.",
416
+ };
417
+ } else if (!regulated && !declaresJurisdictionNotice(compliance)
418
+ && !stateEligibilityHasContent(compliance?.stateEligibility)) {
419
+ const field = compliance?.stateEligibility ? "compliance.stateEligibility" : "compliance.shippingRestriction";
420
+ out.jurisdiction = {
421
+ field,
422
+ detail:
423
+ `\`${field}\` is declared but gives its shipping/jurisdiction notice nothing to say, so no notice renders.`,
424
+ hint:
425
+ "Set `compliance.shippingRestriction` to your restriction text, or give `compliance.stateEligibility` " +
426
+ "`restrictedStates`, `eligibleStates` or a `note`, in .tot/config.json.",
427
+ };
428
+ }
239
429
  }
240
430
  return out;
241
431
  }
@@ -251,27 +441,29 @@ export function complianceCoherenceFailures(compliance) {
251
441
  */
252
442
  export function complianceFindings(config, file = ".tot/config.json") {
253
443
  const compliance = isObject(config?.compliance) ? config.compliance : null;
444
+ const obligations = declaredObligations(compliance);
445
+ const owed = new Set(obligations.map((o) => o.id));
254
446
  const out = [];
255
- for (const [check, failure] of Object.entries(complianceCoherenceFailures(compliance))) {
447
+ for (const [check, failure] of Object.entries(complianceCoherenceFailures(compliance, obligations))) {
256
448
  out.push(mk(ERROR, `compliance-${check.replace("_", "-")}`, `${file} compliance`,
257
449
  `${failure.detail} The hosted preview fails its \`${check}\` check on this.`, failure.hint));
258
450
  }
259
451
  // The hosted reconcile refuses a declaration whose `capabilities` switch off the
260
452
  // compliance floor (packages/public-runtime/src/capabilities.ts capabilityFloorViolations).
261
453
  const capabilities = isObject(config?.capabilities) ? config.capabilities : {};
262
- if (compliance?.minAge && capabilities.ageVerification?.enabled === false) {
454
+ if (owed.has("age_gate") && capabilities.ageVerification?.enabled === false) {
263
455
  out.push(mk(ERROR, "capability-compliance-floor", `${file} capabilities`,
264
456
  "`capabilities.ageVerification` cannot be disabled for a store with `compliance.minAge` — the hosted preview refuses this revision.",
265
457
  "Remove `capabilities.ageVerification.enabled: false` from .tot/config.json."));
266
458
  }
267
- if (compliance?.nicotineWarning && capabilities.exciseTax?.enabled === false) {
459
+ if (owed.has("excise") && capabilities.exciseTax?.enabled === false) {
268
460
  out.push(mk(ERROR, "capability-compliance-floor", `${file} capabilities`,
269
- "`capabilities.exciseTax` cannot be disabled for a store with `compliance.nicotineWarning` — the hosted preview refuses this revision.",
461
+ "`capabilities.exciseTax` cannot be disabled for a store with `compliance.nicotineWarning` or `compliance.exciseTax` — the hosted preview refuses this revision.",
270
462
  "Remove `capabilities.exciseTax.enabled: false` from .tot/config.json."));
271
463
  }
272
- if (isRegulatedCompliance(compliance) && !compliance?.ruleProfile) {
464
+ if (isRegulatedObligationSet(obligations) && !compliance?.ruleProfile) {
273
465
  out.push(mk(WARN, "compliance-rule-profile", `${file} compliance`,
274
- "This store is regulated (`compliance.minAge`/`compliance.nicotineWarning`) but declares no " +
466
+ `This store is regulated (${regulatedBy(compliance, obligations)}) but declares no ` +
275
467
  "`compliance.ruleProfile`, so no checkout rules resolve: the preview renders, but checkout blocks " +
276
468
  "and going live refuses.",
277
469
  'Declare `compliance.ruleProfile` (e.g. "vape-nicotine-us") in .tot/config.json.'));
@@ -416,6 +608,8 @@ export function declarationFindings(current, previous) {
416
608
  if (warning.enabled && warning.problems.length > 0) {
417
609
  out.push(`CONFIG: ${nicotineWarningProblemMessage(warning.problems)}`);
418
610
  }
611
+ const shipping = current.shipping === undefined ? [] : shippingDeclarationProblems(current.shipping);
612
+ if (shipping.length > 0) out.push(`CONFIG: ${shippingProblemMessage(shipping)}`);
419
613
  }
420
614
  for (const { key, notice } of droppedMandatedNotices(previous, current)) {
421
615
  out.push(
@@ -470,7 +664,13 @@ export function validateDeclaredConfig(config, file = ".tot/config.json") {
470
664
  }
471
665
  }
472
666
  out.push(...complianceFindings(config, file));
473
- const warning = resolveNicotineWarning(isObject(config.compliance) ? config.compliance.nicotineWarning : undefined);
667
+ const declaration = isObject(config.compliance) ? config.compliance.nicotineWarning : undefined;
668
+ for (const problem of nicotineWarningCheckWidths(declaration).problems) {
669
+ out.push(mk(ERROR, "config-nicotine-warning-check-widths", `${file} compliance`,
670
+ `\`compliance.nicotineWarning\`: ${problem}. The page browser gate cannot check the warning at the widths you meant.`,
671
+ "list the widths where your layout changes (e.g. where your header becomes fixed), or remove `checkWidthsPx`"));
672
+ }
673
+ const warning = resolveNicotineWarning(declaration);
474
674
  if (warning.enabled && warning.problems.length > 0) {
475
675
  out.push(mk(ERROR, "config-nicotine-warning", `${file} compliance`, nicotineWarningProblemMessage(warning.problems),
476
676
  'use `true`, `{ "preset": "dark" }` or `{ "preset": "light" }`, or fix the style values named above'));
@@ -488,11 +688,23 @@ export function validateDeclaredConfig(config, file = ".tot/config.json") {
488
688
  && !(Array.isArray(config.embeds) && config.embeds.every((/** @type {unknown} */ s) => typeof s === "string"))) {
489
689
  out.push(mk(ERROR, "config-embeds", file, '`embeds` must be an array of embed slugs, e.g. ["youtube"]'));
490
690
  }
691
+ if (config.currency !== undefined) {
692
+ for (const problem of currencyDeclarationProblems(config.currency)) {
693
+ out.push(mk(ERROR, "config-currency", file, `${problem} — the store sells in ${DEFAULT_CURRENCY} until it is fixed`));
694
+ }
695
+ }
491
696
  if (config.seo !== undefined) {
492
697
  for (const problem of seoDeclarationProblems(config.seo)) {
493
698
  out.push(mk(ERROR, "config-seo", `${file} seo`, `${problem} — the whole \`seo\` declaration is ignored until it is fixed`));
494
699
  }
495
700
  }
701
+ if (config.shipping !== undefined) {
702
+ const problems = shippingDeclarationProblems(config.shipping);
703
+ if (problems.length > 0) {
704
+ 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'));
706
+ }
707
+ }
496
708
  return out;
497
709
  }
498
710
 
@@ -504,6 +716,7 @@ export function validateDeclaredConfig(config, file = ".tot/config.json") {
504
716
  // "nicotineWarning": true → the "dark" preset
505
717
  // "nicotineWarning": { "preset": "light" } → a validated preset
506
718
  // "nicotineWarning": { "preset": "light", "style": {...} } → a custom presentation
719
+ // "nicotineWarning": { "checkWidthsPx": [800] } → also checked seen at 800px wide
507
720
  //
508
721
  // Every presentation — preset or custom — must clear CONSPICUITY_FLOOR. A preset needs no
509
722
  // review. A custom presentation is a deviation the owner approves at go-live:
@@ -562,6 +775,9 @@ export const NICOTINE_WARNING_PRESETS = Object.freeze({
562
775
  * }} NicotineWarningPresentation
563
776
  */
564
777
 
778
+ /** Every key a `compliance.nicotineWarning` object may declare. */
779
+ export const NICOTINE_WARNING_KEYS = Object.freeze(["preset", "style", "checkWidthsPx"]);
780
+
565
781
  export const NICOTINE_WARNING_STYLE_KEYS = Object.freeze([
566
782
  "background", "text", "border", "textTransform", "fontWeight", "fontSizePx",
567
783
  ]);
@@ -669,8 +885,9 @@ export function resolveNicotineWarning(declaration) {
669
885
  if (!isObject(declaration)) {
670
886
  return resolved({ problems: ["must be `true` or an object like { \"preset\": \"light\" }"] });
671
887
  }
672
- const { preset: presetName = DEFAULT_NICOTINE_WARNING_PRESET, style, ...rest } = /** @type {Record<string, unknown>} */ (declaration);
673
- const problems = Object.keys(rest).map((k) => `\`${k}\` is not a nicotineWarning key (allowed: preset, style)`);
888
+ const { preset: presetName = DEFAULT_NICOTINE_WARNING_PRESET, style, checkWidthsPx: _checkWidths, ...rest } =
889
+ /** @type {Record<string, unknown>} */ (declaration);
890
+ const problems = Object.keys(rest).map((k) => `\`${k}\` is not a nicotineWarning key (allowed: ${NICOTINE_WARNING_KEYS.join(", ")})`);
674
891
  const base = typeof presetName === "string" ? NICOTINE_WARNING_PRESETS[presetName] : undefined;
675
892
  if (!base) {
676
893
  problems.push(`\`preset\` must be one of ${Object.keys(NICOTINE_WARNING_PRESETS).map((p) => `"${p}"`).join(", ")}`);
@@ -695,6 +912,35 @@ export function resolveNicotineWarning(declaration) {
695
912
  return resolved({ presentation, preset: matching ?? null, custom: !matching });
696
913
  }
697
914
 
915
+ /** The viewport widths a store may add to the page browser gate's nicotine-warning check. */
916
+ export const NICOTINE_WARNING_CHECK_WIDTH_BOUNDS = Object.freeze({ min: 320, max: 3840 });
917
+
918
+ /**
919
+ * The extra viewport widths at which the page browser gate checks the nicotine warning is seen.
920
+ *
921
+ * The gate always checks its own phone, tablet, laptop and desktop widths; `checkWidthsPx` adds
922
+ * the store's own breakpoints (e.g. the width where its header becomes fixed) and never removes
923
+ * a default. A store that does not declare the warning has no widths; `true` and an object
924
+ * without `checkWidthsPx` add none.
925
+ *
926
+ * "nicotineWarning": { "preset": "dark", "checkWidthsPx": [800, 1180] }
927
+ *
928
+ * `problems` lists why a declared value is unusable, in which case no extra width is used.
929
+ * @param {unknown} declaration the store's `compliance.nicotineWarning`
930
+ * @returns {{ widths: number[], problems: string[] }}
931
+ */
932
+ export function nicotineWarningCheckWidths(declaration) {
933
+ if (!isObject(declaration)) return { widths: [], problems: [] };
934
+ const value = /** @type {Record<string, unknown>} */ (declaration).checkWidthsPx;
935
+ if (value === undefined) return { widths: [], problems: [] };
936
+ const { min, max } = NICOTINE_WARNING_CHECK_WIDTH_BOUNDS;
937
+ const usable = (/** @type {unknown} */ w) => Number.isInteger(w) && /** @type {number} */ (w) >= min && /** @type {number} */ (w) <= max;
938
+ if (!Array.isArray(value) || value.length === 0 || !value.every(usable)) {
939
+ return { widths: [], problems: [`\`checkWidthsPx\` must be a non-empty array of whole pixel widths from ${min} to ${max}, e.g. [800]`] };
940
+ }
941
+ return { widths: [...new Set(/** @type {number[]} */ (value))].sort((a, b) => a - b), problems: [] };
942
+ }
943
+
698
944
  /**
699
945
  * The exact, human-readable statement of a custom presentation — what the owner approves.
700
946
  * Every resolved value is in it, so two presentations that differ in any value describe
@@ -712,6 +958,16 @@ export function describeNicotineWarningPresentation(p) {
712
958
  * of every page; "block" is the reinforced box on the product page. Every interpolated value
713
959
  * is a validated hex colour, enum or number. Styling is inline (no utility classes), so the
714
960
  * markup renders the same wherever it is served, whatever stylesheet the page carries.
961
+ *
962
+ * Every presentation and visibility declaration is inline `!important`, which no tenant
963
+ * stylesheet rule can override: tenant CSS cannot hide, move, clip, fade or restyle the warning,
964
+ * and the text element re-asserts the warning's own presentation so a tenant `p { … }` rule does
965
+ * not reach it. What inline styles cannot stop is another element painting over the warning (a
966
+ * fixed or sticky header pinned to the top of the viewport). Raising the warning above page
967
+ * chrome would paint it over the tenant's own open menus and drawers, so occlusion is measured
968
+ * instead: the page browser gate (`validate-page-browser.mjs`, `nicotineWarning`) fails a page
969
+ * whose warning is covered at a phone, tablet or desktop width, or at a width the store declares
970
+ * in `checkWidthsPx`, and names the covering element.
715
971
  * @param {unknown} declaration the tenant's `compliance.nicotineWarning`
716
972
  * @param {"bar" | "block"} variant
717
973
  */
@@ -719,11 +975,40 @@ export function nicotineWarningHtml(declaration, variant) {
719
975
  const { presentation, preset } = resolveNicotineWarning(declaration);
720
976
  const attrs = `style="${nicotineWarningStyle(presentation, variant)}" role="note" aria-label="Health warning" ` +
721
977
  `data-compliance="nicotine-warning" data-nicotine-warning-preset="${preset ?? "custom"}"`;
978
+ const text = NICOTINE_WARNING_TEXT_LOCK;
722
979
  return variant === "bar"
723
- ? `<div ${attrs}><p class="container-prose" style="margin-block:0;padding-block:0.5rem;text-align:center">${NICOTINE_WARNING_TEXT}</p></div>`
724
- : `<div ${attrs}><p style="margin:0">${NICOTINE_WARNING_TEXT}</p></div>`;
980
+ ? `<div ${attrs}><p class="container-prose" style="margin-block:0;padding-block:0.5rem;text-align:center;${text}">${NICOTINE_WARNING_TEXT}</p></div>`
981
+ : `<div ${attrs}><p style="margin:0;${text}">${NICOTINE_WARNING_TEXT}</p></div>`;
725
982
  }
726
983
 
984
+ /** Declarations that keep the warning on screen, at full opacity, in its own place. */
985
+ const NICOTINE_WARNING_VISIBILITY_LOCK = [
986
+ "display:block",
987
+ "visibility:visible",
988
+ "opacity:1",
989
+ "position:relative",
990
+ "inset:auto",
991
+ "transform:none",
992
+ "clip-path:none",
993
+ "filter:none",
994
+ "height:auto",
995
+ "max-height:none",
996
+ "overflow:visible",
997
+ ];
998
+
999
+ /** The text element inherits the warning's presentation, whatever a tenant `p` rule says. */
1000
+ const NICOTINE_WARNING_TEXT_LOCK = [
1001
+ "display:block",
1002
+ "visibility:visible",
1003
+ "opacity:1",
1004
+ "color:inherit",
1005
+ "font-size:inherit",
1006
+ "font-weight:inherit",
1007
+ "text-transform:inherit",
1008
+ "letter-spacing:inherit",
1009
+ "line-height:inherit",
1010
+ ].map((d) => `${d}!important`).join(";");
1011
+
727
1012
  /**
728
1013
  * Inline CSS for a presentation. `variant` "block" (the product page) never renders smaller
729
1014
  * than 14px.
@@ -744,5 +1029,6 @@ export function nicotineWarningStyle(p, variant) {
744
1029
  `font-weight:${p.fontWeight}`,
745
1030
  `font-size:${size}px`,
746
1031
  "line-height:1.4",
747
- ].join(";");
1032
+ ...NICOTINE_WARNING_VISIBILITY_LOCK,
1033
+ ].map((d) => `${d}!important`).join(";");
748
1034
  }
@@ -33,7 +33,7 @@ export const PLATFORM_PATHS = Object.freeze([
33
33
  "/apps", "/assets", "/auth", "/brand", "/capabilities", "/cockpit", "/dashboard", "/dev",
34
34
  "/favicon.svg", "/health", "/js", "/feed.json", "/llms.txt", "/publication-discovery",
35
35
  "/search-index.json", "/sitemap.xml", "/platform", "/preview", "/products", "/collections",
36
- "/search", "/saved", "/robots.txt", "/shared", "/style-guide", "/tenants",
36
+ "/search", "/catalog-index.json", "/saved", "/robots.txt", "/shared", "/style-guide", "/tenants",
37
37
  ]);
38
38
 
39
39
  const SPACE_ID = /^[a-z0-9](?:[a-z0-9._-]{0,126}[a-z0-9])?$/;
@@ -15,6 +15,7 @@
15
15
  "./image-finding-dependencies": "./src/image-finding-dependencies.ts",
16
16
  "./image-finding-acceptance": "./src/image-finding-acceptance.ts",
17
17
  "./image-preview-rewrite-overlay": "./src/image-preview-rewrite-overlay.ts",
18
+ "./capture-rewrites": "./src/capture-rewrites.ts",
18
19
  "./hosted-image-media-types": "./src/hosted-image-media-types.ts",
19
20
  "./durable-assets": "./src/durable-assets.ts",
20
21
  "./catalog-files": "./src/catalog-files.ts",
@@ -207,7 +207,7 @@
207
207
  "mountPath": {
208
208
  "allOf": [
209
209
  { "$ref": "#/$defs/path" },
210
- { "type": "string", "pattern": "^/(?!(?:\\.[wW][eE][lL][lL]-[kK][nN][oO][wW][nN]|__[eE][vV][iI][dD][eE][nN][cC][eE]|__[pP][rR][eE][vV][iI][eE][wW]|_[aA]|_[aA][sS][tT][rR][oO]|_[sS][tT][yY][lL][eE]-[gG][uU][iI][dD][eE]|[aA][dD][mM][iI][nN]|[aA][pP][iI]|[aA][pP][pP][sS]|[aA][sS][sS][eE][tT][sS]|[aA][uU][tT][hH]|[bB][rR][aA][nN][dD]|[cC][aA][pP][aA][bB][iI][lL][iI][tT][iI][eE][sS]|[cC][oO][cC][kK][pP][iI][tT]|[cC][oO][lL][lL][eE][cC][tT][iI][oO][nN][sS]|[dD][aA][sS][hH][bB][oO][aA][rR][dD]|[dD][eE][vV]|[fF][aA][vV][iI][cC][oO][nN]\\.[sS][vV][gG]|[fF][eE][eE][dD]\\.[jJ][sS][oO][nN]|[hH][eE][aA][lL][tT][hH]|[jJ][sS]|[lL][lL][mM][sS]\\.[tT][xX][tT]|[pP][lL][aA][tT][fF][oO][rR][mM]|[pP][rR][eE][vV][iI][eE][wW]|[pP][rR][oO][dD][uU][cC][tT][sS]|[pP][uU][bB][lL][iI][cC][aA][tT][iI][oO][nN]-[dD][iI][sS][cC][oO][vV][eE][rR][yY]|[rR][oO][bB][oO][tT][sS]\\.[tT][xX][tT]|[sS][aA][vV][eE][dD]|[sS][eE][aA][rR][cC][hH]|[sS][eE][aA][rR][cC][hH]-[iI][nN][dD][eE][xX]\\.[jJ][sS][oO][nN]|[sS][hH][aA][rR][eE][dD]|[sS][iI][tT][eE][mM][aA][pP]\\.[xX][mM][lL]|[sS][tT][yY][lL][eE]-[gG][uU][iI][dD][eE]|[tT][eE][nN][aA][nN][tT][sS])(?:/|$))" }
210
+ { "type": "string", "pattern": "^/(?!(?:\\.[wW][eE][lL][lL]-[kK][nN][oO][wW][nN]|__[eE][vV][iI][dD][eE][nN][cC][eE]|__[pP][rR][eE][vV][iI][eE][wW]|_[aA]|_[aA][sS][tT][rR][oO]|_[sS][tT][yY][lL][eE]-[gG][uU][iI][dD][eE]|[aA][dD][mM][iI][nN]|[aA][pP][iI]|[aA][pP][pP][sS]|[aA][sS][sS][eE][tT][sS]|[aA][uU][tT][hH]|[bB][rR][aA][nN][dD]|[cC][aA][pP][aA][bB][iI][lL][iI][tT][iI][eE][sS]|[cC][aA][tT][aA][lL][oO][gG]-[iI][nN][dD][eE][xX]\\.[jJ][sS][oO][nN]|[cC][oO][cC][kK][pP][iI][tT]|[cC][oO][lL][lL][eE][cC][tT][iI][oO][nN][sS]|[dD][aA][sS][hH][bB][oO][aA][rR][dD]|[dD][eE][vV]|[fF][aA][vV][iI][cC][oO][nN]\\.[sS][vV][gG]|[fF][eE][eE][dD]\\.[jJ][sS][oO][nN]|[hH][eE][aA][lL][tT][hH]|[jJ][sS]|[lL][lL][mM][sS]\\.[tT][xX][tT]|[pP][lL][aA][tT][fF][oO][rR][mM]|[pP][rR][eE][vV][iI][eE][wW]|[pP][rR][oO][dD][uU][cC][tT][sS]|[pP][uU][bB][lL][iI][cC][aA][tT][iI][oO][nN]-[dD][iI][sS][cC][oO][vV][eE][rR][yY]|[rR][oO][bB][oO][tT][sS]\\.[tT][xX][tT]|[sS][aA][vV][eE][dD]|[sS][eE][aA][rR][cC][hH]|[sS][eE][aA][rR][cC][hH]-[iI][nN][dD][eE][xX]\\.[jJ][sS][oO][nN]|[sS][hH][aA][rR][eE][dD]|[sS][iI][tT][eE][mM][aA][pP]\\.[xX][mM][lL]|[sS][tT][yY][lL][eE]-[gG][uU][iI][dD][eE]|[tT][eE][nN][aA][nN][tT][sS])(?:/|$))" }
211
211
  ]
212
212
  },
213
213
  "aliasPath": {
@@ -67,7 +67,7 @@
67
67
  "mountPath": {
68
68
  "allOf": [
69
69
  { "$ref": "#/$defs/normalizedPath" },
70
- { "type": "string", "pattern": "^/(?!(?:\\.[wW][eE][lL][lL]-[kK][nN][oO][wW][nN]|__[eE][vV][iI][dD][eE][nN][cC][eE]|__[pP][rR][eE][vV][iI][eE][wW]|_[aA]|_[aA][sS][tT][rR][oO]|_[sS][tT][yY][lL][eE]-[gG][uU][iI][dD][eE]|[aA][dD][mM][iI][nN]|[aA][pP][iI]|[aA][pP][pP][sS]|[aA][sS][sS][eE][tT][sS]|[aA][uU][tT][hH]|[bB][rR][aA][nN][dD]|[cC][aA][pP][aA][bB][iI][lL][iI][tT][iI][eE][sS]|[cC][oO][cC][kK][pP][iI][tT]|[cC][oO][lL][lL][eE][cC][tT][iI][oO][nN][sS]|[dD][aA][sS][hH][bB][oO][aA][rR][dD]|[dD][eE][vV]|[fF][aA][vV][iI][cC][oO][nN]\\.[sS][vV][gG]|[fF][eE][eE][dD]\\.[jJ][sS][oO][nN]|[hH][eE][aA][lL][tT][hH]|[jJ][sS]|[lL][lL][mM][sS]\\.[tT][xX][tT]|[pP][lL][aA][tT][fF][oO][rR][mM]|[pP][rR][eE][vV][iI][eE][wW]|[pP][rR][oO][dD][uU][cC][tT][sS]|[pP][uU][bB][lL][iI][cC][aA][tT][iI][oO][nN]-[dD][iI][sS][cC][oO][vV][eE][rR][yY]|[rR][oO][bB][oO][tT][sS]\\.[tT][xX][tT]|[sS][aA][vV][eE][dD]|[sS][eE][aA][rR][cC][hH]|[sS][eE][aA][rR][cC][hH]-[iI][nN][dD][eE][xX]\\.[jJ][sS][oO][nN]|[sS][hH][aA][rR][eE][dD]|[sS][iI][tT][eE][mM][aA][pP]\\.[xX][mM][lL]|[sS][tT][yY][lL][eE]-[gG][uU][iI][dD][eE]|[tT][eE][nN][aA][nN][tT][sS])(?:/|$))" }
70
+ { "type": "string", "pattern": "^/(?!(?:\\.[wW][eE][lL][lL]-[kK][nN][oO][wW][nN]|__[eE][vV][iI][dD][eE][nN][cC][eE]|__[pP][rR][eE][vV][iI][eE][wW]|_[aA]|_[aA][sS][tT][rR][oO]|_[sS][tT][yY][lL][eE]-[gG][uU][iI][dD][eE]|[aA][dD][mM][iI][nN]|[aA][pP][iI]|[aA][pP][pP][sS]|[aA][sS][sS][eE][tT][sS]|[aA][uU][tT][hH]|[bB][rR][aA][nN][dD]|[cC][aA][pP][aA][bB][iI][lL][iI][tT][iI][eE][sS]|[cC][aA][tT][aA][lL][oO][gG]-[iI][nN][dD][eE][xX]\\.[jJ][sS][oO][nN]|[cC][oO][cC][kK][pP][iI][tT]|[cC][oO][lL][lL][eE][cC][tT][iI][oO][nN][sS]|[dD][aA][sS][hH][bB][oO][aA][rR][dD]|[dD][eE][vV]|[fF][aA][vV][iI][cC][oO][nN]\\.[sS][vV][gG]|[fF][eE][eE][dD]\\.[jJ][sS][oO][nN]|[hH][eE][aA][lL][tT][hH]|[jJ][sS]|[lL][lL][mM][sS]\\.[tT][xX][tT]|[pP][lL][aA][tT][fF][oO][rR][mM]|[pP][rR][eE][vV][iI][eE][wW]|[pP][rR][oO][dD][uU][cC][tT][sS]|[pP][uU][bB][lL][iI][cC][aA][tT][iI][oO][nN]-[dD][iI][sS][cC][oO][vV][eE][rR][yY]|[rR][oO][bB][oO][tT][sS]\\.[tT][xX][tT]|[sS][aA][vV][eE][dD]|[sS][eE][aA][rR][cC][hH]|[sS][eE][aA][rR][cC][hH]-[iI][nN][dD][eE][xX]\\.[jJ][sS][oO][nN]|[sS][hH][aA][rR][eE][dD]|[sS][iI][tT][eE][mM][aA][pP]\\.[xX][mM][lL]|[sS][tT][yY][lL][eE]-[gG][uU][iI][dD][eE]|[tT][eE][nN][aA][nN][tT][sS])(?:/|$))" }
71
71
  ]
72
72
  },
73
73
  "aliasPath": {
@@ -176,7 +176,7 @@ export const ACTION_CATALOG = {
176
176
  // A reconcile reached a terminal state (reconciled|blocked|failed). Low
177
177
  // volume, high value → timelined for the ops UI.
178
178
  actors: ["system"], source: "server", sinks: LIFECYCLE,
179
- argsAllow: ["changeId", "result", "evidenceStatus", "evidencePushError", "prNumber", "forgeOpenVerified", "terminalSource"], renderedAllow: [],
179
+ argsAllow: ["changeId", "result", "evidenceStatus", "evidencePushError", "prNumber", "forgeOpenVerified", "terminalSource", "cause", "traceId"], renderedAllow: [],
180
180
  desc: "A candidate reconcile reached a terminal state.",
181
181
  },
182
182
  "candidate.closed": {
@@ -323,6 +323,13 @@ export const ACTION_CATALOG = {
323
323
  argsAllow: ["versionId", "scope"], renderedAllow: [],
324
324
  desc: "A tenant's checkout styling was saved to a version.",
325
325
  },
326
+ "content.checkout_requirements.saved": {
327
+ // A store's shared-test-store checkout requirements (Settings → Checkout requirements)
328
+ // were saved to a versioned test artifact. Same actors and spine as checkout styling.
329
+ actors: ["merchant", "admin"], source: "server", sinks: LIFECYCLE,
330
+ argsAllow: ["versionId", "scope"], renderedAllow: [],
331
+ desc: "A store's checkout requirements were saved to a version.",
332
+ },
326
333
  "change.go_live": {
327
334
  // Renamed from the orphaned, un-namespaced `go_live` (the ONE key that broke the
328
335
  // domain-prefix rule). The snake_case suffix is kept (the `deploy.version_observed`
@@ -78,6 +78,11 @@ export interface CandidateReconcileLifecycle {
78
78
  | "ready"
79
79
  | "blocked";
80
80
  correlationId: string;
81
+ /**
82
+ * The forge's `X-Gitea-Delivery` id MCP forwarded with this reconcile — the key that joins this
83
+ * record to MCP's delivery record (`preview_status.delivery`) and to Gitea's own delivery log.
84
+ */
85
+ deliveryId?: string | null;
81
86
  startedAt: string;
82
87
  lastUpdatedAt: string;
83
88
  elapsedMs: number;