@tokenoftrust/storefront-runner 2.4.34 → 2.4.36

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.
@@ -321,7 +321,11 @@ a.article-card__tag:hover { text-decoration: underline; }
321
321
 
322
322
  /* --- Age gate (21+ interstitial; remembered per device) ------------------- */
323
323
  /* Overlay + on-dark surfaces derive from --color-text (deep ink) so no brand hex. */
324
- .age-gate { position: fixed; inset: 0; z-index: 1000; display: none; align-items: center; justify-content: center; padding: 24px; background: color-mix(in srgb, var(--color-text) 78%, transparent); }
324
+ /* Sized by viewport units, not `inset: 0`: on a phone the fixed containing block is the
325
+ LAYOUT viewport, which widens with content wider than the screen and shrinks as it
326
+ settles, so a panel centered in it moves with the page and can sit off-screen. The
327
+ shadow spread carries the backdrop over any layout viewport wider than the screen. */
328
+ .age-gate { box-sizing: border-box; position: fixed; top: 0; left: 0; width: 100vw; height: 100svh; overflow-y: auto; z-index: 1000; display: none; align-items: center; justify-content: center; padding: 24px; background: color-mix(in srgb, var(--color-text) 78%, transparent); box-shadow: 0 0 0 200vmax color-mix(in srgb, var(--color-text) 78%, transparent); }
325
329
  .age-gate.is-open { display: flex; }
326
330
  .age-gate__panel { width: min(30rem, 100%); background: var(--color-surface); border-radius: var(--radius-lg); box-shadow: var(--shadow-lg); padding: clamp(24px, 4vw, 40px); text-align: center; }
327
331
  .age-gate__mark { font-family: var(--font-display); font-weight: 800; letter-spacing: .04em; font-size: 1.05rem; color: var(--color-text); }
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { useEffect, useRef, useState } from "preact/hooks";
10
10
  import type { ChromeAgeGate } from "@/lib/chrome/model";
11
- import { deriveAgeGateStorageKey, resolveAgeGateCopy } from "@/lib/chrome/model";
11
+ import { deriveAgeGateStorageKey, isSafeAgeGateHref, resolveAgeGateCopy } from "@/lib/chrome/model";
12
12
 
