@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.
Files changed (79) hide show
  1. package/agency-kit/tools/lib/public-binaries.mjs +111 -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
@@ -0,0 +1,321 @@
1
+ /**
2
+ * <CatalogQueryView> — applies a listing or search URL's query in the browser
3
+ * when the page's render did not (lib/publication/shared/staticQuery.ts). A
4
+ * static host serves one document per path, so `?sort=`, facets, price and
5
+ * `?q=` reach no renderer there; this island reads the static catalog index and
6
+ * replaces the rendered results (`[data-catalog-query-rendered]`) with the view
7
+ * the URL asks for. Where the render already applied the query (the Worker, the
8
+ * local dev loop) it renders nothing.
9
+ */
10
+ import { useEffect, useState } from "preact/hooks";
11
+ import type { Facet, ListProductsQuery } from "@tot/public-runtime";
12
+ import {
13
+ bucketByKey,
14
+ collectionViewFromIndex,
15
+ loadCatalogIndex,
16
+ searchFromIndex,
17
+ } from "@/lib/catalog/shared/catalogQueryView";
18
+ import type { CatalogIndexProduct } from "@/lib/catalog/shared/catalogIndex";
19
+ import { planCollectionQuery, searchQueryFromUrl } from "@/lib/publication/shared/staticQuery";
20
+ import {
21
+ buildQueryString,
22
+ isPriceBucketActive,
23
+ toggleFacet,
24
+ togglePriceBucket,
25
+ } from "@/lib/publication/shared/url";
26
+ import { formatPrice } from "@/lib/content/shared/format";
27
+ import { clientBasePath, withClientBase } from "@/lib/publication/shared/basePath";
28
+
29
+ type Props =
30
+ | {
31
+ mode: "collection";
32
+ collection: string;
33
+ /** The page the route's path names. */
34
+ pathPage: number;
35
+ /** Tenant-prefixed collection path in the tenant's URL form: query links hang off it. */
36
+ linkBase: string;
37
+ /** Tenant-prefixed collection path, unformed: page variants hang off it. */
38
+ pageBase: string;
39
+ trailingSlash: boolean;
40
+ locale: string;
41
+ }
42
+ | { mode: "search"; trailingSlash: boolean; locale: string };
43
+
44
+ type View =
45
+ | { kind: "collection"; query: ListProductsQuery; products: CatalogIndexProduct[]; page: number; pageSize: number; total: number; facets: Facet[] }
46
+ | { kind: "search"; q: string; products: CatalogIndexProduct[] }
47
+ | { kind: "error" };
48
+
49
+ function renderedRegion(): HTMLElement | null {
50
+ return document.querySelector<HTMLElement>("[data-catalog-query-rendered]");
51
+ }
52
+
53
+ export default function CatalogQueryView(props: Props) {
54
+ const [view, setView] = useState<View | null>(null);
55
+
56
+ useEffect(() => {
57
+ const region = renderedRegion();
58
+ const appliedKey = region?.dataset.appliedQuery ?? null;
59
+ const url = new URL(window.location.href);
60
+ let run: (() => Promise<View>) | null = null;
61
+
62
+ if (props.mode === "collection") {
63
+ const plan = planCollectionQuery({
64
+ url,
65
+ pathPage: props.pathPage,
66
+ appliedKey,
67
+ pageBase: props.pageBase,
68
+ trailingSlash: props.trailingSlash,
69
+ });
70
+ if (plan.kind === "rendered") return;
71
+ if (plan.kind === "redirect") {
72
+ window.location.replace(plan.href);
73
+ return;
74
+ }
75
+ run = async () => {
76
+ const index = await loadCatalogIndex(clientBasePath());
77
+ return { kind: "collection", query: plan.query, ...collectionViewFromIndex(index, props.collection, plan.query) } as View;
78
+ };
79
+ } else {
80
+ const q = searchQueryFromUrl(url);
81
+ if (appliedKey !== null && q === appliedKey) return;
82
+ run = async () => {
83
+ const index = await loadCatalogIndex(clientBasePath());
84
+ return { kind: "search", q, products: q ? searchFromIndex(index, q) : [] };
85
+ };
86
+ }
87
+
88
+ run()
89
+ .then(setView)
90
+ .catch(() => setView({ kind: "error" }));
91
+ }, []);
92
+
93
+ useEffect(() => {
94
+ if (!view || view.kind === "error") return;
95
+ const region = renderedRegion();
96
+ if (region) region.style.display = "none";
97
+ if (view.kind === "search") {
98
+ const heading = document.querySelector<HTMLElement>("[data-search-heading]");
99
+ if (heading) heading.textContent = view.q ? `Results for “${view.q}”` : "What are you after?";
100
+ const input = document.querySelector<HTMLInputElement>("input[name=q]");
101
+ if (input) input.value = view.q;
102
+ }
103
+ }, [view]);
104
+
105
+ if (!view || view.kind === "error") return null;
106
+ const productHref = (handle: string) => {
107
+ const path = withClientBase(`/products/${handle}`);
108
+ return props.trailingSlash && !path.endsWith("/") ? `${path}/` : path;
109
+ };
110
+
111
+ if (view.kind === "search") {
112
+ return (
113
+ <div class="container-prose pb-[clamp(2rem,5vw,3.5rem)]" data-widget="catalog-query-search">
114
+ <p class="mb-6 text-sm text-[var(--color-muted)]">
115
+ {view.products.length} {view.products.length === 1 ? "result" : "results"}
116
+ </p>
117
+ {view.q && view.products.length === 0 ? (
118
+ <p class="display-md">No matches for “{view.q}”</p>
119
+ ) : (
120
+ <Grid products={view.products} locale={props.locale} productHref={productHref} wide />
121
+ )}
122
+ </div>
123
+ );
124
+ }
125
+
126
+ const linkBase = props.mode === "collection" ? props.linkBase : "";
127
+ return (
128
+ <CollectionResults view={view} linkBase={linkBase} locale={props.locale} productHref={productHref} />
129
+ );
130
+ }
131
+
132
+ function Grid(props: {
133
+ products: CatalogIndexProduct[];
134
+ locale: string;
135
+ productHref: (handle: string) => string;
136
+ wide?: boolean;
137
+ }) {
138
+ return (
139
+ <ul class={props.wide ? "grid grid-cols-2 gap-5 lg:grid-cols-4" : "grid grid-cols-2 gap-5 lg:grid-cols-3"}>
140
+ {props.products.map((p) => {
141
+ const img = p.image;
142
+ return (
143
+ <li class="group relative">
144
+ <div class="relative aspect-[4/5] overflow-hidden rounded-md bg-[var(--color-border)] ring-1 ring-[var(--color-border)]">
145
+ {img?.url && (
146
+ <img
147
+ src={img.url}
148
+ alt={img.alt ?? p.title}
149
+ width={400}
150
+ height={500}
151
+ loading="lazy"
152
+ class="h-full w-full object-cover transition-transform duration-500 motion-safe:group-hover:scale-[1.03]"
153
+ />
154
+ )}
155
+ </div>
156
+ <a href={props.productHref(p.handle)} class="mt-3 block after:absolute after:inset-0">
157
+ {p.vendor && (
158
+ <span class="block font-mono text-[0.62rem] uppercase tracking-[0.14em] text-[var(--color-muted)]">
159
+ {p.vendor}
160
+ </span>
161
+ )}
162
+ <span class="mt-1 block font-display text-[0.95rem] font-medium leading-snug">{p.title}</span>
163
+ <span class="mt-1 block font-display text-sm tabular-nums">
164
+ {formatPrice(p.price_range.min, p.price_range.currency, props.locale)}
165
+ </span>
166
+ </a>
167
+ </li>
168
+ );
169
+ })}
170
+ </ul>
171
+ );
172
+ }
173
+
174
+ const SORTS: { value: NonNullable<ListProductsQuery["sort"]>; label: string }[] = [
175
+ { value: "featured", label: "Featured" },
176
+ { value: "newest", label: "Newest" },
177
+ { value: "price-asc", label: "Price: Low to High" },
178
+ { value: "price-desc", label: "Price: High to Low" },
179
+ { value: "title-asc", label: "Alphabetical (A–Z)" },
180
+ ];
181
+
182
+ function CollectionResults(props: {
183
+ view: Extract<View, { kind: "collection" }>;
184
+ linkBase: string;
185
+ locale: string;
186
+ productHref: (handle: string) => string;
187
+ }) {
188
+ const { query, products, page, pageSize, total, facets } = props.view;
189
+ const base = props.linkBase;
190
+ const pageCount = Math.max(1, Math.ceil(total / pageSize));
191
+ const chips: { label: string; href: string }[] = [];
192
+ for (const [name, values] of Object.entries(query.filters ?? {})) {
193
+ for (const v of values) chips.push({ label: name === "tags" ? `#${v}` : `${name}: ${v}`, href: base + toggleFacet(query, name, v) });
194
+ }
195
+ if (query.priceMin != null || query.priceMax != null) {
196
+ const { priceMin: _min, priceMax: _max, ...rest } = query;
197
+ chips.push({ label: `$${query.priceMin ?? 0}–${query.priceMax != null ? `$${query.priceMax}` : "any"}`, href: base + buildQueryString(rest) });
198
+ }
199
+ if (query.availability === "in-stock") {
200
+ const { availability: _a, ...rest } = query;
201
+ chips.push({ label: "In stock", href: base + buildQueryString(rest) });
202
+ }
203
+ const selected = (name: string, value: string) => (query.filters?.[name] ?? []).includes(value);
204
+ const facetLabel = (name: string) => (name === "tags" ? "Tags" : name === "availability" ? "Availability" : name);
205
+ const ordered = facets
206
+ .filter((f) => f.name !== "price")
207
+ .sort((a, b) => {
208
+ const rank = (n: string) => (n === "availability" ? 2 : n === "tags" ? 1 : 0);
209
+ return rank(a.name) - rank(b.name);
210
+ });
211
+ const priceFacet = facets.find((f) => f.name === "price");
212
+ const option = (href: string, label: string, count: number, isSel: boolean) => (
213
+ <li>
214
+ <a
215
+ href={href}
216
+ rel="nofollow"
217
+ aria-current={isSel ? "true" : undefined}
218
+ class={`flex items-center justify-between gap-3 rounded-[var(--radius-sm)] px-2 py-1 ${isSel ? "bg-[var(--color-primary)] text-[var(--color-primary-contrast)]" : "hover:bg-[color-mix(in_srgb,var(--color-primary)_8%,transparent)]"}`}
219
+ >
220
+ <span>{isSel ? "✓ " : ""}{label}</span>
221
+ <span class="text-xs">{count}</span>
222
+ </a>
223
+ </li>
224
+ );
225
+
226
+ return (
227
+ <div class="container-prose grid gap-10 pb-16 lg:grid-cols-[230px_1fr]" data-widget="catalog-query-collection">
228
+ <aside aria-label="Filters" class="text-sm lg:sticky lg:top-24 lg:self-start">
229
+ <div class="space-y-7">
230
+ {ordered.map((facet) => (
231
+ <fieldset>
232
+ <legend class="mb-2.5 font-display text-[0.95rem] font-medium">{facetLabel(facet.name)}</legend>
233
+ <ul class="space-y-1.5">
234
+ {facet.values.map((fv) =>
235
+ option(
236
+ base + toggleFacet(query, facet.name, fv.value),
237
+ fv.value === "all" ? "All items" : fv.value,
238
+ fv.count,
239
+ selected(facet.name, fv.value),
240
+ ),
241
+ )}
242
+ </ul>
243
+ </fieldset>
244
+ ))}
245
+ {priceFacet && (
246
+ <fieldset>
247
+ <legend class="mb-2.5 font-display text-[0.95rem] font-medium">Price</legend>
248
+ <ul class="space-y-1.5">
249
+ {priceFacet.values.map((fv) => {
250
+ const bucket = bucketByKey(fv.value);
251
+ return bucket
252
+ ? option(base + togglePriceBucket(query, bucket), bucket.label, fv.count, isPriceBucketActive(query, bucket))
253
+ : null;
254
+ })}
255
+ </ul>
256
+ </fieldset>
257
+ )}
258
+ </div>
259
+ </aside>
260
+
261
+ <div>
262
+ <div class="mb-5 flex flex-wrap items-center justify-between gap-3 border-b border-[var(--color-border)] pb-4">
263
+ <p class="text-sm text-[var(--color-muted)]">
264
+ {total} {total === 1 ? "item" : "items"}
265
+ </p>
266
+ <label class="flex items-center gap-2 text-sm">
267
+ <span class="text-[var(--color-muted)]">Sort</span>
268
+ <select
269
+ class="rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 font-display"
270
+ onChange={(e) => {
271
+ const sort = (e.currentTarget as HTMLSelectElement).value as ListProductsQuery["sort"];
272
+ window.location.assign(base + buildQueryString(query, { sort, page: 1 }));
273
+ }}
274
+ >
275
+ {SORTS.map((o) => (
276
+ <option value={o.value} selected={o.value === (query.sort ?? "featured")}>{o.label}</option>
277
+ ))}
278
+ </select>
279
+ </label>
280
+ </div>
281
+
282
+ {chips.length > 0 && (
283
+ <div class="mb-5 flex flex-wrap items-center gap-2">
284
+ {chips.map((chip) => (
285
+ <a href={chip.href} rel="nofollow" class="inline-flex items-center gap-1.5 rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] py-1 pl-3 pr-2 text-xs">
286
+ <span>{chip.label}</span>
287
+ <span aria-hidden="true">×</span>
288
+ <span class="sr-only">Remove filter</span>
289
+ </a>
290
+ ))}
291
+ <a href={base} rel="nofollow" class="ml-1 text-xs text-[var(--color-muted)] underline underline-offset-2">Clear all</a>
292
+ </div>
293
+ )}
294
+
295
+ {products.length === 0 ? (
296
+ <p class="display-md">Nothing matches — yet</p>
297
+ ) : (
298
+ <Grid products={products} locale={props.locale} productHref={props.productHref} />
299
+ )}
300
+
301
+ {pageCount > 1 && (
302
+ <nav aria-label="Pagination" class="mt-12 flex justify-center">
303
+ <ul class="flex items-center gap-1">
304
+ {Array.from({ length: pageCount }, (_, i) => i + 1).map((p) => (
305
+ <li>
306
+ <a
307
+ href={base + buildQueryString(query, { page: p })}
308
+ aria-current={p === page ? "page" : undefined}
309
+ class={`grid h-9 w-9 place-items-center rounded-[var(--radius-sm)] font-display text-sm ${p === page ? "bg-[var(--color-primary)] text-[var(--color-primary-contrast)]" : "hover:bg-[var(--color-surface)]"}`}
310
+ >
311
+ {p}
312
+ </a>
313
+ </li>
314
+ ))}
315
+ </ul>
316
+ </nav>
317
+ )}
318
+ </div>
319
+ </div>
320
+ );
321
+ }
@@ -19,8 +19,9 @@ import {
19
19
  type DeclaredTenantConfig,
20
20
  } from "@tot/public-runtime/declared-tenant-config";
