@tokenoftrust/storefront-runner 2.4.79 → 2.4.81

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.
@@ -152,6 +152,14 @@ declare global {
152
152
  * the pinned render.
153
153
  */
154
154
  apiBasePath: string;
155
+ /**
156
+ * True when this request was admitted to a store that is NOT public on this
157
+ * host (a login-gated host, tenant without `access.public`) — by session,
158
+ * preview grant, or QA bypass. Visitor routes read it to avoid recording
159
+ * side effects, such as a newsletter signup, for an audience that cannot
160
+ * exist yet. Unset on every public request.
161
+ */
162
+ storeNotPublic?: boolean;
155
163
  /**
156
164
  * Which customization environment to serve. "live" on production
157
165
  * hosts; "test" only behind a valid, tenant-bound preview token on a
@@ -8,6 +8,7 @@
8
8
  * the request.
9
9
  */
10
10
  import Layout from "@/layouts/Layout.astro";
11
+ import { sourceSeoTitle } from "@/lib/seo/documentTitle";
11
12
  import Breadcrumbs from "@/components/Breadcrumbs.astro";
12
13
  import BlogPaywall from "@/components/blog/BlogPaywall.astro";
13
14
  import ReactionBar from "@/components/blog/ReactionBar.astro";
@@ -26,6 +27,7 @@ const {
26
27
 
27
28
  <Layout
28
29
  title={post.seoTitle || post.title}
30
+ seoTitle={sourceSeoTitle(Astro.locals.tenant.seo, post.seoTitle)}
29
31
  description={post.description}
30
32
  path={path}
31
33
  ogType="article"
@@ -68,6 +68,14 @@ export function gatedHoldingHtml(loginHref: string): string {
68
68
  </html>`;
69
69
  }
70
70
 
71
+ /**
72
+ * Names the gate's decision on every response it answers in place of the route, so a
73
+ * caller (or a curl) can tell "withheld by the store-not-public gate" from anything
74
+ * the route itself said without reading the body.
75
+ */
76
+ export const GATE_HEADER = "x-tot-gate";
77
+ export const GATE_WITHHELD = "store-not-public";
78
+
71
79
  /** A `200 OK` holding page — never indexed, never cached. */
72
80
  export function gatedHoldingResponse(loginHref: string): Response {
73
81
  return new Response(gatedHoldingHtml(loginHref), {
@@ -76,6 +84,39 @@ export function gatedHoldingResponse(loginHref: string): Response {
76
84
  "content-type": "text/html; charset=utf-8",
77
85
  "cache-control": "no-store",
78
86
  "x-robots-tag": "noindex, nofollow",
87
+ [GATE_HEADER]: GATE_WITHHELD,
79
88
  },
80
89
  });
81
90
  }
91
+
92
+ /** A browser form submission or link, as opposed to a script's fetch. */
93
+ function isNavigation(request: Request): boolean {
94
+ const mode = request.headers.get("sec-fetch-mode");
95
+ if (mode) return mode === "navigate";
96
+ const accept = request.headers.get("accept") ?? "";
97
+ return accept.includes("text/html") && !accept.includes("application/json");
98
+ }
99
+
100
+ /**
101
+ * The gate's answer to a withheld `/api/` request: `401`, never a `200` page a
102
+ * script would read as the route's own success. A browser form submission still gets
103
+ * the holding page with its Sign-in button, the same answer the store's pages give;
104
+ * a fetch gets JSON naming the reason and where to sign in.
105
+ */
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
+ };
112
+ if (isNavigation(request)) {
113
+ return new Response(gatedHoldingHtml(loginHref), {
114
+ status: 401,
115
+ headers: { ...headers, "content-type": "text/html; charset=utf-8" },
116
+ });
117
+ }
118
+ return new Response(
119
+ JSON.stringify({ ok: false, error: GATE_WITHHELD, signIn: loginHref }),
120
+ { status: 401, headers: { ...headers, "content-type": "application/json; charset=utf-8" } },
121
+ );
122
+ }
@@ -273,6 +273,36 @@ export function isBypassPrivilegedPath(pathname: string): boolean {
273
273
  return false;
274
274
  }
275
275
 
276
+ /**
277
+ * The `/api/` routes a signed, tenant-bound preview grant admits: visitor forms on a
278
+ * tenant's own pages whose route has NO effect on a store that is not public. A
279
+ * reviewer on a copy-preview link submits them and gets the route's own answer
280
+ * instead of the holding page; the route reads `locals.storeNotPublic` and records
281
+ * nothing (the footer/homepage newsletter form confirms "off in preview").
282
+ *
283
+ * Membership is earned by that property, not by being visitor-facing. The checkout
284
+ * handoff and the compliance preflight stay refused: the preflight is the checkout
285
+ * enforcement gate and returns a real decision, and no preview credential may reach
286
+ * it. Admin, publish, change, and dashboard APIs still require a session, and beacons
287
+ * (RUM, tracking, CSP/tamper reports, blog reactions) are not admitted so preview
288
+ * traffic never lands in a store's live measurement.
289
+ *
290
+ * Exact paths, in BOTH addressing forms (the gate sees the PRE-STRIP path).
291
+ */
292
+ export const PREVIEW_VISITOR_API_PATHS = ["/api/newsletter"] as const;
293
+
294
+ export function isPreviewVisitorApiPath(pathname: string): boolean {
295
+ if ((PREVIEW_VISITOR_API_PATHS as readonly string[]).includes(pathname)) return true;
296
+ const prefixed = /^\/[^/]+(\/api\/.*)$/.exec(pathname);
297
+ return !!prefixed && (PREVIEW_VISITOR_API_PATHS as readonly string[]).includes(prefixed[1]!);
298
+ }
299
+
300
+ /** Any `/api/` route, bare or tenant-prefixed. */
301
+ export function isApiPath(pathname: string): boolean {
302
+ if (pathname === "/api" || pathname.startsWith("/api/")) return true;
303
+ return /^\/[^/]+\/api(?:\/|$)/.test(pathname);
304
+ }
305
+
276
306
  /**
277
307
  * Control surfaces a withheld visitor is sent straight to sign-in for, instead of the
278
308
  * "Coming soon" holding page: admin and the cockpit (with its `/dev` alias), in both the
@@ -327,8 +357,9 @@ export interface LoginGateInput {
327
357
  *
328
358
  * Available to EVERY tenant: previewing an unpublished storefront is platform
329
359
  * functionality, not a property of which tenant is being viewed. Passes the gate
330
- * for PUBLIC CONTENT paths only; {@link isBypassPrivilegedPath} paths (admin,
331
- * cockpit, /api) still require a real session.
360
+ * for PUBLIC CONTENT paths and {@link isPreviewVisitorApiPath} routes only; every
361
+ * other {@link isBypassPrivilegedPath} path (admin, cockpit, /api) still requires a
362
+ * real session.
332
363
  */
333
364
  previewTokenValid?: boolean;
334
365
  /** A verified, tenant-bound candidate content pin admits public content only. */
@@ -357,11 +388,12 @@ export function evaluateLoginGate(input: LoginGateInput): LoginGateDecision {
357
388
  // public content only. NEVER for admin/cockpit/api — that guard lives here (not
358
389
  // just in middleware) so /admin stays gated even if the flag is set upstream.
359
390
  if (input.qaBypass && !isBypassPrivilegedPath(input.pathname)) return { action: "pass" };
360
- // Signed, tenant-bound preview grants admit public content only. Admin,
361
- // cockpit, and API routes still require a session.
391
+ // Signed, tenant-bound preview grants admit public content and the side-effect-free
392
+ // visitor forms on it. Admin, cockpit, and every other API route still require a
393
+ // session.
362
394
  if (
363
395
  (input.previewTokenValid || input.candidatePinValid) &&
364
- !isBypassPrivilegedPath(input.pathname)
396
+ (!isBypassPrivilegedPath(input.pathname) || isPreviewVisitorApiPath(input.pathname))
365
397
  ) {
366
398
  return { action: "pass" };
367
399
  }
@@ -13,14 +13,14 @@
13
13
  * renders as written, any other is suffixed.
14
14
  */
15
15
  /**
16
- * A product's or collection's own catalog SEO title, as the `seoTitle` to render verbatim, when
17
- * the tenant declares `seo.verbatimCatalogTitles`; otherwise undefined, so it is suffixed.
16
+ * A product's, collection's or blog post's own SEO title, as the `seoTitle` to render verbatim,
17
+ * when the tenant declares `seo.verbatimSeoTitles`; otherwise undefined, so it is suffixed.
18
18
  */
19
- export function catalogSeoTitle(
20
- seo: { verbatimCatalogTitles?: boolean } | undefined,
19
+ export function sourceSeoTitle(
20
+ seo: { verbatimSeoTitles?: boolean } | undefined,
21
21
  ownSeoTitle: string | undefined,
22
22
  ): string | undefined {
23
- return seo?.verbatimCatalogTitles && ownSeoTitle?.trim() ? ownSeoTitle : undefined;
23
+ return seo?.verbatimSeoTitles && ownSeoTitle?.trim() ? ownSeoTitle : undefined;
24
24
  }
25
25
 
26
26
  export function documentTitle(title: string, seoTitle: string | undefined, storeName: string): string {
@@ -23,6 +23,7 @@ import type {
23
23
  } from "@tot/public-runtime";
24
24
  import {
25
25
  evaluateLoginGate,
26
+ isApiPath,
26
27
  isAuthPath,
27
28
  isAdminPath,
28
29
  isSignInRedirectPath,
@@ -32,7 +33,7 @@ import {
32
33
  } from "@/lib/auth/loginGate";
33
34
  import { resolveAdminLanding, isAdminSelectPath } from "@/lib/auth/adminLanding";
34
35
  import { resolveAdminTenantMode } from "@/lib/adminTenantEnvelope";
35
- import { gatedHoldingResponse } from "@/lib/auth/gatePage";
36
+ import { gatedApiResponse, gatedHoldingResponse } from "@/lib/auth/gatePage";
36
37
  import { readSession, KvSessionStore, SESSION_COOKIE } from "@/lib/auth/session";
37
38
  import { runRenderChain } from "./renderChain.js";
38
39
  import { resolveCandidateContentPin } from "./candidatePin.js";
@@ -191,9 +192,15 @@ export async function serveControlPlane(input: ControlPlaneInput): Promise<Respo
191
192
  if (isSignInRedirectPath(url.pathname)) {
192
193
  return context.redirect(decision.location, 303);
193
194
  }
195
+ // An API caller gets an honest 401, never a 200 page it would read as success.
196
+ if (isApiPath(url.pathname)) return gatedApiResponse(decision.location, request);
194
197
  // Withhold content but offer sign-in (carrying the intended destination).
195
198
  return gatedHoldingResponse(decision.location);
196
199
  }
200
+ // Admitted to a store that is not public on this host: by session, preview
201
+ // grant, or QA bypass. Visitor routes read this to stay side-effect-free for an
202
+ // audience that does not exist yet (see pages/api/newsletter.ts).
203
+ if (!tenantExempt) locals.storeNotPublic = true;
197
204
  if (admitted && viewerRecord) {
198
205
  // Surface the viewer's standing FOR THIS host's tenant as chrome, BOUND to
199
206
  // the host: a member sees their tenants[] role; the owner of their own tenant
@@ -1,29 +1,83 @@
1
1
  /**
2
- * Newsletter signup endpoint.
2
+ * Newsletter signup endpoint — the footer and homepage capture forms post here.
3
3
  *
4
- * ASSUMPTION (Phase 1): there is no ESP integration yet. We persist the email to
4
+ * ASSUMPTION (Phase 1): there is no ESP integration yet. We persist the signup to
5
5
  * the TENANT_CACHE KV namespace when the Cloudflare runtime binding is present
6
- * (key `newsletter:<email>`), and otherwise just log. Wiring a real ESP
7
- * (Klaviyo/Mailchimp) is a documented Phase 2 swap at the marked spot below.
6
+ * (key `newsletter:<tenant_id>:<email>`). Wiring a real ESP (Klaviyo/Mailchimp) is a
7
+ * documented Phase 2 swap at the marked spot below.
8
8
  *
9
- * Robustness: a signup must NEVER 500. All failures degrade to a friendly
10
- * thank-you so the visitor is never shown an error for handing us their email.
9
+ * A store that is not public yet (`locals.storeNotPublic` — a preview, a staged
10
+ * release, an owner browsing before cutover) has no audience, so a submission there
11
+ * records nothing and says so.
12
+ *
13
+ * Every outcome is a confirmation page with a link back to where the visitor was;
14
+ * a script that asks for JSON gets `{ ok, status }`. Robustness: a signup must NEVER
15
+ * 500. All failures degrade to a friendly thank-you.
11
16
  */
12
17
  import type { APIContext } from "astro";
13
18
  import { readKv } from "@/lib/env";
19
+ import { withBase } from "@/lib/basePath";
14
20
 
15
21
  // Minimal, permissive email shape check — we are not the source of truth for
16
22
  // deliverability; the ESP validates properly downstream.
17
23
  const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
18
24
 
19
- function thankYou(message: string, status = 200): Response {
25
+ export type NewsletterStatus = "subscribed" | "preview" | "invalid";
26
+
27
+ const COPY: Record<NewsletterStatus, { heading: string; message: string }> = {
28
+ subscribed: {
29
+ heading: "You're on the list.",
30
+ message: "We'll send word when something worth knowing lands.",
31
+ },
32
+ preview: {
33
+ heading: "Newsletter is off in preview.",
34
+ message:
35
+ "This store isn't public yet, so no subscription was recorded. The form works the same way once the store is live.",
36
+ },
37
+ invalid: {
38
+ heading: "That didn't go through.",
39
+ message: "That address didn't look quite right — head back and try again.",
40
+ },
41
+ };
42
+
43
+ function escapeHtml(value: string): string {
44
+ return value.replace(/[&<>"']/g, (ch) => `&#${ch.charCodeAt(0)};`);
45
+ }
46
+
47
+ /** Where "back" goes: the submitting page when it is on this origin, else the store root. */
48
+ function backHref(context: APIContext): string {
49
+ const home = withBase(context.locals.basePath ?? "", "/");
50
+ const referer = context.request.headers.get("referer");
51
+ if (!referer) return home;
52
+ try {
53
+ const back = new URL(referer);
54
+ return back.origin === new URL(context.request.url).origin
55
+ ? `${back.pathname}${back.search}${back.hash}`
56
+ : home;
57
+ } catch {
58
+ return home;
59
+ }
60
+ }
61
+
62
+ function wantsJson(request: Request): boolean {
63
+ return (request.headers.get("accept") ?? "").includes("application/json");
64
+ }
65
+
66
+ function respond(context: APIContext, status: NewsletterStatus): Response {
67
+ if (wantsJson(context.request)) {
68
+ return new Response(JSON.stringify({ ok: status !== "invalid", status }), {
69
+ status: 200,
70
+ headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" },
71
+ });
72
+ }
73
+ const { heading, message } = COPY[status];
20
74
  const html = `<!doctype html>
21
75
  <html lang="en">
22
76
  <head>
23
77
  <meta charset="utf-8" />
24
78
  <meta name="viewport" content="width=device-width, initial-scale=1" />
25
79
  <meta name="robots" content="noindex" />
26
- <title>Thank you</title>
80
+ <title>${escapeHtml(heading)}</title>
27
81
  <style>
28
82
  :root { color-scheme: light dark; }
29
83
  body { font-family: ui-serif, Georgia, serif; margin: 0; min-height: 100vh;
@@ -36,16 +90,16 @@ function thankYou(message: string, status = 200): Response {
36
90
  </style>
37
91
  </head>
38
92
  <body>
39
- <div class="card">
40
- <h1>You're on the list.</h1>
41
- <p>${message}</p>
42
- <p><a href="/">Back to the shop →</a></p>
93
+ <div class="card" data-newsletter-status="${status}">
94
+ <h1>${escapeHtml(heading)}</h1>
95
+ <p>${escapeHtml(message)}</p>
96
+ <p><a href="${escapeHtml(backHref(context))}">Back to the shop →</a></p>
43
97
  </div>
44
98
  </body>
45
99
  </html>`;
46
100
  return new Response(html, {
47
- status,
48
- headers: { "content-type": "text/html; charset=utf-8" },
101
+ status: 200,
102
+ headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" },
49
103
  });
50
104
  }
51
105
 
@@ -59,46 +113,32 @@ export async function POST(context: APIContext): Promise<Response> {
59
113
  const email = String(form.get("email") ?? "").trim().toLowerCase();
60
114
  const source = String(form.get("source") ?? "unknown").trim();
61
115
 
62
- if (!EMAIL_RE.test(email)) {
63
- return thankYou(
64
- "That address didn't look quite right — head back and try again.",
65
- 200,
66
- );
67
- }
116
+ if (!EMAIL_RE.test(email)) return respond(context, "invalid");
117
+ if (context.locals.storeNotPublic) return respond(context, "preview");
68
118
 
69
119
  // Persist. Phase 1: KV stub. Phase 2: replace with an ESP API call here.
120
+ const tenantId = context.locals.tenant.tenant_id;
70
121
  const kv = await readKv("TENANT_CACHE");
71
122
  if (kv) {
72
123
  await kv.put(
73
- `newsletter:${email}`,
124
+ `newsletter:${tenantId}:${email}`,
74
125
  JSON.stringify({ email, source, at: new Date().toISOString() }),
75
126
  );
76
127
  } else {
77
- // No KV binding (local/dev): log and move on.
78
- console.log(`[newsletter] signup ${email} (source: ${source})`);
79
- }
80
-
81
- // Redirect back where they came from (or home) with a success flag, so the
82
- // page can show its own confirmation in-context. Falls back to the
83
- // thank-you page if there's no usable referer.
84
- const referer = context.request.headers.get("referer");
85
- if (referer) {
86
- try {
87
- const back = new URL(referer);
88
- back.searchParams.set("subscribed", "1");
89
- return Response.redirect(back.toString(), 303);
90
- } catch {
91
- // fall through to thank-you
92
- }
128
+ // No KV binding (local/dev): nothing to persist to.
129
+ console.log(`[newsletter] signup not persisted (no TENANT_CACHE) tenant=${tenantId} source=${source}`);
93
130
  }
94
- return thankYou("We'll send word when something worth knowing lands.");
95
- } catch {
96
- // Never 500 on a signup.
97
- return thankYou("We'll send word when something worth knowing lands.");
131
+ return respond(context, "subscribed");
132
+ } catch (err) {
133
+ // Never 500 on a signup, but never swallow the failure silently either.
134
+ console.error(
135
+ `[newsletter] signup failed tenant=${context.locals.tenant?.tenant_id ?? "?"}: ${err instanceof Error ? err.name : "unknown"}`,
136
+ );
137
+ return respond(context, "subscribed");
98
138
  }
99
139
  }
100
140
 
101
141
  export async function GET(context: APIContext): Promise<Response> {
102
142
  // Nothing to render here directly — bounce back to the shop.
103
- return context.redirect("/", 303);
143
+ return context.redirect(withBase(context.locals.basePath ?? "", "/"), 303);
104
144
  }
@@ -19,7 +19,7 @@ import { parseQuery, buildQueryString } from "@/lib/url";
19
19
  import { breadcrumbLd, itemListLd } from "@/lib/jsonld";
20
20
  import { truncate } from "@/lib/format";
21
21
  import { pageUrlForm } from "@/lib/seo/urlForm";
22
- import { catalogSeoTitle } from "@/lib/seo/documentTitle";
22
+ import { sourceSeoTitle } from "@/lib/seo/documentTitle";
23
23
  import { withBase } from "@/lib/basePath";
24
24
  import { isBlankDescriptionHtml, sanitizeDescriptionHtml } from "@/lib/content/sanitizeDescriptionHtml";
25
25
 
@@ -92,7 +92,7 @@ if (query.priceMin != null || query.priceMax != null) {
92
92
 
93
93
  <Layout
94
94
  title={collection.seo.title || collection.title}
95
- seoTitle={catalogSeoTitle(tenant.seo, collection.seo.title)}
95
+ seoTitle={sourceSeoTitle(tenant.seo, collection.seo.title)}
96
96
  description={collection.seo.description || (desc ? truncate(desc, 160) : `Shop ${collection.title} at ${tenant.displayName}.`)}
97
97
  path={basePath}
98
98
  noindex={isEmptyCollection}
@@ -47,7 +47,7 @@ import { isUsableImageUrl } from "@/lib/placeholder";
47
47
  import { withBase } from "@/lib/basePath";
48
48
  import { productPageType, structuredDataProfile } from "@/lib/seo/structuredData";
49
49
  import { pageUrlForm } from "@/lib/seo/urlForm";
50
- import { catalogSeoTitle } from "@/lib/seo/documentTitle";
50
+ import { sourceSeoTitle } from "@/lib/seo/documentTitle";
51
51
  import { shippingPolicyPath } from "@/lib/commerce/shippingPolicy";
52
52
  import AppWidgetFrame from "@/components/apps/AppWidgetFrame.astro";
53
53
  import { getRegistryService } from "@/lib/apps/registryAccess.js";
@@ -314,7 +314,7 @@ const structuredData = {
314
314
 
315
315
  <Layout
316
316
  title={product.seo.title || product.title}
317
- seoTitle={catalogSeoTitle(tenant.seo, product.seo.title)}
317
+ seoTitle={sourceSeoTitle(tenant.seo, product.seo.title)}
318
318
  description={product.seo.description || truncate(product.description_text, 160)}
319
319
  path={`/products/${product.handle}`}
320
320
  ogType="product"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "2.4.79",
3
+ "version": "2.4.81",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
6
6
  "packageManager": "pnpm@11.9.0",
@@ -31,7 +31,7 @@ export const FEATURE_KEYS = [
31
31
  export const SITE_TYPES = ["commerce", "marketing"];
32
32
 
33
33
  /** Every `seo` key (mirrors packages/public-runtime/src/declared-tenant-config.mjs). */
34
- export const SEO_KEYS = Object.freeze(["trailingSlash", "verbatimCatalogTitles", "structuredData"]);
34
+ export const SEO_KEYS = Object.freeze(["trailingSlash", "verbatimSeoTitles", "structuredData"]);
35
35
 
36
36
  /** Every `seo.structuredData` key. */
37
37
  export const STRUCTURED_DATA_KEYS = Object.freeze(["graphs", "names", "reviews", "onlyOn"]);
@@ -86,8 +86,8 @@ export function seoDeclarationProblems(seo) {
86
86
  if (source.trailingSlash !== undefined && typeof source.trailingSlash !== "boolean") {
87
87
  problems.push("`seo.trailingSlash` must be true or false");
88
88
  }
89
- if (source.verbatimCatalogTitles !== undefined && typeof source.verbatimCatalogTitles !== "boolean") {
90
- problems.push("`seo.verbatimCatalogTitles` must be true or false");
89
+ if (source.verbatimSeoTitles !== undefined && typeof source.verbatimSeoTitles !== "boolean") {
90
+ problems.push("`seo.verbatimSeoTitles` must be true or false");
91
91
  }
92
92
  const sd = source.structuredData;
93
93
  if (sd === undefined) return problems;
@@ -75,7 +75,7 @@ export const FEATURE_KEYS = Object.freeze([
75
75
  export const SITE_TYPES_LIST = Object.freeze(["commerce", "marketing"]);
76
76
 
77
77
  /** Every `seo` key (SeoDeclaration, tenant.ts). */
78
- export const SEO_KEYS = Object.freeze(["trailingSlash", "verbatimCatalogTitles", "structuredData"]);
78
+ export const SEO_KEYS = Object.freeze(["trailingSlash", "verbatimSeoTitles", "structuredData"]);
79
79
 
80
80
  /** Every `seo.structuredData` key. */
81
81
  export const STRUCTURED_DATA_KEYS = Object.freeze(["graphs", "names", "reviews", "onlyOn"]);
@@ -132,8 +132,8 @@ export function seoDeclarationProblems(seo) {
132
132
  if (source.trailingSlash !== undefined && typeof source.trailingSlash !== "boolean") {
133
133
  problems.push("`seo.trailingSlash` must be true or false");
134
134
  }
135
- if (source.verbatimCatalogTitles !== undefined && typeof source.verbatimCatalogTitles !== "boolean") {
136
- problems.push("`seo.verbatimCatalogTitles` must be true or false");
135
+ if (source.verbatimSeoTitles !== undefined && typeof source.verbatimSeoTitles !== "boolean") {
136
+ problems.push("`seo.verbatimSeoTitles` must be true or false");
137
137
  }
138
138
  const sd = source.structuredData;
139
139
  if (sd === undefined) return problems;
@@ -778,11 +778,11 @@ export interface SeoDeclaration {
778
778
  */
779
779
  trailingSlash?: boolean;
780
780
  /**
781
- * A product's or collection's own catalog SEO title is the complete `<title>`, rendered as
782
- * written (a source that already ends its titles in "| Brand"). Absent or false ⇒ it is
783
- * suffixed with the store name like any other page title.
781
+ * A product's, collection's or blog post's own SEO title is the complete `<title>`, rendered
782
+ * as written (a source that already ends its titles in "| Brand"). Absent or false ⇒ it is
783
+ * suffixed with the store name unless it already names the store.
784
784
  */
785
- verbatimCatalogTitles?: boolean;
785
+ verbatimSeoTitles?: boolean;
786
786
  /**
787
787
  * The structured-data profile. When present it is the ONLY JSON-LD a declared page type
788
788
  * emits: one `@graph` holding exactly the listed nodes, in order. A node whose data the