@tokenoftrust/storefront-runner 2.5.3 → 2.5.5

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.
@@ -141,11 +141,11 @@ body { overflow-x: clip; }
141
141
  .header-search svg { width: 20px; height: 20px; stroke: var(--color-primary); fill: none; stroke-width: 2; }
142
142
 
143
143
  .header-actions { display: flex; align-items: center; gap: 16px; }
144
- .header-actions a { display: inline-flex; align-items: center; color: var(--color-text); }
145
- .header-actions a:hover { color: var(--color-primary); }
146
- .header-actions svg { width: 24px; height: 24px; stroke: currentColor; fill: none; stroke-width: 1.8; }
147
- .header-actions .cart { position: relative; }
148
- .header-actions .cart .count {
144
+ .header-actions a, .nav-mobile-actions a { display: inline-flex; align-items: center; color: var(--color-text); }
145
+ .header-actions a:hover, .nav-mobile-actions a:hover { color: var(--color-primary); }
146
+ .header-actions svg, .nav-mobile-actions svg { width: 24px; height: 24px; stroke: currentColor; fill: none; stroke-width: 1.8; }
147
+ .header-actions .cart, .nav-mobile-actions .cart { position: relative; }
148
+ .header-actions .cart .count, .nav-mobile-actions .cart .count {
149
149
  position: absolute; top: -6px; right: -8px; background: var(--color-primary); color: var(--color-primary-contrast);
150
150
  font-size: .62rem; font-weight: 700; min-width: 16px; height: 16px; border-radius: var(--radius-full);
151
151
  display: grid; place-items: center; padding: 0 4px;
@@ -158,13 +158,13 @@ body { overflow-x: clip; }
158
158
  .header-cta--primary {
159
159
  background: var(--color-accent); color: var(--color-accent-contrast);
160
160
  }
161
- .header-actions .header-cta--primary:hover { color: var(--color-accent-contrast); transform: translateY(-1px); }
161
+ .header-actions .header-cta--primary:hover, .nav-mobile-actions .header-cta--primary:hover { color: var(--color-accent-contrast); transform: translateY(-1px); }
162
162
  .header-cta--secondary, .header-cta--ghost { border: 1px solid currentColor; }
163
- .header-actions .header-cta--secondary:hover, .header-actions .header-cta--ghost:hover { opacity: .8; }
163
+ .header-actions .header-cta--secondary:hover, .nav-mobile-actions .header-cta--secondary:hover, .header-actions .header-cta--ghost:hover, .nav-mobile-actions .header-cta--ghost:hover { opacity: .8; }
164
164
  /* Labelled action links (chrome header.actionLinks): text with an optional leading icon. */
165
165
  .header-action { gap: 6px; font-size: .875rem; font-weight: 600; white-space: nowrap; }
166
- .header-actions .header-action svg { width: 20px; height: 20px; }
167
- .header-actions .header-action.cart .count { position: static; margin-left: 2px; }
166
+ .header-actions .header-action svg, .nav-mobile-actions .header-action svg { width: 20px; height: 20px; }
167
+ .header-actions .header-action.cart .count, .nav-mobile-actions .header-action.cart .count { position: static; margin-left: 2px; }
168
168
  /* A search form with a visible text submit (header.search.buttonLabel). */
169
169
  .header-search--labelled { display: flex; }
170
170
  .header-search--labelled input { padding-right: 18px; border-radius: var(--radius-full) 0 0 var(--radius-full); }
@@ -197,6 +197,7 @@ body { overflow-x: clip; }
197
197
 
198
198
  /* --- Primary nav + mega-menu ---------------------------------------------- */
199
199
  .primary-nav { flex: 1; }
200
+ .nav-submenu-toggle, .nav-close, .nav-mobile-actions { display: none; }
200
201
  .primary-nav > ul { display: flex; flex-wrap: wrap; align-items: center; gap: clamp(10px, 1.6vw, 22px); }
201
202
  /* Flat primary links */
202
203
  .primary-nav a { font-weight: 600; font-size: .92rem; color: var(--color-text); padding: 6px 2px; border-bottom: 2px solid transparent; transition: color .15s, border-color .15s; }
@@ -478,8 +479,18 @@ a.article-card__tag:hover { text-decoration: underline; }
478
479
  by the hamburger. Gating the fixed/off-screen positioning behind .js-nav
479
480
  means a no-JS visitor always sees the nav in place — JS only changes its
480
481
  PRESENTATION, never its reachability. */
481
- .js-nav .primary-nav { position: fixed; inset: 0 0 0 auto; width: min(84vw, 340px); background: var(--color-surface); box-shadow: var(--shadow-lg); transform: translateX(100%); transition: transform .25s ease; padding: 78px 20px 24px; overflow-y: auto; z-index: 120; }
482
+ .js-nav .primary-nav { position: fixed; inset: 0; width: 100vw; height: 100dvh; box-sizing: border-box; visibility: hidden; background: var(--color-surface); box-shadow: var(--shadow-lg); transform: translateX(100%); transition: transform .25s ease; padding: 78px 20px 24px; overflow-y: auto; z-index: 120; }
482
483
  .js-nav .primary-nav.open { transform: none; }
484
+ .js-nav .primary-nav.open { visibility: visible; }
485
+ .js-nav .primary-nav .nav-close { display: block; position: absolute; top: 16px; right: 20px; width: 44px; height: 44px; border: 0; background: transparent; color: inherit; font-size: 2rem; }
486
+ .js-nav .primary-nav .nav-item { display: grid; grid-template-columns: minmax(0, 1fr) 44px; align-items: center; }
487
+ .js-nav .primary-nav .nav-item > a { grid-column: 1; }
488
+ .js-nav .primary-nav .nav-item > a .caret { display: none; }
489
+ .js-nav .primary-nav .nav-submenu-toggle { display: grid; place-items: center; min-height: 44px; border: 0; background: transparent; color: inherit; }
490
+ .js-nav .primary-nav .nav-submenu-toggle[aria-expanded="true"] span { transform: rotate(180deg); }
491
+ .js-nav .primary-nav .mega { grid-column: 1 / -1; }
492
+ .js-nav .primary-nav .mega[hidden] { display: none; }
493
+ .js-nav .primary-nav .nav-mobile-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 16px; padding-top: 24px; }
483
494
  /* header.mobile — menu button side, brand alignment, search placement. */
484
495
  .js-nav .site-header[data-mobile-menu="left"] .primary-nav { inset: 0 auto 0 0; transform: translateX(-100%); }
485
496
  .js-nav .site-header[data-mobile-menu="left"] .primary-nav.open { transform: none; }
@@ -235,7 +235,7 @@ const currentPath = Astro.url.pathname;
235
235
  }