21
21
  import {
22
- readPublishedArtifact,
23
- readVersionArtifact,
22
+ customizationArtifactKey,
23
+ readSiteVersion,
24
+ resolvePublishedArtifactKey,
24
25
  tenantIdFromPath,
25
26
  type CustomizationEnv,
26
27
  type TenantConfig,
@@ -55,40 +56,67 @@ export function checkoutDeclaredTenantConfig(tenantId: string): DeclaredTenantCo
55
56
  ?? checkoutConfigFiles[`../../../../tenants/e2e/${tenantId}/.tot/config.json`]);
56
57
  }
57
58
 
58
- /** The declaration the reconcile published for this environment, or `{}`. */
59
- export async function publishedDeclaredTenantConfig(
60
- kvGet: (key: string) => Promise<string | null>,
61
- tenantId: string,
62
- env: CustomizationEnv,
63
- ): Promise<DeclaredTenantConfig> {
64
- const raw = await readPublishedArtifact(kvGet, tenantId, env, declaredTenantConfigArtifactPath(tenantId));
65
- if (!raw) return {};
59
+ /**
60
+ * A stored declaration read back: what it declares, or `null` when it could not be read.
61
+ * `null` is never "declares nothing" — a regulated store whose compliance lives only in its
62
+ * `.tot/config.json` would read as unregulated. A judge takes it as unread
63
+ * (`declaredComplianceSignals`); a render refuses (`DeclaredTenantConfigUnreadError`).
64
+ */
65
+ export type DeclarationRead = DeclaredTenantConfig | null;
66
+
67
+ function parseDeclaration(raw: string | null): DeclarationRead {
68
+ if (raw === null) return null;
66
69
  try {
67
70
  return declaredTenantConfig(JSON.parse(raw));
68
71
  } catch {
69
- return {};
72
+ return null;
70
73
  }
71
74
  }
72
75
 
73
- /** The declaration pinned to one immutable version (a candidate under review), or `{}`. */
74
- export async function versionDeclaredTenantConfig(
76
+ /**
77
+ * The declaration the reconcile published for this environment. `{}` when the environment
78
+ * serves no declaration (nothing published, or the served version has none); `null` when the
79
+ * served version names one whose body cannot be read or parsed.
80
+ */
81
+ export async function readPublishedDeclaration(
82
+ kvGet: (key: string) => Promise<string | null>,
83
+ tenantId: string,
84
+ env: CustomizationEnv,
85
+ ): Promise<DeclarationRead> {
86
+ const key = await resolvePublishedArtifactKey(kvGet, tenantId, env, declaredTenantConfigArtifactPath(tenantId));
87
+ return key ? parseDeclaration(await kvGet(key)) : {};
88
+ }
89
+
90
+ /**
91
+ * The declaration pinned to one immutable version (a candidate under review). `{}` when the
92
+ * version carries none (the reconcile materializes only a non-empty declaration); `null` when
93
+ * the version's manifest cannot be read, or it lists a declaration whose body cannot be read or
94
+ * parsed.
95
+ */
96
+ export async function readVersionDeclaration(
75
97
  kvGet: (key: string) => Promise<string | null>,
76
98
  tenantId: string,
77
99
  versionId: string,
78
- ): Promise<DeclaredTenantConfig> {
79
- const raw = await readVersionArtifact(kvGet, tenantId, versionId, declaredTenantConfigArtifactPath(tenantId));
80
- if (!raw) return {};
81
- try {
82
- return declaredTenantConfig(JSON.parse(raw));
83
- } catch {
84
- return {};
100
+ ): Promise<DeclarationRead> {
101
+ const manifest = await readSiteVersion(kvGet, tenantId, versionId);
102
+ if (!manifest) return null;
103
+ const contentHash = manifest.artifacts?.[declaredTenantConfigArtifactPath(tenantId)];
104
+ return contentHash ? parseDeclaration(await kvGet(customizationArtifactKey(tenantId, contentHash))) : {};
105
+ }
106
+
107
+ /** A render refused because the declaration it would render under could not be read. */
108
+ export class DeclaredTenantConfigUnreadError extends Error {
109
+ constructor(tenantId: string, source: string) {
110
+ super(`declared config unreadable for ${tenantId} (${source}); refusing to render without it`);
111
+ this.name = "DeclaredTenantConfigUnreadError";
85
112
  }
86
113
  }
87
114
 
88
115
  /**
89
116
  * The tenant as it renders: its record with every declaration applied. A render
90
117
  * pinned to one version (a candidate under review) takes that version's
91
- * declaration; otherwise the environment's channel supplies it.
118
+ * declaration; otherwise the environment's channel supplies it. Throws
119
+ * {@link DeclaredTenantConfigUnreadError} when that declaration cannot be read.
92
120
  */
93
121
  export async function withDeclaredTenantConfig(
94
122
  tenant: TenantConfig,
@@ -96,10 +124,14 @@ export async function withDeclaredTenantConfig(
96
124
  source: { env: CustomizationEnv; pinnedVersionId?: string },
97
125
  ): Promise<TenantConfig> {
98
126
  const fromCheckout = applyDeclaredTenantConfig(tenant, checkoutDeclaredTenantConfig(tenant.tenant_id));
99
- return applyDeclaredTenantConfig(
100
- fromCheckout,
101
- source.pinnedVersionId
102
- ? await versionDeclaredTenantConfig(kvGet, tenant.tenant_id, source.pinnedVersionId)
103
- : await publishedDeclaredTenantConfig(kvGet, tenant.tenant_id, source.env),
104
- );
127
+ const declared = source.pinnedVersionId
128
+ ? await readVersionDeclaration(kvGet, tenant.tenant_id, source.pinnedVersionId)
129
+ : await readPublishedDeclaration(kvGet, tenant.tenant_id, source.env);
130
+ if (declared === null) {
131
+ throw new DeclaredTenantConfigUnreadError(
132
+ tenant.tenant_id,
133
+ source.pinnedVersionId ? `version ${source.pinnedVersionId}` : `channel ${source.env}`,
134
+ );
135
+ }
136
+ return applyDeclaredTenantConfig(fromCheckout, declared);
105
137
  }
@@ -27,6 +27,7 @@
27
27
  */
28
28
  import type { DeepPartial, TenantConfig, ThemeTokens } from "@tot/public-runtime";
29
29
  import { SHARED_DEMO_CHECKOUT } from "@tot/public-runtime";
30
+ import { DEFAULT_CURRENCY } from "@tot/public-runtime/declared-tenant-config";
30
31
  import { rootPublicationSpaceConfig } from "./publicationSpaceConfig";
31
32
  import { loadTenantAnalyticsDeclarations } from "@/lib/integrations/integration/tenantAnalyticsDeclarations";
32
33
 
@@ -81,7 +82,7 @@ export function synthesizeDevTenant(
81
82
  // content/home.html (globbed by dir == this id).
82
83
  storyblok_space_token: "STORYBLOK_DEV_TENANT_TOKEN_PLACEHOLDER",
83
84
  tot_data_scope: appDomain,
84
- currency: "USD",
85
+ currency: DEFAULT_CURRENCY,
85
86
  locale: "en-US",
86
87
  theme_tokens: themeTokens,
87
88
  publicationSpaces: [rootPublicationSpaceConfig(id, {
@@ -483,7 +483,7 @@ const footerWidgets = await renderWidgetSlot(
483
483
 
484
484
  {/* Checkout loader — turns add-to-cart forms into the cart drawer + hosted
485
485
  checkout. Only for commerce tenants with a store. */}
486
- <CheckoutLoader />
486
+ <CheckoutLoader nonce={cspNonce} />
487
487
 
488
488
  {/* Brand the in-page cart drawer to the tenant theme (baseline + optional
489
489
  per-tenant commerce.checkout.cartDrawerCss override). Commerce tenants only. */}
@@ -11,6 +11,8 @@
11
11
  * renders publicly so people can browse + sign up.
12
12
  */
13
13
 
14
+ import { GATE_REMEDY, type GateReason } from "@/lib/access/shared/publicAccess";
15
+
14
16
  /** Escape a string for safe interpolation into an HTML attribute value. */
15
17
  function escapeAttr(s: string): string {
16
18
  return s
@@ -62,6 +64,7 @@ export function gatedHoldingHtml(loginHref: string): string {
62
64
  <span class="badge">Token of Trust</span>
63
65
  <h1>Coming soon</h1>
64
66
  <p>This storefront isn't open to the public yet. If you have access, sign in to preview it.</p>
67
+ <p style="font-size:.85rem;color:#94a3b8">Store owner? This hosted address is a sign-in review surface and stays gated by design; your public site is served on your own domain.</p>
65
68
  <a class="btn" href="${href}">Sign in</a>
66
69
  </main>
67
70
  </body>
@@ -75,17 +78,26 @@ export function gatedHoldingHtml(loginHref: string): string {
75
78
  */
76
79
  export const GATE_HEADER = "x-tot-gate";
77
80
  export const GATE_WITHHELD = "store-not-public";
81
+ /** Why the gate withheld, and where the answer to "what turns it off" lives. */
82
+ export const GATE_REASON_HEADER = "x-tot-gate-reason";
83
+ export const GATE_DOCS_HEADER = "x-tot-gate-docs";
84
+ export const GATE_DOCS = "runbook://storefront-publish-go-live";
85
+
86
+ function gateHeaders(reason: GateReason): Record<string, string> {
87
+ return {
88
+ "cache-control": "no-store",
89
+ "x-robots-tag": "noindex, nofollow",
90
+ [GATE_HEADER]: GATE_WITHHELD,
91
+ [GATE_REASON_HEADER]: reason,
92
+ [GATE_DOCS_HEADER]: GATE_DOCS,
93
+ };
94
+ }
78
95
 
79
96
  /** A `200 OK` holding page — never indexed, never cached. */
80
- export function gatedHoldingResponse(loginHref: string): Response {
97
+ export function gatedHoldingResponse(loginHref: string, reason: GateReason): Response {
81
98
  return new Response(gatedHoldingHtml(loginHref), {
82
99
  status: 200,
83
- headers: {
84
- "content-type": "text/html; charset=utf-8",
85
- "cache-control": "no-store",
86
- "x-robots-tag": "noindex, nofollow",
87
- [GATE_HEADER]: GATE_WITHHELD,
88
- },
100
+ headers: { ...gateHeaders(reason), "content-type": "text/html; charset=utf-8" },
89
101
  });
90
102
  }
91
103
 
@@ -103,12 +115,8 @@ function isNavigation(request: Request): boolean {
103
115
  * the holding page with its Sign-in button, the same answer the store's pages give;
104
116
  * a fetch gets JSON naming the reason and where to sign in.
105
117
  */
106
- export function gatedApiResponse(loginHref: string, request: Request): Response {
107
- const headers = {
108
- "cache-control": "no-store",
109
- "x-robots-tag": "noindex, nofollow",
110
- [GATE_HEADER]: GATE_WITHHELD,
111
- };
118
+ export function gatedApiResponse(loginHref: string, request: Request, reason: GateReason): Response {
119
+ const headers = gateHeaders(reason);
112
120
  if (isNavigation(request)) {
113
121
  return new Response(gatedHoldingHtml(loginHref), {
114
122
  status: 401,
@@ -116,7 +124,14 @@ export function gatedApiResponse(loginHref: string, request: Request): Response
116
124
  });
117
125
  }
118
126
  return new Response(
119
- JSON.stringify({ ok: false, error: GATE_WITHHELD, signIn: loginHref }),
127
+ JSON.stringify({
128
+ ok: false,
129
+ error: GATE_WITHHELD,
130
+ reason,
131
+ remedy: GATE_REMEDY[reason],
132
+ docs: GATE_DOCS,
133
+ signIn: loginHref,
134
+ }),
120
135
  { status: 401, headers: { ...headers, "content-type": "application/json; charset=utf-8" } },
121
136
  );
122
137
  }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Can the public see this store? — the one answer, derived from the SAME inputs the
3
+ * login gate (`controlPlane.ts` → `evaluateLoginGate`) decides on, so the pipeline
4
+ * verdict, the gate's own response headers, and the docs can never disagree.
5
+ *
6
+ * The gate withholds a store (the "Coming soon" page, `x-tot-gate: store-not-public`) for
7
+ * exactly two reasons, and no others:
8
+ *
9
+ * - `shared-preview-host`: the request host is on `DEMO_GATE_HOSTS` (the shared
10
+ * `*.tokenoftrust.store` hosts) in `login` mode and the platform registry does not
11
+ * declare the tenant `access.public`. That is the permanent state of every hosted
12
+ * preview URL, by design: it is a review surface, not a storefront.
13
+ * - `storefront-unpaid`: `storefront.mode` is explicitly set to something other than
14
+ * `"enabled"`. This gates the tenant on every host the Worker serves, billing-bound.
15
+ *
16
+ * A tenant's own public site is not on the shared host. Cloudflare tenants are served by
17
+ * the Worker on their own domain once it is attached (not in `DEMO_GATE_HOSTS`, so the
18
+ * gate does not apply); CloudFront (`s3-overwrite`) tenants are served CDN -> S3 with no
19
+ * Worker in the path, so the gate never applies to the public site at all.
20
+ */
21
+ import { hostInGateList } from "./demoGate";
22
+ import { storefrontModeRequiresSignIn } from "@tot/public-runtime";
23
+
24
+ export type GateReason = "shared-preview-host" | "storefront-unpaid";
25
+
26
+ export interface PublicAccessInput {
27
+ /** The tenant's `storefront.mode`. */
28
+ storefrontMode: string | null | undefined;
29
+ /** `tenantIsDeclaredPublic(tenantId)` — the platform registry, never the KV record. */
30
+ declaredPublic: boolean;
31
+ /** `DEMO_GATE_MODE` parsed (`parseGateMode`). */
32
+ gateMode: "password" | "login";
33
+ /** `DEMO_GATE_HOSTS`. */
34
+ gateHosts: string | null | undefined;
35
+ /** `serving.provider`. */
36
+ provider: "worker-r2-pointer" | "s3-overwrite";
37
+ /** The host the question is asked on (the shared hosted URL's host). */
38
+ requestHost: string;
39
+ /** The tenant's hostnames that are real domains (not the shared preview hosts). */
40
+ domains: readonly string[];
41
+ }
42
+
43
+ export interface PublicAccess {
44
+ /** The shared hosted URL (`https://storefront.tokenoftrust.store/<tenant>/`). */
45
+ sharedHost: "gated" | "public";
46
+ /** The tenant's own domain(s) as served today by the Worker or CDN. */
47
+ ownDomain: "public" | "gated";
48
+ /** Why `sharedHost` / `ownDomain` is gated; absent when both are public. */
49
+ reason?: GateReason;
50
+ /** `x-tot-gate` value a withheld response carries. */
51
+ gateHeader: "store-not-public";
52
+ /** One sentence a person or agent can relay as the answer to "can the public see my store?". */
53
+ summary: string;
54
+ /** What turns the gate off, or the honest statement that nothing should. */
55
+ remedy: string;
56
+ }
57
+
58
+ export const GATE_REMEDY: Record<GateReason, string> = {
59
+ "shared-preview-host":
60
+ "Nothing to flip: the shared hosted URL is a sign-in review surface for every store. " +
61
+ "Visitors see the store on its own domain once the domain is attached; use a preview link (tot preview token) to show reviewers the pages. " +
62
+ "See runbook://storefront-publish-go-live (\"Can the public see my store?\").",
63
+ "storefront-unpaid":
64
+ "The store's storefront mode is not \"enabled\" (unpaid). Enable the storefront plan " +
65
+ "(scripts/tenant/set-storefront-mode.mjs --mode=enabled, a Token of Trust staff action); until then only signed-in members and preview links reach it.",
66
+ };
67
+
68
+ export function describePublicAccess(input: PublicAccessInput): PublicAccess {
69
+ const unpaid = storefrontModeRequiresSignIn(input.storefrontMode);
70
+ const sharedGated =
71
+ unpaid || (!input.declaredPublic && input.gateMode === "login" && hostInGateList(input.requestHost, input.gateHosts));
72
+ const ownHostGated =
73
+ unpaid ||
74
+ (!input.declaredPublic &&
75
+ input.provider === "worker-r2-pointer" &&
76
+ input.gateMode === "login" &&
77
+ input.domains.some((d) => hostInGateList(d, input.gateHosts)));
78
+ const reason: GateReason | undefined = unpaid ? "storefront-unpaid" : sharedGated ? "shared-preview-host" : undefined;
79
+ const substrate = input.provider === "s3-overwrite" ? "CloudFront" : "Cloudflare";
80
+ const summary = unpaid
81
+ ? "No: the store is unpaid (storefront.mode is not enabled), so every hosted URL shows \"Coming soon\" to signed-out visitors."
82
+ : sharedGated
83
+ ? ownHostGated
84
+ ? "No: every domain this store has points at a gated host, so signed-out visitors see \"Coming soon\"."
85
+ : `The shared hosted URL always shows "Coming soon" to signed-out visitors (by design); the public sees the store only on its own domain, served by ${substrate}.`
86
+ : "Yes: signed-out visitors can see the store.";
87
+ return {
88
+ sharedHost: sharedGated ? "gated" : "public",
89
+ ownDomain: ownHostGated ? "gated" : "public",
90
+ ...(reason ? { reason } : {}),
91
+ gateHeader: "store-not-public",
92
+ summary,
93
+ remedy: reason ? GATE_REMEDY[reason] : "None needed.",
94
+ };
95
+ }
@@ -30,6 +30,25 @@ export interface TenantRouting {
30
30
  renderPath: string;
31
31
  }
32
32
 
33
+ /** Keep preview navigation on its session host whenever that host can route the target. */
34
+ export function previewDashboardHref(
35
+ appDomain: string,
36
+ scope: {
37
+ routedTenant?: Pick<TenantConfig, "appDomain">;
38
+ basePath: string;
39
+ hostTenant: Pick<TenantConfig, "appDomain" | "pathPrefixTenants"> | null;
40
+ sharedOrigin: string;
41
+ },
42
+ ): string {
43
+ if (scope.routedTenant?.appDomain === appDomain) {
44
+ return `${scope.basePath}/preview/dashboard`;
45
+ }
46
+ const path = `/${encodeURIComponent(appDomain)}/preview/dashboard`;
47
+ return scope.hostTenant && !scope.hostTenant.pathPrefixTenants
48
+ ? new URL(path, scope.sharedOrigin).href
49
+ : path;
50
+ }
51
+
33
52
  /**
34
53
  * A path segment is treated as a tenant-domain candidate only if it looks like
35
54
  * a hostname (has a dot, no "@" or other path-ish junk). Real first segments