@tokenoftrust/storefront-runner 2.5.5 → 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 (80) 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/ImageGallery.tsx +20 -3
  7. package/apps/storefront/src/components/catalog/storefront/islands/SearchTypeahead.tsx +17 -20
  8. package/apps/storefront/src/components/catalog/storefront/islands/VariantSelector.tsx +13 -21
  9. package/apps/storefront/src/components/catalog/storefront/plp/Pagination.astro +9 -5
  10. package/apps/storefront/src/components/checkout/storefront/commerce/CheckoutLoader.astro +18 -6
  11. package/apps/storefront/src/components/compliance/storefront/islands/CheckoutComplianceGate.tsx +19 -4
  12. package/apps/storefront/src/components/content/storefront/Badge.astro +1 -0
  13. package/apps/storefront/src/components/content/storefront/blog/BlogIndexView.astro +10 -1
  14. package/apps/storefront/src/components/publication/storefront/islands/CatalogQueryView.tsx +321 -0
  15. package/apps/storefront/src/config/declaredTenantConfig.ts +59 -27
  16. package/apps/storefront/src/config/devTenantSeed.ts +2 -1
  17. package/apps/storefront/src/layouts/Layout.astro +1 -1
  18. package/apps/storefront/src/lib/access/auth/gatePage.ts +29 -14
  19. package/apps/storefront/src/lib/access/shared/publicAccess.ts +95 -0
  20. package/apps/storefront/src/lib/access/shared/tenantRouting.ts +19 -0
  21. package/apps/storefront/src/lib/catalog/shared/catalogIndex.ts +109 -0
  22. package/apps/storefront/src/lib/catalog/shared/catalogQueryView.ts +60 -0
  23. package/apps/storefront/src/lib/catalog/shared/collectionPaging.ts +78 -0
  24. package/apps/storefront/src/lib/catalog/shared/stockStatus.ts +23 -0
  25. package/apps/storefront/src/lib/catalog/tot/query.ts +8 -0
  26. package/apps/storefront/src/lib/checkout/browser/checkoutRequirementsForm.ts +54 -0
  27. package/apps/storefront/src/lib/checkout/commerce/checkoutHandoffRoute.ts +103 -35
  28. package/apps/storefront/src/lib/checkout/commerce/checkoutRequirements.ts +88 -0
  29. package/apps/storefront/src/lib/checkout/commerce/finalCartAuthorization.ts +2 -0
  30. package/apps/storefront/src/lib/checkout/commerce/sharedStoreAdmission.ts +95 -0
  31. package/apps/storefront/src/lib/checkout/shared/lazyCheckoutLoader.ts +191 -0
  32. package/apps/storefront/src/lib/compliance/browser/checkoutVerificationFlow.ts +9 -0
  33. package/apps/storefront/src/lib/compliance/enforcement.ts +7 -5
  34. package/apps/storefront/src/lib/content/seo/structuredData.ts +12 -3
  35. package/apps/storefront/src/lib/content/storyblok/provider.ts +14 -7
  36. package/apps/storefront/src/lib/observability/shared/serverTiming.ts +110 -0
  37. package/apps/storefront/src/lib/publication/shared/edgeCache.ts +3 -4
  38. package/apps/storefront/src/lib/publication/shared/staticQuery.ts +64 -0
  39. package/apps/storefront/src/middleware/controlPlane.ts +14 -11
  40. package/apps/storefront/src/middleware/index.ts +33 -12
  41. package/apps/storefront/src/middleware/renderChain.ts +52 -26
  42. package/apps/storefront/src/pages/[...slug].astro +3 -0
  43. package/apps/storefront/src/pages/api/commerce/final-cart-authorization.ts +112 -42
  44. package/apps/storefront/src/pages/api/compliance/preflight.ts +61 -13
  45. package/apps/storefront/src/pages/blog/author/[author].astro +1 -1
  46. package/apps/storefront/src/pages/blog/category/[category].astro +1 -1
  47. package/apps/storefront/src/pages/blog/tag/[tag].astro +1 -1
  48. package/apps/storefront/src/pages/catalog-index.json.ts +20 -0
  49. package/apps/storefront/src/pages/collections/[handle]/page/[n].astro +28 -0
  50. package/apps/storefront/src/pages/collections/[handle].astro +13 -198
  51. package/apps/storefront/src/pages/collections/_CollectionView.astro +251 -0
  52. package/apps/storefront/src/pages/collections/index.astro +1 -1
  53. package/apps/storefront/src/pages/products/[handle].astro +18 -8
  54. package/apps/storefront/src/pages/search.astro +33 -33
  55. package/apps/storefront/src/pages/sitemap.xml.ts +7 -1
  56. package/apps/storefront/vitest.config.cts +3 -0
  57. package/package.json +1 -1
  58. package/packages/cli/src/declared-config.mjs +318 -32
  59. package/packages/cli/src/publication-spaces.mjs +1 -1
  60. package/packages/public-runtime/package.json +1 -0
  61. package/packages/public-runtime/schemas/publication-space/v1/manifest.schema.json +1 -1
  62. package/packages/public-runtime/schemas/publication-space/v1/normalized-mount.schema.json +1 -1
  63. package/packages/public-runtime/src/activity/catalog.ts +8 -1
  64. package/packages/public-runtime/src/candidate-index.ts +5 -0
  65. package/packages/public-runtime/src/capabilities.ts +12 -9
  66. package/packages/public-runtime/src/capture-rewrites.ts +687 -0
  67. package/packages/public-runtime/src/checkout-orders.ts +6 -7
  68. package/packages/public-runtime/src/checkout.ts +190 -6
  69. package/packages/public-runtime/src/compliance/evaluate.ts +7 -3
  70. package/packages/public-runtime/src/compliance/profile.ts +90 -14
  71. package/packages/public-runtime/src/compliance-coherence.d.mts +10 -1
  72. package/packages/public-runtime/src/compliance-coherence.mjs +129 -29
  73. package/packages/public-runtime/src/customization-runtime.ts +64 -2
  74. package/packages/public-runtime/src/declared-tenant-config.d.mts +13 -2
  75. package/packages/public-runtime/src/declared-tenant-config.mjs +83 -1
  76. package/packages/public-runtime/src/image-preview-rewrite-overlay.ts +13 -37
  77. package/packages/public-runtime/src/membership.ts +3 -0
  78. package/packages/public-runtime/src/publication-space.ts +1 -0
  79. package/packages/public-runtime/src/tenant.ts +28 -4
  80. package/scripts/tenant/validate.mjs +17 -8
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Who may check out on the shared ToT test store, and what evidence that store requires.
3
+ *
4
+ * Every registered tenant whose checkout resolves to the shared test store is admitted, until it
5
+ * installs its own store. The cart's `tenant` option is HMAC-signed by storefront and the shared
6
+ * store enforces cart validation, so a shopper cannot name another tenant; the shared store's cart
7
+ * secret is therefore never issued to a partner. Production never declares the test tier, so it
8
+ * admits no one here.
9
+ */
10
+ import { commerceTenantId, SHARED_DEMO_CHECKOUT, type CustomizationEnv, type TenantConfig } from "@tot/public-runtime";
11
+ import { resolveAdminTargetTenant } from "@/config/adminTargetTenant";
12
+ import { resolveTenantCommerce } from "@/lib/checkout/shared/checkoutCommerce";
13
+ import { readEnv } from "@/lib/publication/shared/runtimeEnv";
14
+ import { readCheckoutRequirements, type CheckoutRequirements } from "./checkoutRequirements";
15
+
16
+ export type SharedStoreFinding =
17
+ | "tenant-unregistered"
18
+ | "shared-store-unavailable"
19
+ | "checkout-unconfigured"
20
+ | "tenant-own-store";
21
+
22
+ export type SharedStoreAdmission =
23
+ | { ok: true; tenant: TenantConfig }
24
+ | { ok: false; finding: SharedStoreFinding };
25
+
26
+ /** Admit `tenantId` to the shared test store: registered, test tier, and its checkout resolves there. */
27
+ export async function admitSharedStoreTenant(tenantId: string): Promise<SharedStoreAdmission> {
28
+ const tenant = await resolveAdminTargetTenant(tenantId);
29
+ if (!tenant || commerceTenantId(tenant) !== tenantId) return { ok: false, finding: "tenant-unregistered" };
30
+ if ((await readEnv("COMMERCE_DEMO_CHECKOUT_TIER")) !== "test") return { ok: false, finding: "shared-store-unavailable" };
31
+ const commerce = await resolveTenantCommerce(tenant);
32
+ if (!commerce || commerce.mode !== "signed") return { ok: false, finding: "checkout-unconfigured" };
33
+ if (commerce.storeSubDomain !== SHARED_DEMO_CHECKOUT.storeSubDomain) return { ok: false, finding: "tenant-own-store" };
34
+ return { ok: true, tenant };
35
+ }
36
+
37
+ export type EvidenceRequirement = "required" | "not-required";
38
+ export type SharedStoreEvidencePolicy = Record<"verification" | "tax" | "risk", EvidenceRequirement> & {
39
+ /** Whether compliance rules (destination, flavor, purchase limits) block at final cart. */
40
+ rules: "enforce" | "off";
41
+ };
42
+
43
+ /** Provenance stamped on evidence the shared test store's policy does not require. */
44
+ export const SHARED_STORE_POLICY_SOURCE = "shared-test-store-default";
45
+ export const SHARED_STORE_POLICY_REVISION = "shared-test-store-v1";
46
+ /** Provenance stamped on verification the live provider confirmed for a store that requires it. */
47
+ export const SHARED_STORE_VERIFICATION_SOURCE = "tot-verification-provider";
48
+
49
+ /**
50
+ * What the shared test store requires of a tenant, from the store's saved checkout requirements.
51
+ * Nothing blocks by default, regulated tenants included. Each switch the store turns on in /admin
52
+ * only adds a requirement. Requirements that cannot be read (`null`) require everything, so a
53
+ * corrupt or unreachable setting is never weaker than what the store saved. Nothing produces risk
54
+ * evidence, so risk is never required here.
55
+ */
56
+ export function testStoreEvidencePolicy(requirements: CheckoutRequirements | null): SharedStoreEvidencePolicy {
57
+ const saved = requirements ?? { verification: true, tax: true, rules: true };
58
+ return {
59
+ verification: saved.verification ? "required" : "not-required",
60
+ tax: saved.tax ? "required" : "not-required",
61
+ risk: "not-required",
62
+ rules: saved.rules ? "enforce" : "off",
63
+ };
64
+ }
65
+
66
+ /**
67
+ * The shared test store's policy for a tenant whose checkout resolves there, or `null` for every
68
+ * other checkout (its own store, or any tier but test), which keeps the full fail-closed contract.
69
+ */
70
+ export async function sharedStorePolicy(
71
+ tenant: Pick<TenantConfig, "tenant_id">,
72
+ commerce: { storeSubDomain?: string } | null | undefined,
73
+ env: CustomizationEnv,
74
+ ): Promise<SharedStoreEvidencePolicy | null> {
75
+ if (!(await usesSharedTestStore(commerce))) return null;
76
+ return testStoreEvidencePolicy(await readCheckoutRequirements(tenant.tenant_id, env));
77
+ }
78
+
79
+ /** True when this checkout resolves to the shared ToT test store on the test tier. */
80
+ export async function usesSharedTestStore(commerce: { storeSubDomain?: string } | null | undefined): Promise<boolean> {
81
+ return commerce?.storeSubDomain === SHARED_DEMO_CHECKOUT.storeSubDomain && (await readEnv("COMMERCE_DEMO_CHECKOUT_TIER")) === "test";
82
+ }
83
+
84
+ /**
85
+ * Whether the storefront's checkout compliance gate runs before handoff. It follows the shared
86
+ * test store's policy for a tenant checking out there; every other checkout keeps the gate.
87
+ */
88
+ export async function checkoutComplianceGateRequired(
89
+ tenant: Pick<TenantConfig, "tenant_id">,
90
+ commerce: { storeSubDomain?: string } | null | undefined,
91
+ env: CustomizationEnv,
92
+ ): Promise<boolean> {
93
+ const policy = await sharedStorePolicy(tenant, commerce, env);
94
+ return !policy || policy.rules === "enforce" || policy.verification === "required";
95
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Deferred managed-checkout loader bootstrap.
3
+ *
4
+ * The provider loader pulls a cart-drawer bundle (jQuery + sidecart) that costs
5
+ * ~150 ms of main-thread time on mobile — and shoppers who never touch the cart
6
+ * pay it anyway. This bootstrap injects the provider's `loader.js` once, on the
7
+ * first of: shopper input that can reach a commerce control, or a quiet-period
8
+ * timer after `load`.
9
+ *
10
+ * Commerce intent is a pointer, touch or key press made while a commerce control
11
+ * (a managed add-to-cart form, or a link or form targeting the cart origin) is
12
+ * reachable — not inside an `inert` subtree. Focus is never intent: a dialog that
13
+ * takes focus on load (the age gate) fires a trusted `focusin` with no shopper
14
+ * input at all, and a keyboard shopper's own focus moves arrive as `keydown`.
15
+ * Input on a modal that has inerted the page cannot reach a cart control, so it
16
+ * does not count either.
17
+ *
18
+ * A cart-origin link click or form submit that beats the drawer is held, not
19
+ * lost: it is cancelled, the loader is fetched, and the same control is activated
20
+ * again once the drawer reports ready (or after a bounded wait), so it opens in
21
+ * the drawer exactly as it would have. Holding runs in the window bubble phase,
22
+ * after every page handler, so an action another handler already took over (the
23
+ * compliance gate) is left alone.
24
+ *
25
+ * On a store that is not public (`storeNotPublic`), a managed add-to-cart is
26
+ * checked before it leaves the page: the checkout route refuses a viewer admitted
27
+ * only by a preview grant, so instead of navigating to the gate's holding page
28
+ * the form says checkout is unavailable here and offers sign-in. A public store
29
+ * never pays that round trip.
30
+ *
31
+ * Emitted by <CheckoutLoader> with the page nonce (an attribute cannot carry
32
+ * one), and identical on the Cloudflare-served preview and the statically
33
+ * published (CloudFront/S3) output — both render Layout.astro.
34
+ */
35
+ export const LAZY_CHECKOUT_LOADER_IDLE_MS = 8000;
36
+
37
+ /** Upper bound on waiting for the drawer to report ready before replaying a held action. */
38
+ export const LAZY_CHECKOUT_READY_TIMEOUT_MS = 4000;
39
+
40
+ export const LAZY_CHECKOUT_LOADER_EVENTS = ["pointerdown", "keydown", "touchstart"] as const;
41
+
42
+ /** The platform's add-to-cart form, which posts to the worker cart route rather than the cart origin. */
43
+ export const MANAGED_CART_FORM_SELECTOR = "form[data-managed-cart]";
44
+
45
+ export const STORE_NOT_PUBLIC_CHECKOUT_MESSAGE =
46
+ "This store isn't open to the public yet, so checkout is unavailable here. Sign in to the store to test checkout.";
47
+
48
+ export interface LazyCheckoutLoaderOptions {
49
+ loaderSrc: string;
50
+ /** `https://<store domain>` — the cart origin whose links and forms the drawer takes over. */
51
+ cartOrigin: string;
52
+ /** The store is not public on this host, so its checkout route may refuse this viewer. */
53
+ storeNotPublic?: boolean;
54
+ }
55
+
56
+ /** JSON for a script literal: `<` is escaped so a value can never close the surrounding <script>. */
57
+ function literal(value: unknown): string {
58
+ return JSON.stringify(value).replace(/</g, "\\u003c");
59
+ }
60
+
61
+ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string {
62
+ const managedCart = literal(MANAGED_CART_FORM_SELECTOR);
63
+ return `(function () {
64
+ var src = ${literal(options.loaderSrc)};
65
+ var cartOrigin = ${literal(options.cartOrigin)};
66
+ var storeNotPublic = ${options.storeNotPublic ? "true" : "false"};
67
+ var events = ${literal(LAZY_CHECKOUT_LOADER_EVENTS)};
68
+ var state = 'idle';
69
+ var waiters = [];
70
+ function targetsCart(url) {
71
+ if (!url) return false;
72
+ try { return new URL(url, document.baseURI).origin === cartOrigin; } catch (e) { return false; }
73
+ }
74
+ function commerceReachable() {
75
+ var nodes = document.querySelectorAll(${managedCart} + ', a[href], form[action]');
76
+ for (var i = 0; i < nodes.length; i++) {
77
+ var n = nodes[i];
78
+ if (n.closest('[inert]')) continue;
79
+ if (n.matches(${managedCart}) || targetsCart(n.tagName === 'A' ? n.href : n.getAttribute('action'))) return true;
80
+ }
81
+ return false;
82
+ }
83
+ function ready() {
84
+ if (state === 'ready') return;
85
+ state = 'ready';
86
+ var run = waiters;
87
+ waiters = [];
88
+ for (var i = 0; i < run.length; i++) run[i]();
89
+ }
90
+ function start() {
91
+ if (state !== 'idle') return;
92
+ state = 'loading';
93
+ for (var i = 0; i < events.length; i++) document.removeEventListener(events[i], onInput, true);
94
+ setTimeout(ready, ${LAZY_CHECKOUT_READY_TIMEOUT_MS});
95
+ var s = document.createElement('script');
96
+ s.src = src;
97
+ s.async = true;
98
+ // The loader appends the drawer bundle and exposes it as fc_script; the drawer is
99
+ // ready once that bundle has run and its client reports ready.done.
100
+ s.addEventListener('load', function () {
101
+ var bundle = window.fc_script;
102
+ if (!bundle) return ready();
103
+ bundle.addEventListener('load', function () {
104
+ var client = window.FC && window.FC.client;
105
+ if (!client || typeof client.on !== 'function') return ready();
106
+ client.on('ready.done', ready);
107
+ });
108
+ bundle.addEventListener('error', ready);
109
+ });
110
+ s.addEventListener('error', ready);
111
+ document.body.appendChild(s);
112
+ }
113
+ function whenReady(fn) {
114
+ if (state === 'ready') return fn();
115
+ waiters.push(fn);
116
+ start();
117
+ }
118
+ function onInput() {
119
+ if (commerceReachable()) start();
120
+ }
121
+ for (var i = 0; i < events.length; i++) {
122
+ document.addEventListener(events[i], onInput, { capture: true, passive: true });
123
+ }
124
+ function arm() { setTimeout(start, ${LAZY_CHECKOUT_LOADER_IDLE_MS}); }
125
+ if (document.readyState === 'complete') arm();
126
+ else window.addEventListener('load', arm);
127
+
128
+ window.addEventListener('click', function (event) {
129
+ if (state === 'ready' || event.defaultPrevented) return;
130
+ if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
131
+ var anchor = event.target && event.target.closest ? event.target.closest('a[href]') : null;
132
+ if (!anchor || anchor.target === '_blank' || !targetsCart(anchor.href)) return;
133
+ event.preventDefault();
134
+ whenReady(function () { anchor.click(); });
135
+ });
136
+
137
+ function resubmit(form) {
138
+ if (typeof form.requestSubmit === 'function') form.requestSubmit();
139
+ else form.submit();
140
+ }
141
+ function showUnavailable(form, signIn) {
142
+ var next = form.nextElementSibling;
143
+ if (next && next.hasAttribute('data-checkout-unavailable')) return;
144
+ var note = document.createElement('p');
145
+ note.setAttribute('role', 'alert');
146
+ note.setAttribute('data-checkout-unavailable', '');
147
+ note.className = 'mt-2 text-sm';
148
+ note.textContent = ${literal(STORE_NOT_PUBLIC_CHECKOUT_MESSAGE)} + ' ';
149
+ var link = document.createElement('a');
150
+ link.className = 'underline';
151
+ link.textContent = 'Sign in';
152
+ var login;
153
+ try { login = new URL(signIn || '/auth/login', location.origin); } catch (e) { login = new URL('/auth/login', location.origin); }
154
+ login.searchParams.set('next', location.pathname);
155
+ link.href = login.pathname + login.search;
156
+ note.appendChild(link);
157
+ form.insertAdjacentElement('afterend', note);
158
+ }
159
+ function proceed(form) {
160
+ form.dataset.checkoutReachable = '1';
161
+ resubmit(form);
162
+ }
163
+ window.addEventListener('submit', function (event) {
164
+ if (event.defaultPrevented) return;
165
+ var form = event.target;
166
+ if (!form || typeof form.getAttribute !== 'function') return;
167
+ if (storeNotPublic && form.matches(${managedCart}) && form.dataset.checkoutReachable !== '1') {
168
+ event.preventDefault();
169
+ // The handoff route's JSON form only mints an unattributed correlation, so it answers
170
+ // "may this viewer reach checkout?" without starting one. Any other answer, or no
171
+ // answer, submits natively and the route decides.
172
+ fetch(form.action, {
173
+ method: 'POST',
174
+ headers: { 'content-type': 'application/json' },
175
+ body: '{}',
176
+ credentials: 'same-origin',
177
+ }).then(function (res) {
178
+ if (res.status === 401 && res.headers.get('x-tot-gate') === 'store-not-public') {
179
+ return res.json().then(function (body) { return body && body.signIn; }, function () { return null; })
180
+ .then(function (signIn) { showUnavailable(form, signIn); });
181
+ }
182
+ proceed(form);
183
+ }, function () { proceed(form); });
184
+ return;
185
+ }
186
+ if (state === 'ready' || !targetsCart(form.getAttribute('action'))) return;
187
+ event.preventDefault();
188
+ whenReady(function () { resubmit(form); });
189
+ });
190
+ })();`;
191
+ }
@@ -234,6 +234,15 @@ export function verificationRefKey(
234
234
  return `tot:verify-ref:${preflightUrl}:${line.handle}:${variant}`;
235
235
  }
236
236
 
237
+ /**
238
+ * sessionStorage key for the ref a cleared verification produced on this store. Every later
239
+ * line in the same session reuses it, so the whole cart carries one ref and the shopper
240
+ * verifies once; the server still re-reads its status on every preflight.
241
+ */
242
+ export function verifiedRefKey(preflightUrl: string): string {
243
+ return `tot:verified-ref:${preflightUrl}`;
244
+ }
245
+
237
246
  export function readStoredRef(key: string): string | null {
238
247
  try {
239
248
  const v = sessionStorage.getItem(key);
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Compliance enforcement ADAPTER — the request-scoped bridge between a resolved
3
3
  * tenant and the pure `@tot/public-runtime` enforcement engine. It:
4
- * - decides whether a tenant is REGULATED (age/nicotine),
4
+ * - decides whether a tenant is REGULATED (from the obligation registry),
5
5
  * - resolves its data-driven ruleset (fail-closed: regulated + no ruleset ⇒ null),
6
6
  * - wires the LIVE verification provider from operational env
7
7
  * (`TOT_VERIFICATION_PROVIDER`) — absent ⇒ the verification gate fails closed,
@@ -17,16 +17,18 @@ import type {
17
17
  ComplianceRuleset,
18
18
  EnforcementEvidenceSignals,
19
19
  } from "@tot/public-runtime";
20
- import { enforcementSignals } from "@tot/public-runtime";
20
+ import { enforcementSignals, isRegulated } from "@tot/public-runtime";
21
21
  import { readEnv } from "@/lib/publication/shared/env";
22
22
  import { COMPLIANCE_RULE_PROFILES } from "@/config/compliance/rulesets";
23
23
 
24
- /** True when the tenant sells age/nicotine-restricted goods (drives fail-closed). */
24
+ /**
25
+ * True when the tenant's declared obligations put it on the regulated surface (drives
26
+ * fail-closed). The obligation registry decides; this never restates which keys count.
27
+ */
25
28
  export function isRegulatedTenant(
26
29
  tenant: Pick<TenantConfig, "compliance">,
27
30
  ): boolean {
28
- const c = tenant.compliance;
29
- return !!c && ((c.minAge != null && c.minAge > 0) || !!c.nicotineWarning);
31
+ return isRegulated({ compliance: tenant.compliance });
30
32
  }
31
33
 
32
34
  /**
@@ -23,8 +23,7 @@ type Node = Record<string, unknown>;
23
23
  type Profile = NonNullable<NonNullable<TenantConfig["seo"]>["structuredData"]>;
24
24
 
25
25
  /** What a page knows about itself, for the nodes that describe it. Every field is optional. */
26
- export interface StructuredDataPage {
27
- pageType: StructuredDataPageType;
26
+ interface StructuredDataPageFields {
28
27
  /** The page's primary image, site-relative or absolute. */
29
28
  image?: string | null;
30
29
  author?: { name: string; url?: string } | null;
@@ -41,6 +40,16 @@ export interface StructuredDataPage {
41
40
  items?: { title: string; url: string }[];
42
41
  }
43
42
 
43
+ /**
44
+ * A `collection` page names its breadcrumb trail: a declared `BreadcrumbList` has nothing to emit
45
+ * without one, so an archive that omits it would render no JSON-LD at all.
46
+ */
47
+ export type StructuredDataPage = StructuredDataPageFields &
48
+ (
49
+ | { pageType: Exclude<StructuredDataPageType, "collection"> }
50
+ | { pageType: "collection"; crumbs: NonNullable<StructuredDataPageFields["crumbs"]> }
51
+ );
52
+
44
53
  /** The tenant's declared profile, or null when it declares none. */
45
54
  export function structuredDataProfile(tenant: Pick<TenantConfig, "seo"> | undefined): Profile | null {
46
55
  return tenant?.seo?.structuredData ?? null;
@@ -50,7 +59,7 @@ export function structuredDataProfile(tenant: Pick<TenantConfig, "seo"> | undefi
50
59
  * A product with more than one variant is a `productGroup` when the profile declares that graph;
51
60
  * every other page type maps to itself.
52
61
  */
53
- export function productPageType(profile: Profile | null, product: CatalogProduct): StructuredDataPageType {
62
+ export function productPageType(profile: Profile | null, product: CatalogProduct): "product" | "productGroup" {
54
63
  return product.variants.length > 1 && profile?.graphs.productGroup ? "productGroup" : "product";
55
64
  }
56
65
 
@@ -29,7 +29,7 @@ import {
29
29
  readPublishedArtifact,
30
30
  readVersionArtifact,
31
31
  resolvePublishedArtifactKey,
32
- resolveVersionArtifactKey,
32
+ resolveCapturedVersionArtifactKey,
33
33
  tenantDirRelative,
34
34
  tenantDirSegment,
35
35
  } from "@tot/public-runtime";
@@ -718,8 +718,10 @@ export class PublishedContentProvider implements ContentProvider {
718
718
  return page ?? ((await this.usesVersionedContent()) ? null : this.base.getPage(slug));
719
719
  }
720
720
  async listPages(): Promise<EditorialPage[]> {
721
+ // Editorial enumeration follows its source, not the generic artifact fallback.
722
+ if (!(await this.usesVersionedContent())) return this.base.listPages();
721
723
  const paths = await this.listArtifactPaths("pages/");
722
- if (paths === null) return this.base.listPages();
724
+ if (paths === null) return [];
723
725
  const pages = await Promise.all(
724
726
  paths
725
727
  .filter((path) => path.endsWith(".json"))
@@ -728,8 +730,10 @@ export class PublishedContentProvider implements ContentProvider {
728
730
  return pages.filter((page): page is EditorialPage => page !== null);
729
731
  }
730
732
 
731
- readArtifact(relative: string): Promise<string | null> {
732
- return this.publishedRaw(relative);
733
+ async readArtifact(relative: string): Promise<string | null> {
734
+ const raw = await this.publishedRaw(relative);
735
+ if (raw !== null) return raw;
736
+ return (await this.usesVersionedContent()) ? null : this.base.readArtifact(relative);
733
737
  }
734
738
 
735
739
  async derivedArtifact(name: string): Promise<DerivedArtifact | null> {
@@ -743,8 +747,9 @@ export class PublishedContentProvider implements ContentProvider {
743
747
  async listArtifactPaths(prefix = ""): Promise<string[] | null> {
744
748
  const root = contentArtifactPath(this.tenantId, "");
745
749
  const paths = await this.versionedPaths();
746
- return paths?.filter((path) => path.startsWith(`${root}${prefix}`))
747
- .map((path) => path.slice(root.length)) ?? null;
750
+ if (paths === null) return this.base.listArtifactPaths(prefix);
751
+ return paths.filter((path) => path.startsWith(`${root}${prefix}`))
752
+ .map((path) => path.slice(root.length));
748
753
  }
749
754
  }
750
755
 
@@ -839,7 +844,9 @@ export class VersionPinnedContentProvider implements ContentProvider {
839
844
 
840
845
  async derivedArtifact(name: string): Promise<DerivedArtifact | null> {
841
846
  const get = this.get;
842
- const key = await resolveVersionArtifactKey(
847
+ // The catalog listing projection names every catalog image, so it reads through the capture
848
+ // overlay exactly as the product files it was derived from do.
849
+ const key = await resolveCapturedVersionArtifactKey(
843
850
  get, this.tenantId, this.versionId, derivedArtifactPath(this.tenantId, name),
844
851
  );
845
852
  return key ? { key, read: () => get(key) } : null;
@@ -0,0 +1,110 @@
1
+ /**
2
+ * `Server-Timing` for every Worker response: where the time before the first byte went.
3
+ *
4
+ * Each phase the request waits on (tenant resolution, the preview gate, D1 reads, the content
5
+ * source, the render) is timed and summed by name, and the middleware stamps the result on the
6
+ * way out:
7
+ *
8
+ * curl -s -o /dev/null -D - '<url>' | grep -i server-timing
9
+ * server-timing: isolate;desc="cold", tenant;dur=41, gate;dur=3, installs;dur=88, ..., total;dur=512
10
+ *
11
+ * Browsers show the same entries in DevTools (Network → Timing) and expose them to page script as
12
+ * `performance.getEntriesByType("navigation")[0].serverTiming`.
13
+ *
14
+ * The Workers clock advances only across I/O, so a duration here is time spent WAITING (KV, R2,
15
+ * D1, the cache, a subrequest), not CPU. A phase that reads 0 did no I/O; Worker CPU time is in
16
+ * `wrangler tail`'s `cpuTime`. `isolate;desc="cold"` marks the first request a Worker isolate
17
+ * served, which also pays module initialization.
18
+ *
19
+ * Phase names and durations only — never a tenant id, path, or anything request-derived.
20
+ */
21
+
22
+ /** Requests this isolate has served; the first one is the cold start. */
23
+ let isolateRequests = 0;
24
+
25
+ const PHASE_NAME = /^[a-z][a-z0-9-]*$/;
26
+
27
+ export class ServerTiming {
28
+ readonly #now: () => number;
29
+ readonly #start: number;
30
+ readonly #durations = new Map<string, number>();
31
+ readonly #notes = new Map<string, string>();
32
+
33
+ constructor(now: () => number = defaultClock) {
34
+ this.#now = now;
35
+ this.#start = now();
36
+ isolateRequests += 1;
37
+ if (isolateRequests === 1) this.#notes.set("isolate", "cold");
38
+ }
39
+
40
+ /** Time `run`, adding its duration to `name` (a phase timed twice reports the sum). */
41
+ async time<T>(name: string, run: () => Promise<T> | T): Promise<T> {
42
+ const started = this.#now();
43
+ try {
44
+ return await run();
45
+ } finally {
46
+ this.add(name, this.#now() - started);
47
+ }
48
+ }
49
+
50
+ /** Add `ms` to phase `name`. */
51
+ add(name: string, ms: number): void {
52
+ if (!PHASE_NAME.test(name)) throw new Error(`invalid Server-Timing phase name: ${name}`);
53
+ this.#durations.set(name, (this.#durations.get(name) ?? 0) + Math.max(0, ms));
54
+ }
55
+
56
+ /** A duration-less entry carrying a fixed description, e.g. `cache;desc="hit"`. */
57
+ note(name: string, desc: string): void {
58
+ if (!PHASE_NAME.test(name)) throw new Error(`invalid Server-Timing phase name: ${name}`);
59
+ this.#notes.set(name, desc.replace(/["\\\r\n]/g, ""));
60
+ }
61
+
62
+ /** The header value: notes, phases in first-timed order, then `total`. */
63
+ header(): string {
64
+ const entries: string[] = [];
65
+ for (const [name, desc] of this.#notes) entries.push(`${name};desc="${desc}"`);
66
+ for (const [name, ms] of this.#durations) entries.push(`${name};dur=${round(ms)}`);
67
+ entries.push(`total;dur=${round(this.#now() - this.#start)}`);
68
+ return entries.join(", ");
69
+ }
70
+ }
71
+
72
+ /** `timing.time(name, run)` when a recorder is present (a request); `run()` otherwise (a test). */
73
+ export async function timed<T>(
74
+ timing: ServerTiming | undefined,
75
+ name: string,
76
+ run: () => Promise<T> | T,
77
+ ): Promise<T> {
78
+ return timing ? timing.time(name, run) : await run();
79
+ }
80
+
81
+ function defaultClock(): number {
82
+ return typeof performance !== "undefined" && typeof performance.now === "function"
83
+ ? performance.now()
84
+ : Date.now();
85
+ }
86
+
87
+ function round(ms: number): number {
88
+ return Math.round(ms * 10) / 10;
89
+ }
90
+
91
+ /**
92
+ * Stamp `timing` onto `response`. A response whose headers are immutable (one returned straight
93
+ * from `fetch` or the Cache API) is re-wrapped so the header can still be set.
94
+ */
95
+ export function withServerTiming(response: Response, timing: ServerTiming): Response {
96
+ const value = timing.header();
97
+ try {
98
+ response.headers.set("Server-Timing", value);
99
+ return response;
100
+ } catch {
101
+ const copy = new Response(response.body, response);
102
+ copy.headers.set("Server-Timing", value);
103
+ return copy;
104
+ }
105
+ }
106
+
107
+ /** Test seam: forget the isolate's request count. */
108
+ export function resetIsolateForTest(): void {
109
+ isolateRequests = 0;
110
+ }
@@ -31,13 +31,12 @@ const DEFAULT_TTL = 300;
31
31
  const DEFAULT_SWR = 86_400;
32
32
 
33
33
  /**
34
- * Routes worth caching. Excludes the JSON typeahead (`?format=json`, high-q
35
- * cardinality), `/api/*`, and anything with a preview token (handled upstream).
34
+ * Routes worth caching. Excludes `/api/*` and anything with a preview token
35
+ * (handled upstream).
36
36
  * Single-segment slugs (e.g. /about, /shipping-returns) cover editorial pages;
37
37
  * non-200 responses are filtered at store time, so unknown slugs never persist.
38
38
  */
39
- export function isCacheablePath(pathname: string, params: URLSearchParams): boolean {
40
- if (params.get("format") === "json") return false;
39
+ export function isCacheablePath(pathname: string): boolean {
41
40
  if (pathname.startsWith("/api/")) return false;
42
41
  // Authenticated operator surfaces are per-viewer and MUST NOT be edge-cached —
43
42
  // the bare `/dashboard` index is a single-segment slug that would otherwise
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The query contract between a listing/search page's render and the browser.
3
+ *
4
+ * A static host serves one prerendered document per path, so the query string
5
+ * never reaches a renderer there; the Worker and the local dev loop render with
6
+ * it. Each render stamps the key of the query it applied
7
+ * (`data-applied-query`), and the browser recomputes the key from `location`:
8
+ * equal means the render already answered the URL, different means a static
9
+ * host dropped the query and the page must apply it itself
10
+ * (components/publication/storefront/islands/CatalogQueryView.tsx).
11
+ */
12
+ import type { ListProductsQuery } from "@tot/public-runtime";
13
+ import { isPathPageable, withPathPage } from "../../catalog/shared/collectionPaging.js";
14
+ import { buildQueryString, parseQuery } from "./url.js";
15
+
16
+ /** The listing query a collection view at `url` applies (page from the path when the query names none). */
17
+ export function effectiveCollectionQuery(url: URL, pathPage?: number): ListProductsQuery {
18
+ return withPathPage(parseQuery(url), pathPage);
19
+ }
20
+
21
+ /** A stable key for the query a listing view applied. */
22
+ export function listQueryKey(query: ListProductsQuery): string {
23
+ const { collection: _collection, ...rest } = query;
24
+ return buildQueryString(rest);
25
+ }
26
+
27
+ /** The search query the URL asks for (trimmed `q`); it is also the search page's applied key. */
28
+ export function searchQueryFromUrl(url: URL): string {
29
+ return (url.searchParams.get("q") ?? "").trim();
30
+ }
31
+
32
+ export type CollectionQueryPlan =
33
+ | { kind: "rendered" }
34
+ | { kind: "redirect"; href: string }
35
+ | { kind: "apply"; query: ListProductsQuery };
36
+
37
+ /**
38
+ * What a collection page must do with its URL. `rendered`: the render applied
39
+ * this query. `redirect`: the query is only a page number, which has a path
40
+ * variant. `apply`: compute the view client-side.
41
+ */
42
+ export function planCollectionQuery(opts: {
43
+ url: URL;
44
+ pathPage: number;
45
+ appliedKey: string | null;
46
+ /** Tenant-prefixed collection path, unformed (`/collections/<h>`). */
47
+ pageBase: string;
48
+ trailingSlash: boolean;
49
+ }): CollectionQueryPlan {
50
+ const query = effectiveCollectionQuery(opts.url, opts.pathPage);
51
+ if (opts.appliedKey !== null && listQueryKey(query) === opts.appliedKey) return { kind: "rendered" };
52
+ if (isPathPageable(query)) {
53
+ const page = query.page ?? 1;
54
+ let href = page > 1 ? `${opts.pageBase}/page/${page}` : opts.pageBase;
55
+ if (opts.trailingSlash && !href.endsWith("/")) href += "/";
56
+ // Params the listing does not read (a preview token, campaign tags) ride along.
57
+ const rest = new URLSearchParams(opts.url.search);
58
+ rest.delete("page");
59
+ rest.delete("pageSize");
60
+ const tail = rest.toString();
61
+ return { kind: "redirect", href: tail ? `${href}?${tail}` : href };
62
+ }
63
+ return { kind: "apply", query };
64
+ }