@tokenoftrust/storefront-runner 2.5.6 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agency-kit/tools/lib/public-binaries.mjs +111 -0
- package/apps/storefront/astro.config.mjs +7 -0
- package/apps/storefront/env.d.ts +3 -0
- package/apps/storefront/src/components/catalog/storefront/ProductCard.astro +5 -4
- package/apps/storefront/src/components/catalog/storefront/commerce/PriceDisplay.astro +2 -1
- package/apps/storefront/src/components/catalog/storefront/islands/SearchTypeahead.tsx +17 -20
- package/apps/storefront/src/components/catalog/storefront/islands/VariantSelector.tsx +13 -21
- package/apps/storefront/src/components/catalog/storefront/plp/Pagination.astro +9 -5
- package/apps/storefront/src/components/checkout/storefront/commerce/CheckoutLoader.astro +18 -6
- package/apps/storefront/src/components/compliance/storefront/islands/CheckoutComplianceGate.tsx +19 -4
- package/apps/storefront/src/components/content/storefront/Badge.astro +1 -0
- package/apps/storefront/src/components/content/storefront/blog/BlogIndexView.astro +10 -1
- package/apps/storefront/src/components/publication/storefront/islands/CatalogQueryView.tsx +321 -0
- package/apps/storefront/src/config/declaredTenantConfig.ts +59 -27
- package/apps/storefront/src/config/devTenantSeed.ts +2 -1
- package/apps/storefront/src/layouts/Layout.astro +1 -1
- package/apps/storefront/src/lib/access/auth/gatePage.ts +29 -14
- package/apps/storefront/src/lib/access/shared/publicAccess.ts +95 -0
- package/apps/storefront/src/lib/access/shared/tenantRouting.ts +19 -0
- package/apps/storefront/src/lib/catalog/shared/catalogIndex.ts +109 -0
- package/apps/storefront/src/lib/catalog/shared/catalogQueryView.ts +60 -0
- package/apps/storefront/src/lib/catalog/shared/collectionPaging.ts +78 -0
- package/apps/storefront/src/lib/catalog/shared/stockStatus.ts +23 -0
- package/apps/storefront/src/lib/catalog/tot/query.ts +8 -0
- package/apps/storefront/src/lib/checkout/browser/checkoutRequirementsForm.ts +54 -0
- package/apps/storefront/src/lib/checkout/commerce/checkoutHandoffRoute.ts +103 -35
- package/apps/storefront/src/lib/checkout/commerce/checkoutRequirements.ts +88 -0
- package/apps/storefront/src/lib/checkout/commerce/finalCartAuthorization.ts +2 -0
- package/apps/storefront/src/lib/checkout/commerce/sharedStoreAdmission.ts +95 -0
- package/apps/storefront/src/lib/checkout/shared/lazyCheckoutLoader.ts +191 -0
- package/apps/storefront/src/lib/compliance/browser/checkoutVerificationFlow.ts +9 -0
- package/apps/storefront/src/lib/compliance/enforcement.ts +7 -5
- package/apps/storefront/src/lib/content/seo/structuredData.ts +12 -3
- package/apps/storefront/src/lib/content/storyblok/provider.ts +4 -2
- package/apps/storefront/src/lib/observability/shared/serverTiming.ts +110 -0
- package/apps/storefront/src/lib/publication/shared/edgeCache.ts +3 -4
- package/apps/storefront/src/lib/publication/shared/staticQuery.ts +64 -0
- package/apps/storefront/src/middleware/controlPlane.ts +14 -11
- package/apps/storefront/src/middleware/index.ts +33 -12
- package/apps/storefront/src/middleware/renderChain.ts +52 -26
- package/apps/storefront/src/pages/[...slug].astro +3 -0
- package/apps/storefront/src/pages/api/commerce/final-cart-authorization.ts +112 -42
- package/apps/storefront/src/pages/api/compliance/preflight.ts +61 -13
- package/apps/storefront/src/pages/blog/author/[author].astro +1 -1
- package/apps/storefront/src/pages/blog/category/[category].astro +1 -1
- package/apps/storefront/src/pages/blog/tag/[tag].astro +1 -1
- package/apps/storefront/src/pages/catalog-index.json.ts +20 -0
- package/apps/storefront/src/pages/collections/[handle]/page/[n].astro +28 -0
- package/apps/storefront/src/pages/collections/[handle].astro +13 -198
- package/apps/storefront/src/pages/collections/_CollectionView.astro +251 -0
- package/apps/storefront/src/pages/collections/index.astro +1 -1
- package/apps/storefront/src/pages/products/[handle].astro +10 -5
- package/apps/storefront/src/pages/search.astro +33 -33
- package/apps/storefront/src/pages/sitemap.xml.ts +7 -1
- package/apps/storefront/vitest.config.cts +3 -0
- package/package.json +1 -1
- package/packages/cli/src/declared-config.mjs +318 -32
- package/packages/cli/src/publication-spaces.mjs +1 -1
- package/packages/public-runtime/package.json +1 -0
- package/packages/public-runtime/schemas/publication-space/v1/manifest.schema.json +1 -1
- package/packages/public-runtime/schemas/publication-space/v1/normalized-mount.schema.json +1 -1
- package/packages/public-runtime/src/activity/catalog.ts +8 -1
- package/packages/public-runtime/src/candidate-index.ts +5 -0
- package/packages/public-runtime/src/capabilities.ts +12 -9
- package/packages/public-runtime/src/capture-rewrites.ts +687 -0
- package/packages/public-runtime/src/checkout-orders.ts +6 -7
- package/packages/public-runtime/src/checkout.ts +190 -6
- package/packages/public-runtime/src/compliance/evaluate.ts +7 -3
- package/packages/public-runtime/src/compliance/profile.ts +90 -14
- package/packages/public-runtime/src/compliance-coherence.d.mts +10 -1
- package/packages/public-runtime/src/compliance-coherence.mjs +129 -29
- package/packages/public-runtime/src/customization-runtime.ts +64 -2
- package/packages/public-runtime/src/declared-tenant-config.d.mts +13 -2
- package/packages/public-runtime/src/declared-tenant-config.mjs +83 -1
- package/packages/public-runtime/src/image-preview-rewrite-overlay.ts +13 -37
- package/packages/public-runtime/src/membership.ts +3 -0
- package/packages/public-runtime/src/publication-space.ts +1 -0
- package/packages/public-runtime/src/tenant.ts +28 -4
- 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 (
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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):
|
|
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
|
-
|
|
32
|
+
resolveCapturedVersionArtifactKey,
|
|
33
33
|
tenantDirRelative,
|
|
34
34
|
tenantDirSegment,
|
|
35
35
|
} from "@tot/public-runtime";
|
|
@@ -844,7 +844,9 @@ export class VersionPinnedContentProvider implements ContentProvider {
|
|
|
844
844
|
|
|
845
845
|
async derivedArtifact(name: string): Promise<DerivedArtifact | null> {
|
|
846
846
|
const get = this.get;
|
|
847
|
-
|
|
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(
|
|
848
850
|
get, this.tenantId, this.versionId, derivedArtifactPath(this.tenantId, name),
|
|
849
851
|
);
|
|
850
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
|
|
35
|
-
*
|
|
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
|
|
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
|
+
}
|
|
@@ -11,7 +11,7 @@ import { readEnv, readD1, readKv } from "@/lib/publication/shared/env";
|
|
|
11
11
|
import { fromD1, type D1Like } from "@/lib/catalog/d1/catalog";
|
|
12
12
|
import { readCurrentTenantConsentConfig } from "@/lib/measurement/consent/consentConfigRead";
|
|
13
13
|
import { loadActiveManagedInstallDeclarations } from "@/lib/integrations/integration/managedInstallDeclarations";
|
|
14
|
-
import { buildTenantConsentBroker, verifyPreviewToken } from "@tot/public-runtime";
|
|
14
|
+
import { buildTenantConsentBroker, verifyPreviewToken, storefrontModeRequiresSignIn } from "@tot/public-runtime";
|
|
15
15
|
import { tenantIsDeclaredPublic } from "@/config/tenants";
|
|
16
16
|
import type {
|
|
17
17
|
ConsentSnapshot,
|
|
@@ -37,6 +37,7 @@ import { gatedApiResponse, gatedHoldingResponse } from "@/lib/access/auth/gatePa
|
|
|
37
37
|
import { readSession, KvSessionStore, SESSION_COOKIE } from "@/lib/access/auth/session";
|
|
38
38
|
import { runRenderChain } from "./renderChain.js";
|
|
39
39
|
import { resolveCandidateContentPin } from "./candidatePin.js";
|
|
40
|
+
import { timed } from "@/lib/observability/shared/serverTiming";
|
|
40
41
|
import type { InternalContentGrant } from "@/lib/access/auth/internalContentAuthority";
|
|
41
42
|
|
|
42
43
|
export interface ControlPlaneInput {
|
|
@@ -92,13 +93,13 @@ export async function serveControlPlane(input: ControlPlaneInput): Promise<Respo
|
|
|
92
93
|
let viewerRecord = null;
|
|
93
94
|
if (!isAuthPath(url.pathname)) {
|
|
94
95
|
const sessionKv = await readKv("SESSION");
|
|
96
|
+
const sid = context.cookies.get(SESSION_COOKIE)?.value ?? null;
|
|
95
97
|
if (sessionKv) {
|
|
96
|
-
|
|
97
|
-
viewerRecord = await readSession(
|
|
98
|
+
viewerRecord = await timed(locals.serverTiming, "session", () => readSession(
|
|
98
99
|
new KvSessionStore(sessionKv),
|
|
99
100
|
sid,
|
|
100
101
|
Math.floor(Date.now() / 1000),
|
|
101
|
-
);
|
|
102
|
+
));
|
|
102
103
|
}
|
|
103
104
|
}
|
|
104
105
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
@@ -193,9 +194,12 @@ export async function serveControlPlane(input: ControlPlaneInput): Promise<Respo
|
|
|
193
194
|
return context.redirect(decision.location, 303);
|
|
194
195
|
}
|
|
195
196
|
// An API caller gets an honest 401, never a 200 page it would read as success.
|
|
196
|
-
|
|
197
|
+
const gateReason = storefrontModeRequiresSignIn(tenant.storefront?.mode)
|
|
198
|
+
? "storefront-unpaid"
|
|
199
|
+
: "shared-preview-host";
|
|
200
|
+
if (isApiPath(url.pathname)) return gatedApiResponse(decision.location, request, gateReason);
|
|
197
201
|
// Withhold content but offer sign-in (carrying the intended destination).
|
|
198
|
-
return gatedHoldingResponse(decision.location);
|
|
202
|
+
return gatedHoldingResponse(decision.location, gateReason);
|
|
199
203
|
}
|
|
200
204
|
// Admitted to a store that is not public on this host: by session, preview
|
|
201
205
|
// grant, or QA bypass. Visitor routes read this to stay side-effect-free for an
|
|
@@ -251,10 +255,8 @@ export async function serveControlPlane(input: ControlPlaneInput): Promise<Respo
|
|
|
251
255
|
let managedIntegrationDeclarations: IntegrationDeclarationInput[] = [];
|
|
252
256
|
if (appsD1) {
|
|
253
257
|
try {
|
|
254
|
-
managedIntegrationDeclarations = await
|
|
255
|
-
fromD1(appsD1),
|
|
256
|
-
tenant.tenant_id,
|
|
257
|
-
);
|
|
258
|
+
managedIntegrationDeclarations = await timed(locals.serverTiming, "installs", () =>
|
|
259
|
+
loadActiveManagedInstallDeclarations(fromD1(appsD1), tenant.tenant_id));
|
|
258
260
|
} catch {
|
|
259
261
|
managedIntegrationDeclarations = [];
|
|
260
262
|
}
|
|
@@ -278,7 +280,8 @@ export async function serveControlPlane(input: ControlPlaneInput): Promise<Respo
|
|
|
278
280
|
publicationBucket: input.publicationBucket,
|
|
279
281
|
verifiedCandidatePin: candidatePin ?? undefined,
|
|
280
282
|
managedIntegrationDeclarations,
|
|
281
|
-
readTenantConsentSnapshot: () =>
|
|
283
|
+
readTenantConsentSnapshot: () =>
|
|
284
|
+
timed(locals.serverTiming, "consent", () => readSavedConsentSnapshot(tenant.tenant_id)),
|
|
282
285
|
// The D1 catalog serving projection, for a tenant that declares
|
|
283
286
|
// `catalog.serving: "d1"` (ADR 0033). Deferred to the chain's own catalog build
|
|
284
287
|
// so a request the static bundle or the edge cache answers never pays for it.
|