236
236
  {
237
237
  !minimal && savedItemsHref && (
238
- <a
238
+ <a data-mobile-menu-action
239
239
  href={withBase(basePath, savedItemsHref)}
240
240
  aria-label="Saved items"
241
241
  class="relative grid h-9 w-9 shrink-0 place-items-center rounded-full text-text transition-colors hover:text-accent-ink"
@@ -265,7 +265,7 @@ const currentPath = Astro.url.pathname;
265
265
  index: i,
266
266
  });
267
267
  return (
268
- <a
268
+ <a data-mobile-menu-action
269
269
  href={withBase(basePath, cta.href)}
270
270
  class={ctaClass(cta.variant)}
271
271
  {...{ [idAttr.name]: idAttr.value }}
@@ -280,7 +280,7 @@ const currentPath = Astro.url.pathname;
280
280
  }
281
281
  {
282
282
  authLink && (
283
- <a
283
+ <a data-mobile-menu-action
284
284
  href={authLink.href}
285
285
  class="inline-flex shrink-0 items-center rounded-full border border-current px-4 py-2 font-display text-sm font-medium transition-opacity hover:opacity-80"
286
286
  >
@@ -290,7 +290,7 @@ const currentPath = Astro.url.pathname;
290
290
  }
291
291
  {
292
292
  accountHref && (
293
- <a
293
+ <a data-mobile-menu-action
294
294
  href={withBase(basePath, accountHref)}
295
295
  aria-label="Account"
296
296
  class="grid h-9 w-9 place-items-center rounded-full text-text transition-colors hover:text-accent-ink"
@@ -304,7 +304,7 @@ const currentPath = Astro.url.pathname;
304
304
  }
305
305
  {
306
306
  cartHref && (
307
- <a
307
+ <a data-mobile-menu-action
308
308
  href={withBase(basePath, cartHref)}
309
309
  aria-label={`Cart, ${cartCount} ${cartCount === 1 ? "item" : "items"}`}
310
310
  class="relative grid h-9 w-9 place-items-center rounded-full text-text transition-colors hover:text-accent-ink"
@@ -60,11 +60,13 @@ function prefersReducedMotion(): boolean {
60
60
 
61
61
  export default function MobileNav({ nav, announcement, currentPath }: Props) {
62
62
  const [open, setOpen] = useState(false);
63
+ const [expanded, setExpanded] = useState<Set<number>>(new Set());
63
64
  const buttonRef = useRef<HTMLButtonElement>(null);
64
65
  const panelRef = useRef<HTMLDivElement>(null);
66
+ const actionsRef = useRef<HTMLDivElement>(null);
65
67
  const reduced = prefersReducedMotion();
66
68
 
67
- const close = useCallback(() => setOpen(false), []);
69
+ const close = useCallback(() => { setOpen(false); setExpanded(new Set()); }, []);
68
70
 
69
71
  // The no-JS fallback nav is only needed when JS never runs. Since this
70
72
  // component mounting IS JS running, hide it in favor of the drawer above —
@@ -77,6 +79,29 @@ export default function MobileNav({ nav, announcement, currentPath }: Props) {
77
79
  });
78
80
  }, []);
79
81
 
82
+ // Desktop navigation takes over at the same breakpoint as md:hidden.
83
+ useEffect(() => {
84
+ const desktop = window.matchMedia("(min-width: 768px)");
85
+ const onViewportChange = () => { if (desktop.matches) close(); };
86
+ desktop.addEventListener("change", onViewportChange);
87
+ onViewportChange();
88
+ return () => desktop.removeEventListener("change", onViewportChange);
89
+ }, [close]);
90
+
91
+ // Keep each governed action's DOM identity unique across header and drawer.
92
+ useEffect(() => {
93
+ if (!open || !actionsRef.current) return;
94
+ const mount = actionsRef.current;
95
+ const header = buttonRef.current?.closest("header");
96
+ const slots = Array.from(header?.querySelectorAll<HTMLElement>("[data-mobile-menu-action]") ?? []).map((node) => {
97
+ const marker = document.createComment("header-action");
98
+ node.parentNode!.insertBefore(marker, node);
99
+ mount.appendChild(node);
100
+ return { node, marker };
101
+ });
102
+ return () => slots.forEach(({ node, marker }) => marker.parentNode?.replaceChild(node, marker));
103
+ }, [open]);
104
+
80
105
  // Body scroll lock while open.
81
106
  useEffect(() => {
82
107
  if (!open) return;
@@ -100,7 +125,7 @@ export default function MobileNav({ nav, announcement, currentPath }: Props) {
100
125
  return () => window.cancelAnimationFrame(id);
101
126
  }
102
127
  // On close, return focus to the hamburger.
103
- buttonRef.current?.focus();
128
+ if (!window.matchMedia("(min-width: 768px)").matches) buttonRef.current?.focus();
104
129
  return undefined;
105
130
  }, [open]);
106
131
 
@@ -188,7 +213,7 @@ export default function MobileNav({ nav, announcement, currentPath }: Props) {
188
213
  role="dialog"
189
214
  aria-modal="true"
190
215
  aria-label="Site menu"
191
- class="absolute inset-y-0 right-0 flex w-[85vw] max-w-sm flex-col overflow-y-auto bg-surface text-text shadow-xl"
216
+ class="absolute inset-0 flex w-full flex-col overflow-y-auto bg-surface text-text shadow-xl"
192
217
  style={
193
218
  reduced
194
219
  ? undefined
@@ -233,7 +258,7 @@ export default function MobileNav({ nav, announcement, currentPath }: Props) {
233
258
  {nav.map((item, i) => (
234
259
  <li key={item.href}>
235
260
  {item.children && item.children.length > 0 ? (
236
- <div class="px-3 py-2">
261
+ <div class="grid grid-cols-[minmax(0,1fr)_44px] items-center px-3 py-2">
237
262
  <a
238
263
  href={item.href}
239
264
  aria-current={
@@ -251,7 +276,21 @@ export default function MobileNav({ nav, announcement, currentPath }: Props) {
251
276
  >
252
277
  {item.label}
253
278
  </a>
254
- <ul class="mt-1 space-y-0.5 border-l border-border pl-3">
279
+ <button
280
+ type="button"
281
+ aria-label={`Toggle ${item.label} submenu`}
282
+ aria-expanded={expanded.has(i)}
283
+ aria-controls={`${DRAWER_ID}-submenu-${i}`}
284
+ class="grid h-11 w-11 place-items-center text-text"
285
+ onClick={() => setExpanded((previous) => {
286
+ const next = new Set(previous);
287
+ if (next.has(i)) next.delete(i); else next.add(i);
288
+ return next;
289
+ })}
290
+ >
291
+ <span aria-hidden="true">{expanded.has(i) ? "▴" : "▾"}</span>
292
+ </button>
293
+ <ul id={`${DRAWER_ID}-submenu-${i}`} hidden={!expanded.has(i)} class="col-span-2 mt-1 space-y-0.5 border-l border-border pl-3">
255
294
  {item.children.map((child, ci) => (
256
295
  <li key={child.href}>
257
296
  <a
@@ -297,6 +336,7 @@ export default function MobileNav({ nav, announcement, currentPath }: Props) {
297
336
  </li>
298
337
  ))}
299
338
  </ul>
339
+ <div ref={actionsRef} class="mt-6 flex flex-wrap items-center gap-4 border-t border-border px-3 pt-4" />
300
340
  </nav>
301
341
  </div>
302
342
  </div>
@@ -195,7 +195,10 @@ export async function listAllActiveProducts(db: Queryable): Promise<CatalogProdu
195
195
  return rows.map((r) => JSON.parse(r.data) as CatalogProduct);
196
196
  }
197
197
 
198
- function sortClause(sort?: ListProductsQuery["sort"]): string {
198
+ function sortClause(sort?: ListProductsQuery["sort"], collection = false): string {
199
+ if (collection && (!sort || sort === "featured")) {
200
+ return "ORDER BY COALESCE(cm.collection_position, 2147483647), p.position ASC, p.handle ASC";
201
+ }
199
202
  switch (sort) {
200
203
  case "price-asc":
201
204
  return "ORDER BY p.price_min ASC, p.title COLLATE NOCASE ASC";
@@ -378,10 +381,16 @@ export async function listProducts(
378
381
 
379
382
  // Pool: active products, optionally scoped to a collection.
380
383
  const poolJoin = query.collection
381
- ? "JOIN product_collections pc ON pc.handle = p.handle AND pc.collection = ?"
384
+ ? `LEFT JOIN (SELECT j.value AS handle, MIN(CAST(j.key AS INTEGER)) AS collection_position
385
+ FROM collections c, json_each(c.data, '$.product_handles') j
386
+ WHERE c.handle = ? GROUP BY j.value) cm ON cm.handle = p.handle`
382
387
  : "";
383
388
  const poolParams: unknown[] = query.collection ? [query.collection] : [];
384
- const poolSql = `FROM products p ${poolJoin} WHERE p.status = 'active' AND COALESCE(json_extract(p.data, '$.visibility'), 'public') = 'public'`;
389
+ const membership = query.collection
390
+ ? " AND (cm.handle IS NOT NULL OR EXISTS (SELECT 1 FROM product_collections pc WHERE pc.handle = p.handle AND pc.collection = ?))"
391
+ : "";
392
+ if (query.collection) poolParams.push(query.collection);
393
+ const poolSql = `FROM products p ${poolJoin} WHERE p.status = 'active' AND COALESCE(json_extract(p.data, '$.visibility'), 'public') = 'public'${membership}`;
385
394
 
386
395
  // Result filters layered on the pool.
387
396
  const clauses = buildFilterClauses(query);
@@ -398,7 +407,7 @@ export async function listProducts(
398
407
  const offset = (page - 1) * pageSize;
399
408
 
400
409
  const rows = await db.all<{ data: string }>(
401
- `SELECT p.data AS data ${poolSql}${filterClause} ${sortClause(query.sort)} LIMIT ? OFFSET ?`,
410
+ `SELECT p.data AS data ${poolSql}${filterClause} ${sortClause(query.sort, !!query.collection)} LIMIT ? OFFSET ?`,
402
411
  ...resultParams, pageSize, offset,
403
412
  );
404
413
  const products = rows.map((r) => JSON.parse(r.data) as CatalogProduct);
@@ -23,7 +23,7 @@ import type {
23
23
  TenantConfig,
24
24
  } from "@tot/public-runtime";
25
25
  import { isPublicCatalogProduct } from "@tot/public-runtime";
26
- import { buildFacets, applyQuery, paginate } from "./query.js";
26
+ import { buildFacets, applyQuery, paginate, collectionProducts } from "./query.js";
27
27
  import { D1ToTClient } from "./d1Client.js";
28
28
  import { fromD1, type D1Like } from "../d1/catalog.js";
29
29
  import { buildSearchIndex, type SearchIndex } from "../search/index.js";
@@ -67,11 +67,10 @@ export class FixtureToTClient implements ToTClient {
67
67
  let pool = active;
68
68
  if (query.collection) {
69
69
  const col = this.collections.find((c) => c.handle === query.collection);
70
- const handles = new Set(col?.product_handles ?? []);
71
- pool = active.filter((p) => handles.has(p.handle));
70
+ pool = col ? collectionProducts(active, col) : [];
72
71
  }
73
72
  const facets: Facet[] = buildFacets(pool, query);
74
- const filtered = applyQuery(pool, query);
73
+ const filtered = applyQuery(pool, query, !!query.collection);
75
74
  const { items, page, pageSize, total } = paginate(filtered, query);
76
75
  return { products: items, page, pageSize, total, facets };
77
76
  }
@@ -197,12 +197,35 @@ function applyFilters(
197
197
  return out;
198
198
  }
199
199
 
200
+ /** Declared members first (first duplicate wins), then product-side members in catalog order. */
201
+ export function collectionProducts(pool: CatalogProduct[], collection: CatalogCollection): CatalogProduct[] {
202
+ const byHandle = new Map(pool.map((p) => [p.handle, p]));
203
+ const seen = new Set<string>();
204
+ const members: CatalogProduct[] = [];
205
+ for (const handle of collection.product_handles) {
206
+ const product = byHandle.get(handle);
207
+ if (product && !seen.has(handle)) {
208
+ members.push(product);
209
+ seen.add(handle);
210
+ }
211
+ }
212
+ for (const product of pool) {
213
+ if (!seen.has(product.handle) && product.collections.includes(collection.handle)) {
214
+ members.push(product);
215
+ seen.add(product.handle);
216
+ }
217
+ }
218
+ return members;
219
+ }
220
+
200
221
  /** Apply filters + sort (NOT pagination). */
201
222
  export function applyQuery(
202
223
  pool: CatalogProduct[],
203
224
  query: ListProductsQuery,
225
+ manualOrder = false,
204
226
  ): CatalogProduct[] {
205
227
  const out = applyFilters(pool, query);
228
+ if (manualOrder && (!query.sort || query.sort === "featured")) return out;
206
229
  return sortProducts(out, query.sort, pool);
207
230
  }
208
231
 
@@ -0,0 +1,197 @@
1
+ /**
2
+ * POST /api/commerce/checkout-handoff — the checkout-correlation MINT seam
3
+ * (ext-commerce-10).
4
+ *
5
+ * The shopper's analytics consent is a CLIENT-side decision (CMP / native banner),
6
+ * but the correlation token is minted under the SERVER-only signing secret. So at the
7
+ * handoff the client posts its LIVE consent snapshot and the server mints the token,
8
+ * returning ONLY the opaque passthrough cart field to attach to the managed-cart form.
9
+ * The signing secret never leaves the server; the token carries no PII and no secret.
10
+ *
11
+ * Server-owned by construction:
12
+ * - The tenant and the {@link IntegrationEnvironment} stage come from `context.locals`
13
+ * (the renderer-owned `BrowserEnvironment`), NEVER from the request body — a client
14
+ * cannot mint a cross-tenant or cross-stage token.
15
+ * - The consent snapshot is parsed FAIL-CLOSED (`parseConsentSnapshot`): an absent,
16
+ * forged, or tampered snapshot collapses to default-deny → unattributed.
17
+ * - No store on this tenant, consent withheld, or no signing key → an explicit
18
+ * unattributed response; the handoff proceeds, checkout is never blocked.
19
+ */
20
+ import type { APIContext } from "astro";
21
+ import {
22
+ enforceCartSubmission,
23
+ foxyCartRedirectUrl,
24
+ parseConsentSnapshot,
25
+ defaultConsentSnapshot,
26
+ buildVariantCartPayload,
27
+ commerceTenantId,
28
+ MAX_CART_QUANTITY,
29
+ type CartDenialCode,
30
+ type CatalogProduct,
31
+ type TenantConfig,
32
+ type ResolvedManagedCheckout,
33
+ } from "@tot/public-runtime";
34
+ import { resolveCheckoutHandoffCorrelation } from "@/lib/checkout/commerce/checkoutHandoffCorrelation";
35
+ import { resolveCartSigning, shopperCartDenialMessage } from "@/lib/checkout/shared/checkoutCommerce";
36
+ import { readEnv } from "@/lib/publication/shared/runtimeEnv";
37
+
38
+ export type SampleProductReader = (tenant: TenantConfig, handle: string, commerce: ResolvedManagedCheckout) => Promise<CatalogProduct | null>;
39
+
40
+ function json(body: unknown, status = 200): Response {
41
+ return new Response(JSON.stringify(body), {
42
+ status,
43
+ headers: {
44
+ "content-type": "application/json; charset=utf-8",
45
+ "cache-control": "no-store",
46
+ },
47
+ });
48
+ }
49
+
50
+ interface HandoffBody {
51
+ /** The shopper's serialized consent snapshot (the `__TOT_INTEGRATION_CONSENT__` global). */
52
+ consent?: unknown;
53
+ }
54
+
55
+ function escapeHtml(value: string): string {
56
+ return value.replace(/[&<>"']/g, (ch) => {
57
+ if (ch === "&") return "&amp;";
58
+ if (ch === "<") return "&lt;";
59
+ if (ch === ">") return "&gt;";
60
+ if (ch === '"') return "&quot;";
61
+ return "&#39;";
62
+ });
63
+ }
64
+
65
+ function cartDenial(code: CartDenialCode, message: string, status: number): Response {
66
+ const body =
67
+ `<!doctype html><html><body><p data-cart-denial="${code}" data-payment-authorized="false">${escapeHtml(message)}</p></body></html>`;
68
+ return new Response(body, {
69
+ status,
70
+ headers: {
71
+ "content-type": "text/html; charset=utf-8",
72
+ "cache-control": "no-store",
73
+ "x-tot-payment-authorized": "false",
74
+ },
75
+ });
76
+ }
77
+
78
+ function isCartForm(request: Request): boolean {
79
+ const type = request.headers.get("content-type") ?? "";
80
+ return (
81
+ type.includes("application/x-www-form-urlencoded") ||
82
+ type.includes("multipart/form-data")
83
+ );
84
+ }
85
+
86
+ /** Native cart POST. Browser preflight is not consulted. Payment is never authorized. */
87
+ async function cartFormPost(context: APIContext, readSampleProduct?: SampleProductReader): Promise<Response> {
88
+ const { tenant, commerce } = context.locals;
89
+ if (!commerce) {
90
+ return cartDenial(
91
+ "signing-unavailable",
92
+ "Checkout is unavailable because this store has no cart configured.",
93
+ 409,
94
+ );
95
+ }
96
+ const signing = await resolveCartSigning(commerce);
97
+ if (!signing.enabled) {
98
+ return cartDenial(
99
+ "signing-unavailable",
100
+ shopperCartDenialMessage("signing-unavailable", signing.operatorError ?? ""),
101
+ 503,
102
+ );
103
+ }
104
+ const form = await context.request.formData();
105
+ const entries: Array<[string, string]> = [];
106
+ for (const [name, value] of form.entries()) {
107
+ if (typeof value !== "string") return cartDenial("signature", "Invalid cart.", 422);
108
+ entries.push([name, value]);
109
+ }
110
+ if (form.has("sample_product") || form.has("sample_variant")) {
111
+ const sampleTenant = await readEnv("COMMERCE_SAMPLE_TENANT_ID");
112
+ if (
113
+ !sampleTenant || sampleTenant !== commerceTenantId(tenant) ||
114
+ await readEnv("COMMERCE_DEMO_CHECKOUT_TIER") !== "test" || commerce.mode !== "signed"
115
+ ) return cartDenial("tenant", "Invalid cart.", 422);
116
+ const allowed = new Set(["sample_product", "sample_variant", "quantity"]);
117
+ if (entries.length !== 3 || entries.some(([name]) => !allowed.has(name)) ||
118
+ new Set(entries.map(([name]) => name)).size !== 3) {
119
+ return cartDenial("signature", "Invalid cart.", 422);
120
+ }
121
+ const quantity = form.get("quantity") as string;
122
+ if (!/^[1-9][0-9]*$/.test(quantity) || !Number.isSafeInteger(Number(quantity)) || Number(quantity) > MAX_CART_QUANTITY) {
123
+ return cartDenial("quantity", "Invalid quantity.", 422);
124
+ }
125
+ const product = await readSampleProduct?.(tenant, form.get("sample_product") as string, commerce) ?? null;
126
+ const variant = product?.variants.find((v) => v.id === form.get("sample_variant"));
127
+ if (!product || product.status !== "active" || product.visibility === "hidden" || product.visibility === "unlisted" ||
128
+ !variant || !variant.inventory.available || product.tenant_id !== tenant.tot_data_scope ||
129
+ variant.tenant_id !== tenant.tot_data_scope) {
130
+ return cartDenial("signature", "Product is unavailable.", 422);
131
+ }
132
+ const payload = await buildVariantCartPayload(product, variant, {
133
+ mode: "signed", secret: signing.secret, tenantId: commerceTenantId(tenant),
134
+ });
135
+ entries.splice(0, entries.length, ...payload.hidden.map((f): [string, string] => [f.name, f.value]), [payload.quantityName, quantity]);
136
+ }
137
+ const result = await enforceCartSubmission({
138
+ entries,
139
+ mode: commerce.mode,
140
+ secret: signing.secret,
141
+ trustedTenantId: commerceTenantId(tenant),
142
+ });
143
+ if (!result.ok) {
144
+ const status = result.code === "signing-unavailable" ? 503 : 422;
145
+ return cartDenial(result.code, shopperCartDenialMessage(result.code, result.message), status);
146
+ }
147
+ return new Response(null, {
148
+ status: 303,
149
+ headers: {
150
+ location: foxyCartRedirectUrl(commerce.cartUrl, result.fields),
151
+ "cache-control": "no-store",
152
+ "x-tot-payment-authorized": "false",
153
+ },
154
+ });
155
+ }
156
+
157
+ export async function checkoutHandoffPost(context: APIContext, readSampleProduct?: SampleProductReader): Promise<Response> {
158
+ if (isCartForm(context.request)) return cartFormPost(context, readSampleProduct);
159
+ const { tenant, browserEnvironment, commerce } = context.locals;
160
+
161
+ // No managed checkout on this tenant → nothing to attribute.
162
+ if (!commerce) {
163
+ return json({ attributed: false, reason: "no-store" });
164
+ }
165
+
166
+ let body: HandoffBody;
167
+ try {
168
+ body = (await context.request.json()) as HandoffBody;
169
+ } catch {
170
+ body = {};
171
+ }
172
+
173
+ // Fail-closed: an absent/forged/tampered snapshot is default-deny (essential only).
174
+ const consent = parseConsentSnapshot(body.consent) ?? defaultConsentSnapshot();
175
+
176
+ const result = await resolveCheckoutHandoffCorrelation({
177
+ tenant: tenant.tenant_id,
178
+ // Env-consistency: the request's own resolved stage, never a client value.
179
+ environment: browserEnvironment.environment,
180
+ consent,
181
+ // Opaque per-handoff logical id the order join threads back to.
182
+ eventId: crypto.randomUUID(),
183
+ });
184
+
185
+ if (result.attributed) {
186
+ // Return ONLY the opaque passthrough field; the signing secret never leaves here.
187
+ return json({ attributed: true, cartField: result.cartField });
188
+ }
189
+ return json({ attributed: false, reason: result.reason });
190
+ }
191
+
192
+ export async function GET(): Promise<Response> {
193
+ return json({ error: "method_not_allowed" }, 405);
194
+ }
195
+
196
+ /** The renderer has no hosted preview catalog authority. */
197
+ export const POST = (context: APIContext): Promise<Response> => checkoutHandoffPost(context);
@@ -265,8 +265,10 @@ function renderNavItem(item: ChromeNavItem, index: number, currentPath?: string)
265
265
  return `<div class="mega__col">${heading}${links}</div>`;
266
266
  })
267
267
  .join("");
268
- const mega = `<div class="mega${wide}"><div class="mega__cols">${cols}</div></div>`;
269
- return `<li class="nav-item">${link}${mega}</li>`;
268
+ const panelId = `nav-submenu-${index}`;
269
+ const toggle = `<button type="button" class="nav-submenu-toggle" aria-label="${esc(`Toggle ${item.label} submenu`)}" aria-expanded="false" aria-controls="${panelId}"><span aria-hidden="true">▾</span></button>`;
270
+ const mega = `<div class="mega${wide}" id="${panelId}"><div class="mega__cols">${cols}</div></div>`;
271
+ return `<li class="nav-item">${link}${toggle}${mega}</li>`;
270
272
  }
271
273
 
272
274
  /**
@@ -383,7 +385,9 @@ function renderHeader(header: ChromeHeader, variant: HeaderVariant, currentPath?
383
385
  const primaryNav =
384
386
  `<nav class="primary-nav" id="primaryNav" aria-label="Primary">\n` +
385
387
  menuSearch +
388
+ `<button type="button" class="nav-close" aria-label="Close navigation menu">×</button>` +
386
389
  ` <ul>\n ${navItems}\n </ul>\n` +
390
+ `<div class="nav-mobile-actions"></div>` +
387
391
  ` </nav>`;
388
392
  const navBar = minimal
389
393
  ? ""
@@ -396,7 +400,7 @@ function renderHeader(header: ChromeHeader, variant: HeaderVariant, currentPath?
396
400
  ` </nav>\n`;
397
401
  const compactSearch =
398
402
  compact && header.search
399
- ? `<a class="header-search-trigger" href="${esc(header.search.action ?? "/search")}" aria-label="${esc(header.search.label ?? "Search")}">${ICON_SEARCH}</a>`
403
+ ? `<a class="header-search-trigger" data-header-fixed-action href="${esc(header.search.action ?? "/search")}" aria-label="${esc(header.search.label ?? "Search")}">${ICON_SEARCH}</a>`
400
404
  : "";
401
405
  const mobileBottom =
402
406
  compact
@@ -656,10 +660,81 @@ export const CHROME_BEHAVIOR_JS = `(function () {
656
660
  // Mobile nav drawer
657
661
  var burger = document.getElementById('hamburger');
658
662
  var nav = document.getElementById('primaryNav');
659
- if (burger && nav) burger.addEventListener('click', function () {
660
- var open = nav.classList.toggle('open');
661
- burger.setAttribute('aria-expanded', open ? 'true' : 'false');
662
- });
663
+ if (burger && nav) {
664
+ var mobileNavQuery = window.matchMedia('(max-width: 860px)');
665
+ var navClose = nav.querySelector('.nav-close');
666
+ var previousOverflow = null;
667
+ var actionMount = nav.querySelector('.nav-mobile-actions');
668
+ var header = burger.closest('.site-header');
669
+ var actionNodes = header ? Array.from(header.querySelectorAll('.header-actions > a:not([data-header-fixed-action])')) : [];
670
+ var actionSlots = actionNodes.map(function (node) {
671
+ return { node: node, marker: document.createComment('header-action') };
672
+ });
673
+ var placeActions = function (inMenu) {
674
+ actionSlots.forEach(function (slot) {
675
+ if (inMenu && actionMount && !slot.marker.parentNode) {
676
+ slot.node.parentNode.insertBefore(slot.marker, slot.node);
677
+ actionMount.appendChild(slot.node);
678
+ } else if (!inMenu && slot.marker.parentNode) {
679
+ slot.marker.parentNode.replaceChild(slot.node, slot.marker);
680
+ }
681
+ });
682
+ };
683
+ var setNavOpen = function (open, returnFocus) {
684
+ open = open && mobileNavQuery.matches;
685
+ nav.classList.toggle('open', open);
686
+ burger.setAttribute('aria-expanded', open ? 'true' : 'false');
687
+ nav.inert = mobileNavQuery.matches && !open;
688
+ placeActions(open);
689
+ if (open) {
690
+ previousOverflow = document.body.style.overflow;
691
+ document.body.style.overflow = 'hidden';
692
+ nav.setAttribute('role', 'dialog');
693
+ nav.setAttribute('aria-modal', 'true');
694
+ if (navClose) navClose.focus();
695
+ } else {
696
+ nav.removeAttribute('role');
697
+ nav.removeAttribute('aria-modal');
698
+ if (previousOverflow !== null) document.body.style.overflow = previousOverflow;
699
+ previousOverflow = null;
700
+ if (returnFocus) burger.focus();
701
+ }
702
+ };
703
+ var resetMobileNav = function () {
704
+ setNavOpen(false, false);
705
+ nav.querySelectorAll('.nav-submenu-toggle').forEach(function (toggle) {
706
+ toggle.setAttribute('aria-expanded', 'false');
707
+ var panel = document.getElementById(toggle.getAttribute('aria-controls'));
708
+ if (panel) panel.hidden = mobileNavQuery.matches;
709
+ });
710
+ };
711
+ nav.querySelectorAll('.nav-submenu-toggle').forEach(function (toggle) {
712
+ toggle.addEventListener('click', function () {
713
+ var panel = document.getElementById(toggle.getAttribute('aria-controls'));
714
+ var expanded = toggle.getAttribute('aria-expanded') !== 'true';
715
+ toggle.setAttribute('aria-expanded', expanded ? 'true' : 'false');
716
+ if (panel) panel.hidden = !expanded;
717
+ });
718
+ });
719
+ burger.addEventListener('click', function () { setNavOpen(!nav.classList.contains('open'), true); });
720
+ if (navClose) navClose.addEventListener('click', function () { setNavOpen(false, true); });
721
+ nav.addEventListener('keydown', function (event) {
722
+ if (!nav.classList.contains('open')) return;
723
+ if (event.key === 'Escape') {
724
+ event.preventDefault();
725
+ setNavOpen(false, true);
726
+ }
727
+ if (event.key !== 'Tab') return;
728
+ var links = Array.from(nav.querySelectorAll('a[href], button, input, [tabindex="0"]')).filter(function (el) {
729
+ return !el.disabled && !el.closest('[hidden]') && el.getClientRects().length;
730
+ });
731
+ var first = links[0], last = links[links.length - 1];
732
+ if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
733
+ else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
734
+ });
735
+ if (mobileNavQuery.addEventListener) mobileNavQuery.addEventListener('change', resetMobileNav);
736
+ resetMobileNav();
737
+ }
663
738
 
664
739
  // Announcement bar: dismiss (remembered) + rotate messages
665
740
  var bar = document.getElementById('announceBar');
@@ -0,0 +1,28 @@
1
+ import type { APIContext } from "astro";
2
+
3
+ const ROUTES = {
4
+ "pipeline-job": { path: /^\/api\/pipeline\/jobs\/([A-Za-z0-9._:-]{1,128})\/?$/, param: "jobId" },
5
+ "fulfillment-order": { path: /^\/api\/admin\/fulfillment\/orders\/([A-Za-z0-9._:-]{1,128})\/?$/, param: "id" },
6
+ "fulfillment-address": { path: /^\/api\/admin\/fulfillment\/orders\/([A-Za-z0-9._:-]{1,128})\/address\/?$/, param: "id" },
7
+ } as const;
8
+
9
+ export type RoutedApiKind = keyof typeof ROUTES;
10
+
11
+ /** Resolve an opaque resource inside the server-routed tenant, including rewritten endpoints. */
12
+ export function routedApiParam(context: APIContext, kind: RoutedApiKind): string | undefined {
13
+ if (!Object.hasOwn(ROUTES, kind)) return undefined;
14
+ const tenant = context.locals.tenant?.appDomain;
15
+ const prefix = context.locals.apiBasePath;
16
+ if (typeof tenant !== "string" || !/^[A-Za-z0-9][A-Za-z0-9.-]{0,252}$/.test(tenant)) return undefined;
17
+ if (typeof prefix !== "string" || (prefix !== "" && !/^\/[A-Za-z0-9][A-Za-z0-9.-]{0,252}$/.test(prefix))) return undefined;
18
+ let pathname: string;
19
+ try { pathname = new URL(context.request.url).pathname; } catch { return undefined; }
20
+ if (prefix && !pathname.startsWith(`${prefix}/`)) return undefined;
21
+ const relative = pathname.slice(prefix.length);
22
+ const route = ROUTES[kind];
23
+ const id = route.path.exec(relative)?.[1];
24
+ if (!id || id === "." || id === "..") return undefined;
25
+ const param = context.params[route.param];
26
+ if (param !== undefined && param !== "" && param !== id) return undefined;
27
+ return id;
28
+ }
@@ -1,3 +1,4 @@
1
+ import { routedApiParam } from "@/lib/publication/shared/routedApiParam";
1
2
  /**
2
3
  * Owner-only retrieval of a stored ship-to address.
3
4
  * The read runs only after the owner gate admits the caller.
@@ -24,8 +25,8 @@ export async function GET(context: APIContext): Promise<Response> {
24
25
  },
25
26
  });
26
27
  }
27
- const orderId = context.params.id ?? "";
28
- if (!isOpaqueId(orderId)) return fulfillmentResultResponse({ ok: false, code: "not-found" }, () => ({}));
28
+ const orderId = routedApiParam(context, "fulfillment-address");
29
+ if (!orderId || !isOpaqueId(orderId)) return fulfillmentResultResponse({ ok: false, code: "not-found" }, () => ({}));
29
30
  const opened = await openFulfillment(context);
30
31
  if (!opened.ok) return opened.response;
31
32
  return fulfillmentResultResponse(await opened.service.readAddress(opened.actor, orderId), (address) => address);
@@ -1,3 +1,4 @@
1
+ import { routedApiParam } from "@/lib/publication/shared/routedApiParam";
1
2
  /**
2
3
  * Authenticated owner API for one order's fulfillment commands.
3
4
  * The response is state codes and the fulfillment view. It never returns an address.
@@ -53,8 +54,8 @@ function fail(code: string): Response {
53
54
  export async function GET(context: APIContext): Promise<Response> {
54
55
  const refused = await requireOwner(context);
55
56
  if (refused) return refused;
56
- const orderId = context.params.id ?? "";
57
- if (!isOpaqueId(orderId)) return fail("not-found");
57
+ const orderId = routedApiParam(context, "fulfillment-order");
58
+ if (!orderId || !isOpaqueId(orderId)) return fail("not-found");
58
59
  const opened = await openFulfillment(context);
59
60
  if (!opened.ok) return opened.response;
60
61
  return fulfillmentResultResponse(await opened.service.get(opened.actor, orderId), (view) => view);
@@ -63,8 +64,8 @@ export async function GET(context: APIContext): Promise<Response> {
63
64
  export async function POST(context: APIContext): Promise<Response> {
64
65
  const refused = await requireOwner(context);
65
66
  if (refused) return refused;
66
- const orderId = context.params.id ?? "";
67
- if (!isOpaqueId(orderId)) return fail("not-found");
67
+ const orderId = routedApiParam(context, "fulfillment-order");
68
+ if (!orderId || !isOpaqueId(orderId)) return fail("not-found");
68
69
  const raw = await readJsonBody(context.request);
69
70
  if (!raw.ok) return fulfillmentResultResponse(raw, () => ({}));
70
71
  const parsed = parseFulfillmentBody(raw.value);
@@ -1,191 +1,2 @@
1
- /**
2
- * POST /api/commerce/checkout-handoff — the checkout-correlation MINT seam
3
- * (ext-commerce-10).
4
- *
5
- * The shopper's analytics consent is a CLIENT-side decision (CMP / native banner),
6
- * but the correlation token is minted under the SERVER-only signing secret. So at the
7
- * handoff the client posts its LIVE consent snapshot and the server mints the token,
8
- * returning ONLY the opaque passthrough cart field to attach to the managed-cart form.
9
- * The signing secret never leaves the server; the token carries no PII and no secret.
10
- *
11
- * Server-owned by construction:
12
- * - The tenant and the {@link IntegrationEnvironment} stage come from `context.locals`
13
- * (the renderer-owned `BrowserEnvironment`), NEVER from the request body — a client
14
- * cannot mint a cross-tenant or cross-stage token.
15
- * - The consent snapshot is parsed FAIL-CLOSED (`parseConsentSnapshot`): an absent,
16
- * forged, or tampered snapshot collapses to default-deny → unattributed.
17
- * - No store on this tenant, consent withheld, or no signing key → an explicit
18
- * unattributed response; the handoff proceeds, checkout is never blocked.
19
- */
20
- import type { APIContext } from "astro";
21
- import {
22
- enforceCartSubmission,
23
- foxyCartRedirectUrl,
24
- parseConsentSnapshot,
25
- defaultConsentSnapshot,
26
- buildVariantCartPayload,
27
- commerceTenantId,
28
- MAX_CART_QUANTITY,
29
- type CartDenialCode,
30
- } from "@tot/public-runtime";
31
- import { resolveCheckoutHandoffCorrelation } from "@/lib/checkout/commerce/checkoutHandoffCorrelation";
32
- import { resolveCartSigning, shopperCartDenialMessage } from "@/lib/checkout/shared/checkoutCommerce";
33
- import { readEnv } from "@/lib/publication/shared/runtimeEnv";
34
-
35
1
  export const prerender = false;
36
-
37
- function json(body: unknown, status = 200): Response {
38
- return new Response(JSON.stringify(body), {
39
- status,
40
- headers: {
41
- "content-type": "application/json; charset=utf-8",
42
- "cache-control": "no-store",
43
- },
44
- });
45
- }
46
-
47
- interface HandoffBody {
48
- /** The shopper's serialized consent snapshot (the `__TOT_INTEGRATION_CONSENT__` global). */
49
- consent?: unknown;
50
- }
51
-
52
- function escapeHtml(value: string): string {
53
- return value.replace(/[&<>"']/g, (ch) => {
54
- if (ch === "&") return "&amp;";
55
- if (ch === "<") return "&lt;";
56
- if (ch === ">") return "&gt;";
57
- if (ch === '"') return "&quot;";
58
- return "&#39;";
59
- });
60
- }
61
-
62
- function cartDenial(code: CartDenialCode, message: string, status: number): Response {
63
- const body =
64
- `<!doctype html><html><body><p data-cart-denial="${code}" data-payment-authorized="false">${escapeHtml(message)}</p></body></html>`;
65
- return new Response(body, {
66
- status,
67
- headers: {
68
- "content-type": "text/html; charset=utf-8",
69
- "cache-control": "no-store",
70
- "x-tot-payment-authorized": "false",
71
- },
72
- });
73
- }
74
-
75
- function isCartForm(request: Request): boolean {
76
- const type = request.headers.get("content-type") ?? "";
77
- return (
78
- type.includes("application/x-www-form-urlencoded") ||
79
- type.includes("multipart/form-data")
80
- );
81
- }
82
-
83
- /** Native cart POST. Browser preflight is not consulted. Payment is never authorized. */
84
- async function cartFormPost(context: APIContext): Promise<Response> {
85
- const { tenant, commerce } = context.locals;
86
- if (!commerce) {
87
- return cartDenial(
88
- "signing-unavailable",
89
- "Checkout is unavailable because this store has no cart configured.",
90
- 409,
91
- );
92
- }
93
- const signing = await resolveCartSigning(commerce);
94
- if (!signing.enabled) {
95
- return cartDenial(
96
- "signing-unavailable",
97
- shopperCartDenialMessage("signing-unavailable", signing.operatorError ?? ""),
98
- 503,
99
- );
100
- }
101
- const form = await context.request.formData();
102
- const entries: Array<[string, string]> = [];
103
- for (const [name, value] of form.entries()) {
104
- if (typeof value !== "string") return cartDenial("signature", "Invalid cart.", 422);
105
- entries.push([name, value]);
106
- }
107
- if (form.has("sample_product") || form.has("sample_variant")) {
108
- const sampleTenant = await readEnv("COMMERCE_SAMPLE_TENANT_ID");
109
- if (
110
- !sampleTenant || sampleTenant !== commerceTenantId(tenant) ||
111
- await readEnv("COMMERCE_DEMO_CHECKOUT_TIER") !== "test" || commerce.mode !== "signed"
112
- ) return cartDenial("tenant", "Invalid cart.", 422);
113
- const allowed = new Set(["sample_product", "sample_variant", "quantity"]);
114
- if (entries.length !== 3 || entries.some(([name]) => !allowed.has(name)) ||
115
- new Set(entries.map(([name]) => name)).size !== 3) {
116
- return cartDenial("signature", "Invalid cart.", 422);
117
- }
118
- const quantity = form.get("quantity") as string;
119
- if (!/^[1-9][0-9]*$/.test(quantity) || !Number.isSafeInteger(Number(quantity)) || Number(quantity) > MAX_CART_QUANTITY) {
120
- return cartDenial("quantity", "Invalid quantity.", 422);
121
- }
122
- const product = await context.locals.tot.getProductByHandle(form.get("sample_product") as string);
123
- const variant = product?.variants.find((v) => v.id === form.get("sample_variant"));
124
- if (!product || product.status !== "active" || product.visibility === "hidden" || product.visibility === "unlisted" ||
125
- !variant || !variant.inventory.available || product.tenant_id !== tenant.tot_data_scope ||
126
- variant.tenant_id !== tenant.tot_data_scope) {
127
- return cartDenial("signature", "Product is unavailable.", 422);
128
- }
129
- const payload = await buildVariantCartPayload(product, variant, {
130
- mode: "signed", secret: signing.secret, tenantId: commerceTenantId(tenant),
131
- });
132
- entries.splice(0, entries.length, ...payload.hidden.map((f): [string, string] => [f.name, f.value]), [payload.quantityName, quantity]);
133
- }
134
- const result = await enforceCartSubmission({
135
- entries,
136
- mode: commerce.mode,
137
- secret: signing.secret,
138
- trustedTenantId: commerceTenantId(tenant),
139
- });
140
- if (!result.ok) {
141
- const status = result.code === "signing-unavailable" ? 503 : 422;
142
- return cartDenial(result.code, shopperCartDenialMessage(result.code, result.message), status);
143
- }
144
- return new Response(null, {
145
- status: 303,
146
- headers: {
147
- location: foxyCartRedirectUrl(commerce.cartUrl, result.fields),
148
- "cache-control": "no-store",
149
- "x-tot-payment-authorized": "false",
150
- },
151
- });
152
- }
153
-
154
- export async function POST(context: APIContext): Promise<Response> {
155
- if (isCartForm(context.request)) return cartFormPost(context);
156
- const { tenant, browserEnvironment, commerce } = context.locals;
157
-
158
- // No managed checkout on this tenant → nothing to attribute.
159
- if (!commerce) {
160
- return json({ attributed: false, reason: "no-store" });
161
- }
162
-
163
- let body: HandoffBody;
164
- try {
165
- body = (await context.request.json()) as HandoffBody;
166
- } catch {
167
- body = {};
168
- }
169
-
170
- // Fail-closed: an absent/forged/tampered snapshot is default-deny (essential only).
171
- const consent = parseConsentSnapshot(body.consent) ?? defaultConsentSnapshot();
172
-
173
- const result = await resolveCheckoutHandoffCorrelation({
174
- tenant: tenant.tenant_id,
175
- // Env-consistency: the request's own resolved stage, never a client value.
176
- environment: browserEnvironment.environment,
177
- consent,
178
- // Opaque per-handoff logical id the order join threads back to.
179
- eventId: crypto.randomUUID(),
180
- });
181
-
182
- if (result.attributed) {
183
- // Return ONLY the opaque passthrough field; the signing secret never leaves here.
184
- return json({ attributed: true, cartField: result.cartField });
185
- }
186
- return json({ attributed: false, reason: result.reason });
187
- }
188
-
189
- export async function GET(): Promise<Response> {
190
- return json({ error: "method_not_allowed" }, 405);
191
- }
2
+ export { GET, POST } from "@/lib/checkout/commerce/checkoutHandoffRoute";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "2.5.3",
3
+ "version": "2.5.5",
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",