13
13
  export interface Props {
14
14
  minAge: number;
@@ -40,6 +40,18 @@ export function resolveAgeGateRuntime({
40
40
  resolveAgeGateCopy(presentation?.denyText, minAge) ??
41
41
  `You must be ${minAge} or older to view this store. Please close this tab.`,
42
42
  fineText: resolveAgeGateCopy(presentation?.fineText, minAge),
43
+ showNo: presentation?.showNo !== false,
44
+ eyebrow:
45
+ presentation?.eyebrow === false
46
+ ? null
47
+ : (resolveAgeGateCopy(presentation?.eyebrow, minAge) ?? "Age verification"),
48
+ helpLink:
49
+ presentation?.helpLink && isSafeAgeGateHref(presentation.helpLink.href)
50
+ ? {
51
+ label: resolveAgeGateCopy(presentation.helpLink.label, minAge)!,
52
+ href: presentation.helpLink.href,
53
+ }
54
+ : null,
43
55
  };
44
56
  }
45
57
 
@@ -47,7 +59,7 @@ export default function AgeGate({ minAge, brand, tenantId, presentation, onBlock
47
59
  const [state, setState] = useState<"hidden" | "ask" | "denied">("hidden");
48
60
  const yesRef = useRef<HTMLButtonElement>(null);
49
61
  const retryRef = useRef<HTMLButtonElement>(null);
50
- const { storageKey, compact, title, lede, yesLabel, noLabel, denyText, fineText } =
62
+ const { storageKey, compact, title, lede, yesLabel, noLabel, denyText, fineText, showNo, eyebrow, helpLink } =
51
63
  resolveAgeGateRuntime({ minAge, brand, tenantId, presentation });
52
64
 
53
65
  useEffect(() => {
@@ -91,24 +103,36 @@ export default function AgeGate({ minAge, brand, tenantId, presentation, onBlock
91
103
  // attributes are intentionally admitted by style-src-attr. Keep the modal
92
104
  // self-contained, token-driven, and responsive without opening either the
93
105
  // shadow root or the CSP.
106
+ // The backdrop covers the fixed containing block, which on mobile is the LAYOUT
107
+ // viewport: it grows with any content wider than the screen and shrinks as that
108
+ // content settles. Centering the panel in it let the page under the gate move the
109
+ // panel (CLS 0.30 on product pages). The panel is placed in a frame sized only
110
+ // by viewport units, which no document content can change.
94
111
  const overlayStyle = {
95
112
  boxSizing: "border-box",
96
113
  position: "fixed" as const,
97
114
  inset: "0",
98
- display: "flex",
99
- alignItems: compact ? "flex-start" : "center",
100
- justifyContent: "center",
101
- width: "100%",
102
- height: "100%",
103
- padding: compact ? "min(24px, 4vw)" : "20px",
104
115
  background: compact ? "rgba(0, 0, 0, 0.5)" : "color-mix(in srgb, var(--color-text, #111) 75%, transparent)",
105
116
  backdropFilter: compact ? undefined : "blur(4px)",
106
117
  color: "var(--color-text, #171717)",
107
118
  fontFamily: "var(--font-body, system-ui, sans-serif)",
108
119
  pointerEvents: "auto",
109
120
  };
121
+ const frameStyle = {
122
+ boxSizing: "border-box",
123
+ position: "absolute" as const,
124
+ top: "0",
125
+ left: "0",
126
+ width: "100vw",
127
+ height: "100svh",
128
+ display: "flex",
129
+ overflowY: "auto" as const,
130
+ padding: compact ? "min(24px, 4vw)" : "20px",
131
+ };
110
132
  const panelStyle = {
111
133
  boxSizing: "border-box",
134
+ // Auto margins center without clipping a panel taller than the viewport.
135
+ margin: compact ? "0 auto auto" : "auto",
112
136
  width: compact ? "min(600px, calc(100vw - 32px))" : "min(480px, calc(100vw - 40px))",
113
137
  minHeight: compact ? "min(382px, calc(100vh - 48px))" : undefined,
114
138
  maxHeight: compact ? "calc(100vh - 48px)" : undefined,
@@ -141,7 +165,7 @@ export default function AgeGate({ minAge, brand, tenantId, presentation, onBlock
141
165
  onKeyDown={(event) => {
142
166
  if (event.key !== "Tab") return;
143
167
  const controls = Array.from(
144
- event.currentTarget.querySelectorAll<HTMLButtonElement>("button:not([disabled])"),
168
+ event.currentTarget.querySelectorAll<HTMLElement>("button:not([disabled]), a[href]"),
145
169
  );
146
170
  if (controls.length === 0) return;
147
171
  const first = controls[0]!;
@@ -155,6 +179,7 @@ export default function AgeGate({ minAge, brand, tenantId, presentation, onBlock
155
179
  }
156
180
  }}
157
181
  >
182
+ <div style={frameStyle}>
158
183
  <div style={panelStyle}>
159
184
  {state === "ask" ? (
160
185
  <>
@@ -164,9 +189,9 @@ export default function AgeGate({ minAge, brand, tenantId, presentation, onBlock
164
189
  <span class={part.className}>{part.text}</span>
165
190
  ))}
166
191
  </p>
167
- ) : (
168
- <p style={{ margin: "0 0 16px", fontFamily: "var(--font-display, system-ui, sans-serif)", fontSize: "12px", fontWeight: 700, letterSpacing: ".12em", textTransform: "uppercase", color: "var(--color-muted, #616161)" }}>Age verification</p>
169
- )}
192
+ ) : eyebrow ? (
193
+ <p style={{ margin: "0 0 16px", fontFamily: "var(--font-display, system-ui, sans-serif)", fontSize: "12px", fontWeight: 700, letterSpacing: ".12em", textTransform: "uppercase", color: "var(--color-muted, #616161)" }}>{eyebrow}</p>
194
+ ) : null}
170
195
  <h2 id="agegate-title" style={{ margin: 0, fontFamily: "var(--font-display, system-ui, sans-serif)", fontSize: "clamp(24px, 4vw, 32px)", fontWeight: 600, lineHeight: 1.1, letterSpacing: "-.02em" }}>
171
196
  {title}
172
197
  </h2>
@@ -182,14 +207,23 @@ export default function AgeGate({ minAge, brand, tenantId, presentation, onBlock
182
207
  >
183
208
  {yesLabel}
184
209
  </button>
185
- <button
186
- type="button"
187
- onClick={() => setState("denied")}
188
- style={{ ...buttonBase, width: compact ? "100%" : undefined, border: "1px solid var(--color-border, #d0d0d0)", background: "transparent", color: "var(--color-text, #171717)", fontWeight: 500 }}
189
- >
190
- {noLabel}
191
- </button>
210
+ {showNo && (
211
+ <button
212
+ type="button"
213
+ onClick={() => setState("denied")}
214
+ style={{ ...buttonBase, width: compact ? "100%" : undefined, border: "1px solid var(--color-border, #d0d0d0)", background: "transparent", color: "var(--color-text, #171717)", fontWeight: 500 }}
215
+ >
216
+ {noLabel}
217
+ </button>
218
+ )}
192
219
  </div>
220
+ {helpLink && (
221
+ <p style={{ margin: "16px 0 0", fontSize: "14px", lineHeight: 1.45 }}>
222
+ <a href={helpLink.href} style={{ color: "var(--color-accent-ink, var(--color-primary, #111))", textDecoration: "underline" }}>
223
+ {helpLink.label}
224
+ </a>
225
+ </p>
226
+ )}
193
227
  {fineText && (
194
228
  <p style={{ maxWidth: "32rem", margin: "20px auto 0", color: "var(--color-muted, #616161)", fontSize: "12px", lineHeight: 1.45 }}>
195
229
  {fineText}
@@ -213,6 +247,7 @@ export default function AgeGate({ minAge, brand, tenantId, presentation, onBlock
213
247
  </>
214
248
  )}
215
249
  </div>
250
+ </div>
216
251
  </div>
217
252
  );
218
253
  }
@@ -217,21 +217,36 @@ export function mountIsolatedAgeGate({ minAge, brand, tenantId, presentation }:
217
217
  mount,
218
218
  );
219
219
 
220
- // Re-assert: restore the host if tenant code detaches or hides it.
220
+ // Re-assert: restore the host if tenant code detaches or hides it. The repair
221
+ // writes exactly what this observer watches, so it runs disconnected: a repair
222
+ // that is not perfectly idempotent can never re-trigger itself and spin the
223
+ // main thread in an unbounded microtask loop.
224
+ const observeHost = () => {
225
+ observer.observe(document.body, { childList: true });
226
+ observer.observe(host, {
227
+ attributes: true,
228
+ attributeFilter: ["style", "class", "hidden"],
229
+ });
230
+ };
221
231
  const ensure = () => {
222
- if (!host.isConnected || document.body.lastElementChild !== host) {
223
- document.body.appendChild(host);
232
+ observer.disconnect();
233
+ try {
234
+ if (!host.isConnected || document.body.lastElementChild !== host) {
235
+ // Moving the host blurs whatever the gate had focused; give it back so a
236
+ // keyboard visitor is never stranded by a script appending to <body>.
237
+ const focused = shadow.activeElement as HTMLElement | null;
238
+ document.body.appendChild(host);
239
+ focused?.focus({ preventScroll: true });
240
+ }
241
+ if (host.hasAttribute("hidden")) host.removeAttribute("hidden");
242
+ hardenComplianceHost(host);
243
+ backgroundIsolation?.refresh();
244
+ } finally {
245
+ observeHost();
224
246
  }
225
- if (host.hasAttribute("hidden")) host.removeAttribute("hidden");
226
- hardenComplianceHost(host);
227
- backgroundIsolation?.refresh();
228
247
  };
229
248
  const observer = new MutationObserver(ensure);
230
- observer.observe(document.body, { childList: true });
231
- observer.observe(host, {
232
- attributes: true,
233
- attributeFilter: ["style", "class", "hidden"],
234
- });
249
+ observeHost();
235
250
 
236
251
  return () => {
237
252
  observer.disconnect();
@@ -33,6 +33,7 @@ export {
33
33
  CHROME_AGE_GATE_VARIANTS,
34
34
  type ChromeAgeGateVariant,
35
35
  deriveAgeGateStorageKey,
36
+ isSafeAgeGateHref,
36
37
  resolveAgeGateCopy,
37
38
  type ChromeFooterBrand,
38
39
  type ChromeTrustMark,
@@ -50,6 +50,16 @@ export function carriesLiveNonce(response: Response): boolean {
50
50
  });
51
51
  }
52
52
 
53
+ /**
54
+ * True for the response Astro replaces with its error page: status 404 or 500 and
55
+ * no body (`astro/dist/core/routing/handler.js`, REROUTABLE_STATUS_CODES). Astro
56
+ * renders that page in a separate middleware pass and then lets THIS response's
57
+ * headers override the page's own, so this response must carry no CSP.
58
+ */
59
+ export function isErrorPageReroute(response: Response): boolean {
60
+ return response.body === null && (response.status === 404 || response.status === 500);
61
+ }
62
+
53
63
  /** A fresh, non-guessable per-response nonce. */
54
64
  export function freshNonce(): string {
55
65
  return crypto.randomUUID();
@@ -92,7 +92,12 @@ import {
92
92
  deriveSiteSurface,
93
93
  cspHeaderName,
94
94
  } from "@/lib/integration/browserEnvironment";
95
- import { carriesLiveNonce, freshNonce, stampResponseNonce } from "@/lib/integration/nonceStamp";
95
+ import {
96
+ carriesLiveNonce,
97
+ freshNonce,
98
+ isErrorPageReroute,
99
+ stampResponseNonce,
100
+ } from "@/lib/integration/nonceStamp";
96
101
  import {
97
102
  composeIntegrationDeclarations,
98
103
  resolveIntegrationRenderState,
@@ -712,12 +717,19 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
712
717
  ? await next(rewriteTarget)
713
718
  : await next();
714
719
 
715
- // A nested pass of this middleware — an in-process `rewrite()`, or Astro re-running
716
- // middleware for its error route — already decorated and nonce-stamped the
717
- // response. Serve it verbatim: decorating again would inject preview helpers twice
718
- // and replace the header's nonce while the document keeps the inner one.
720
+ // A nested pass of this middleware (an in-process `rewrite()`) already decorated
721
+ // and nonce-stamped the response. Serve it verbatim: decorating again would inject
722
+ // preview helpers twice and replace the header's nonce while the document keeps
723
+ // the inner one.
719
724
  if (carriesLiveNonce(response)) return response;
720
725
 
726
+ // A body-less 404/500 is Astro's cue to render the error page in its OWN pass of
727
+ // this middleware and then copy THIS response's headers over that page's
728
+ // (astro/dist/core/errors/default-handler.js `mergeResponses`). A CSP set here
729
+ // would win over the error page's, naming this pass's nonce while its document
730
+ // carries the error pass's — so leave it to the pass that renders the document.
731
+ if (isErrorPageReroute(response)) return response;
732
+
721
733
  // On-publish auto-refresh: inject the live-reload client into SHARED preview
722
734
  // (`test`) HTML pages ONLY — never `live` (keeps live clean + edge-cacheable).
723
735
  // It polls /api/preview/version and reloads when this tenant's cache-version
@@ -1426,7 +1426,8 @@ stable `id` (a governed-action-registry substitute, independent of the visible `
1426
1426
  to `ChromeHeader`/`ChromeFooter`, it exports `ChromeBrand`, `ChromeSearch`, `ChromeUtilityNav`,
1427
1427
  `ChromeAnnouncement` (`{ variant: "marquee" | "bar" | "solid"; messages: string[]; label?;
1428
1428
  dismissible?; rotateMs?; storageKey? }`), `ChromeAgeGate` (`{ minAge: number; storageKey?; title?;
1429
- lede?; yesLabel?; noLabel?; denyText?; fineText?; mark? }`), `ChromeFooterBrand`, `ChromeTrustMark`,
1429
+ lede?; yesLabel?; noLabel?; denyText?; fineText?; mark?; showNo?; eyebrow?: string | false;
1430
+ helpLink?: { label; href } }`), `ChromeFooterBrand`, `ChromeTrustMark`,
1430
1431
  `ChromeHead`, `ChromeCta`, `ChromeNavItem`, and `ChromeConfig` itself.
1431
1432
 
1432
1433
  ### `lib/chrome/parseConfig.ts` — the validation gate
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "2.4.34",
3
+ "version": "2.4.36",
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",
@@ -228,6 +228,17 @@ export interface ChromeAgeGate {
228
228
  fineText?: string;
229
229
  /** Optional styled wordmark above the title (spans, like ChromeBrand.wordmark). */
230
230
  mark?: { text: string; className?: string }[];
231
+ /** `false` renders a single affirm button; the deny flow is the default. */
232
+ showNo?: boolean;
233
+ /** The line above the title when no `mark` is set: text replaces "Age verification"; `false` omits it. */
234
+ eyebrow?: string | false;
235
+ /** Help/FAQ link under the actions. `href` is a same-site path or an https URL. */
236
+ helpLink?: { label: string; href: string };
237
+ }
238
+
239
+ /** A help link the platform-owned gate will render: a same-site path or an https URL. */
240
+ export function isSafeAgeGateHref(href: string): boolean {
241
+ return /^\/(?![/\\])/.test(href) || /^https:\/\/[^/\\]/i.test(href);
231
242
  }
232
243
 
233
244
  /** Platform-owned persistence scope for a tenant's enforced age affirmation. */
@@ -590,6 +601,24 @@ function validateAgeGate(v: unknown, path: string, errors: string[]): v is Chrom
590
601
  });
591
602
  }
592
603
  }
604
+ if (v.showNo !== undefined && typeof v.showNo !== "boolean") {
605
+ errors.push(`${path}.showNo: must be a boolean`);
606
+ }
607
+ if (v.eyebrow !== undefined && v.eyebrow !== false && !isNonEmptyString(v.eyebrow)) {
608
+ errors.push(`${path}.eyebrow: must be a non-empty string or false`);
609
+ }
610
+ if (v.helpLink !== undefined) {
611
+ if (!isObject(v.helpLink)) {
612
+ errors.push(`${path}.helpLink: must be an object`);
613
+ } else {
614
+ if (!isNonEmptyString(v.helpLink.label)) {
615
+ errors.push(`${path}.helpLink.label: required non-empty string`);
616
+ }
617
+ if (typeof v.helpLink.href !== "string" || !isSafeAgeGateHref(v.helpLink.href)) {
618
+ errors.push(`${path}.helpLink.href: required; a same-site path ("/pages/faq") or an https:// URL`);
619
+ }
620
+ }
621
+ }
593
622
  return errors.length === before;
594
623
  }
595
624