@tokenoftrust/storefront-runner 4.2.1 → 4.2.3

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.
@@ -122,7 +122,7 @@ const collapsible = variant === "light-collapsible";
122
122
  <div class="mt-4 flex items-center gap-3">
123
123
  {social.map((s) => (
124
124
  <a
125
- href={s.href}
125
+ href={withBase(basePath, s.href)}
126
126
  aria-label={s.label}
127
127
  rel="noopener noreferrer"
128
128
  class="text-muted transition-colors hover:text-primary"
@@ -90,6 +90,7 @@ const {
90
90
 
91
91
  const { basePath } = Astro.locals;
92
92
  const homeHref = withBase(basePath, brandHref ?? "/");
93
+ const searchFormAction = searchAction ? withBase(basePath, searchAction) : undefined;
93
94
  const showLogo = !!logo && /^(https?:|\/)/.test(logo.src);
94
95
  const dark = variant === "dark";
95
96
  const minimal = layout === "minimal";
@@ -205,11 +206,11 @@ const currentPath = Astro.url.pathname;
205
206
 
206
207
  <!-- Search (live typeahead island) -->
207
208
  {
208
- !minimal && !compact && searchAction && (
209
+ !minimal && !compact && searchFormAction && (
209
210
  <div class="hidden flex-1 md:flex">
210
211
  <SearchTypeahead
211
212
  client:idle
212
- action={searchAction}
213
+ action={searchFormAction}
213
214
  placeholder={searchPlaceholder}
214
215
  label={searchLabel}
215
216
  />
@@ -220,9 +221,9 @@ const currentPath = Astro.url.pathname;
220
221
  <!-- Actions: saved items + marketing CTAs + auth + account + cart (link only) + mobile menu -->
221
222
  <div class:list={["flex items-center gap-1", compact ? "col-start-3 justify-self-end" : "ml-auto"]}>
222
223
  {
223
- compact && searchAction && (
224
+ compact && searchFormAction && (
224
225
  <a
225
- href={withBase(basePath, searchAction)}
226
+ href={searchFormAction}
226
227
  aria-label={searchLabel ?? "Search"}
227
228
  class="hidden h-9 w-9 place-items-center rounded-full text-text transition-colors hover:text-accent-ink md:grid"
228
229
  >
@@ -281,7 +282,7 @@ const currentPath = Astro.url.pathname;
281
282
  {
282
283
  authLink && (
283
284
  <a data-mobile-menu-action
284
- href={authLink.href}
285
+ href={withBase(basePath, authLink.href)}
285
286
  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
287
  >
287
288
  {authLink.label}
@@ -338,11 +339,11 @@ const currentPath = Astro.url.pathname;
338
339
  </div>
339
340
 
340
341
  {
341
- compact && searchAction && (
342
+ compact && searchFormAction && (
342
343
  <div class="border-b border-border bg-surface px-4 py-2 md:hidden" data-header-phone-search>
343
344
  <SearchTypeahead
344
345
  client:idle
345
- action={searchAction}
346
+ action={searchFormAction}
346
347
  placeholder={searchPlaceholder}
347
348
  label={searchLabel}
348
349
  />
@@ -387,8 +388,8 @@ const currentPath = Astro.url.pathname;
387
388
  compact && (
388
389
  <nav class:list={["fixed inset-x-0 bottom-0 z-50 grid h-[62px] border-t border-border bg-surface shadow-[0_-3px_14px_color-mix(in_srgb,var(--color-text)_10%,transparent)] md:hidden", cartHref ? "grid-cols-4" : "grid-cols-3"]} aria-label="Quick links">
389
390
  <a href={homeHref} class="flex flex-col items-center justify-center gap-0.5 text-[0.65rem] text-text">Home</a>
390
- <a href={withBase(basePath, searchAction ?? "/search")} class="flex flex-col items-center justify-center gap-0.5 text-[0.65rem] text-text">Search</a>
391
- <a href={accountHref ? withBase(basePath, accountHref) : (authLink?.href ?? "/auth/login")} class="flex flex-col items-center justify-center gap-0.5 text-[0.65rem] text-text">Account</a>
391
+ <a href={searchFormAction ?? withBase(basePath, "/search")} class="flex flex-col items-center justify-center gap-0.5 text-[0.65rem] text-text">Search</a>
392
+ <a href={withBase(basePath, accountHref ?? authLink?.href ?? "/auth/login")} class="flex flex-col items-center justify-center gap-0.5 text-[0.65rem] text-text">Account</a>
392
393
  {cartHref && <a href={withBase(basePath, cartHref)} class="flex flex-col items-center justify-center gap-0.5 text-[0.65rem] text-text">Cart</a>}
393
394
  </nav>
394
395
  )
@@ -64,7 +64,7 @@ const linkStamp = (l: ChromeLink, index: number) =>
64
64
  <span class="flex items-center gap-3">
65
65
  {social.map((s) => (
66
66
  <a
67
- href={s.href}
67
+ href={withBase(basePath, s.href)}
68
68
  aria-label={s.label}
69
69
  rel="noopener noreferrer"
70
70
  class="transition-colors hover:text-primary"
@@ -29,7 +29,8 @@ import { organizationLd, websiteLd } from "@/lib/content/shared/jsonld";
29
29
  import { composeStructuredData, structuredDataProfile, type StructuredDataPage } from "@/lib/content/seo/structuredData";
30
30
  import { canonicalUrl } from "@/lib/content/seo/urlForm";
31
31
  import { documentTitle } from "@/lib/content/seo/documentTitle";
32
- import { nonceInlineTags, prefixApiFormActions, CHROME_BODY_MARKER } from "@/lib/content/shared/rawMarketingHtml";
32
+ import { nonceInlineTags, prefixSharedChrome, CHROME_BODY_MARKER } from "@/lib/content/shared/rawMarketingHtml";
33
+ import { withBase } from "@/lib/publication/shared/basePath";
33
34
  import { CHROME_BEHAVIOR_JS, CHROME_HEAD_JS, sanitizeSharedChromeFragment } from "@/lib/content/shared/rawChrome";
34
35
  import { ASYNC_CSS_SWAP_JS } from "@/lib/content/shared/asyncCss";
35
36
  import { reportTrackingChainHealth } from "@/lib/measurement/tracking/interactionHealth";
@@ -196,17 +197,19 @@ if (chromeHtml && chromeHtml.includes(CHROME_BODY_MARKER)) {
196
197
  const bodyEnd = chromeHtml.lastIndexOf("</body>");
197
198
  sharedChrome = {
198
199
  header: nonceInlineTags(
199
- prefixApiFormActions(
200
+ prefixSharedChrome(
200
201
  sanitizeSharedChromeFragment(chromeHtml.slice(bodyOpen, marker)),
202
+ basePath,
201
203
  apiBasePath,
202
204
  ),
203
205
  cspNonce,
204
206
  ),
205
207
  footer: nonceInlineTags(
206
- prefixApiFormActions(
208
+ prefixSharedChrome(
207
209
  sanitizeSharedChromeFragment(
208
210
  chromeHtml.slice(marker + CHROME_BODY_MARKER.length, bodyEnd >= 0 ? bodyEnd : undefined),
209
211
  ),
212
+ basePath,
210
213
  apiBasePath,
211
214
  ),
212
215
  cspNonce,
@@ -394,7 +397,9 @@ const footerWidgets = await renderWidgetSlot(
394
397
  layout={resolvedHeaderVariant}
395
398
  announcement={chrome.header.announcement?.messages[0]}
396
399
  searchAction={
397
- features.productSearch ? (chrome.header.search?.action ?? "/search") : undefined
400
+ features.productSearch
401
+ ? withBase(basePath, chrome.header.search?.action ?? "/search")
402
+ : undefined
398
403
  }
399
404
  searchPlaceholder={chrome.header.search?.placeholder}
400
405
  searchLabel={chrome.header.search?.label}
@@ -29,6 +29,7 @@ import { fromD1, type D1Like } from "../d1/catalog.js";
29
29
  import { buildSearchIndex, type SearchIndex } from "../search/index.js";
30
30
  import type { ToTClient } from "./totClientInterface.js";
31
31
  import { readTenantCatalogFiles, type CatalogArtifactSource } from "./tenantCatalog.js";
32
+ import { overlayProductInventory, type MirrorInventoryOverlay } from "../../inventory/mirrorStock.js";
32
33
 
33
34
  // The contract lives in its own module so d1Client can implement it without a
34
35
  // circular edge back to this file (which imports D1ToTClient for the factory).
@@ -192,6 +193,42 @@ class DeferredToTClient implements ToTClient {
192
193
  async getReviews(handle: string) { return (await this.resolved()).getReviews(handle); }
193
194
  }
194
195
 
196
+ /** Applies an already-resolved ledger snapshot to every product the catalog client returns. */
197
+ export function overlayCatalogClient(client: ToTClient, overlay: MirrorInventoryOverlay): ToTClient {
198
+ return new MirrorStockClient(client, overlay);
199
+ }
200
+
201
+ class MirrorStockClient implements ToTClient {
202
+ constructor(
203
+ private readonly inner: ToTClient,
204
+ private readonly overlay: MirrorInventoryOverlay,
205
+ ) {}
206
+
207
+ private product(product: CatalogProduct | null): CatalogProduct | null {
208
+ return product ? overlayProductInventory(product, this.overlay) : null;
209
+ }
210
+
211
+ private products(products: CatalogProduct[]): CatalogProduct[] {
212
+ return products.map((product) => overlayProductInventory(product, this.overlay));
213
+ }
214
+
215
+ async listProducts(query?: ListProductsQuery): Promise<ListProductsResponse> {
216
+ const page = await this.inner.listProducts(query);
217
+ return { ...page, products: this.products(page.products) };
218
+ }
219
+
220
+ async getProductByHandle(handle: string): Promise<CatalogProduct | null> {
221
+ return this.product(await this.inner.getProductByHandle(handle));
222
+ }
223
+
224
+ async listCollections() { return this.inner.listCollections(); }
225
+ async getCollectionByHandle(handle: string) { return this.inner.getCollectionByHandle(handle); }
226
+ async search(q: string, limit?: number) { return this.products(await this.inner.search(q, limit)); }
227
+ async getFeatured(limit?: number) { return this.products(await this.inner.getFeatured(limit)); }
228
+ async getAllActiveProducts() { return this.products(await this.inner.getAllActiveProducts()); }
229
+ async getReviews(handle: string) { return this.inner.getReviews(handle); }
230
+ }
231
+
195
232
  /**
196
233
  * The tenant's catalog, selected by its declared `catalog.serving` (ADR 0033):
197
234
  *
@@ -209,24 +246,29 @@ export async function createToTClient(
209
246
  tenant: TenantConfig,
210
247
  d1?: D1Like,
211
248
  content?: CatalogArtifactSource,
249
+ overlay?: MirrorInventoryOverlay | null,
212
250
  ): Promise<ToTClient> {
251
+ let client: ToTClient;
213
252
  if (tenant.catalog?.serving === "d1") {
214
- if (d1) return new D1ToTClient(fromD1(d1));
215
- return new DeferredToTClient(async () => {
216
- throw new Error(
217
- `catalog: ${tenant.tenant_id} declares catalog.serving: "d1", and this render has no ` +
218
- `D1 serving projection (public and static renders never read D1 per request, ADR 0013).`,
219
- );
253
+ client = d1
254
+ ? new D1ToTClient(fromD1(d1))
255
+ : new DeferredToTClient(async () => {
256
+ throw new Error(
257
+ `catalog: ${tenant.tenant_id} declares catalog.serving: "d1", and this render has no ` +
258
+ `D1 serving projection (public and static renders never read D1 per request, ADR 0013).`,
259
+ );
260
+ });
261
+ } else {
262
+ client = new DeferredToTClient(async () => {
263
+ const files = await readTenantCatalogFiles(tenant, content);
264
+ if (files) {
265
+ return new FixtureToTClient(
266
+ files.products, files.collections, tenant.tot_data_scope, {}, files.productDetail,
267
+ );
268
+ }
269
+ const { products, collections, reviews } = await loadFixtures(tenant.tot_data_scope);
270
+ return new FixtureToTClient(products, collections, tenant.tot_data_scope, reviews);
220
271
  });
221
272
  }
222
- return new DeferredToTClient(async () => {
223
- const files = await readTenantCatalogFiles(tenant, content);
224
- if (files) {
225
- return new FixtureToTClient(
226
- files.products, files.collections, tenant.tot_data_scope, {}, files.productDetail,
227
- );
228
- }
229
- const { products, collections, reviews } = await loadFixtures(tenant.tot_data_scope);
230
- return new FixtureToTClient(products, collections, tenant.tot_data_scope, reviews);
231
- });
273
+ return overlay ? overlayCatalogClient(client, overlay) : client;
232
274
  }
@@ -44,6 +44,9 @@ import {
44
44
  import { resolveCheckoutHandoffCorrelation } from "@/lib/checkout/commerce/checkoutHandoffCorrelation";
45
45
  import { bindCartSession, CART_UNAVAILABLE_MESSAGE, withCartSession } from "@/lib/checkout/commerce/cartSession";
46
46
  import { resolveCartSigning, shopperCartDenialMessage } from "@/lib/checkout/shared/checkoutCommerce";
47
+ import { fromD1 } from "@/lib/catalog/d1/catalog";
48
+ import { readD1 } from "@/lib/publication/shared/env";
49
+ import { handoffStockFinding, MIRROR_LOCATION_UNSPECIFIED, readMirrorBalance } from "@/lib/inventory/mirrorStock";
47
50
 
48
51
  export type SampleProductReader = (tenant: TenantConfig, handle: string, commerce: ResolvedManagedCheckout) => Promise<CatalogProduct | null>;
49
52
 
@@ -125,6 +128,42 @@ async function sessionedCartLocation(
125
128
  return foxyCartRedirectUrl(withCartSession(commerce.cartUrl, fcsid), kept);
126
129
  }
127
130
 
131
+ function ledgerStage(env: string | undefined): "preview" | "publication" | null {
132
+ if (env === "live") return "publication";
133
+ if (env === "test") return "preview";
134
+ return null;
135
+ }
136
+
137
+ function submittedCode(fields: readonly { name: string; value: string }[]): string | undefined {
138
+ return fields.find((field) => field.name.split("||")[0] === "code")?.value;
139
+ }
140
+
141
+ function stockUnavailable(): Response {
142
+ return new Response(JSON.stringify({ finding: "stock-unavailable" }), {
143
+ status: 409,
144
+ headers: {
145
+ "content-type": "application/json; charset=utf-8",
146
+ "cache-control": "no-store",
147
+ "x-tot-payment-authorized": "false",
148
+ },
149
+ });
150
+ }
151
+
152
+ /** A non-positive ledger balance refuses the handoff. No stage, no sku, or no rows does not. */
153
+ async function refuseSoldOut(context: APIContext, tenant: TenantConfig, sku: string | undefined): Promise<Response | null> {
154
+ const stage = ledgerStage(context.locals.customizationEnv);
155
+ if (!stage || !sku) return null;
156
+ const database = await readD1("STOREFRONT_APPS_DB");
157
+ if (!database) return null;
158
+ const reading = await readMirrorBalance(fromD1(database), {
159
+ tenantId: commerceTenantId(tenant),
160
+ stage,
161
+ sku,
162
+ locationId: MIRROR_LOCATION_UNSPECIFIED,
163
+ });
164
+ return handoffStockFinding(reading ? reading.balance : null) ? stockUnavailable() : null;
165
+ }
166
+
128
167
  /**
129
168
  * The page the shopper submitted from, when it is served by this request's host or the tenant's
130
169
  * declared domain. The browser's Referer is the only source; a foreign or absent one yields none.
@@ -171,6 +210,8 @@ async function cartFormPost(context: APIContext): Promise<Response> {
171
210
  const status = result.code === "signing-unavailable" ? 503 : 422;
172
211
  return cartDenial(result.code, shopperCartDenialMessage(result.code, result.message), status);
173
212
  }
213
+ const soldOut = await refuseSoldOut(context, tenant, submittedCode(result.fields));
214
+ if (soldOut) return soldOut;
174
215
  const verified = await withVerificationRef(result.fields, { mode: commerce.mode, secret: signing.secret, ref });
175
216
  const shippable = await boundShippingPolicy(verified, tenant, commerce.mode, signing.secret);
176
217
  if (!shippable) return cartDenial("shipping", SHIPPING_UNAVAILABLE_CART_MESSAGE, 409);
@@ -271,8 +312,11 @@ export async function sampleCartGet(
271
312
  const product = await readProduct(tenant, productId, commerce);
272
313
  const variant = product?.variants.find(v => v.id === variantId);
273
314
  if (!product || product.status !== "active" || product.visibility === "hidden" || product.visibility === "unlisted" ||
274
- !variant || !variant.inventory.available || product.tenant_id !== tenant.tot_data_scope ||
315
+ !variant || product.tenant_id !== tenant.tot_data_scope ||
275
316
  variant.tenant_id !== tenant.tot_data_scope) return invalid();
317
+ const soldOut = await refuseSoldOut(context, tenant, variant.sku);
318
+ if (soldOut) return soldOut;
319
+ if (!variant.inventory.available) return invalid();
276
320
  const signing = await resolveCartSigning(commerce);
277
321
  if (!signing.enabled) return sampleResponse('{"error":"sample_unavailable"}', 503);
278
322
  const payload = await buildVariantCartPayload(product, variant, {
@@ -3,11 +3,13 @@
3
3
  *
4
4
  * A marketing tenant can ship a self-contained document (`content/<tenant>/home.html`
5
5
  * or `content/<tenant>/pages-html/<slug>.html`) that we serve VERBATIM, bypassing the
6
- * block Layout. Two transforms are applied at serve time:
6
+ * block Layout. Three transforms are applied at serve time:
7
7
  * 1. nonce-stamp the document's inline <style>/<script> so a strict CSP admits them;
8
8
  * 2. under path-prefix tenant routing (locals.basePath set), prefix same-origin
9
- * root-absolute anchor hrefs with the base path so navigation stays in-tenant —
10
- * the raw doc bypasses the Layout's client base-path fixer, so we do it here.
9
+ * root-absolute anchor hrefs and page form actions with the base path so
10
+ * navigation stays in-tenant — the raw doc bypasses the Layout's client
11
+ * base-path fixer, so we do it here;
12
+ * 3. prefix `/api` form actions with apiBasePath, never a preview pin.
11
13
  *
12
14
  * Single home for both routes (`index.astro`, `[...slug].astro`).
13
15
  *
@@ -24,7 +26,7 @@ import {
24
26
  } from "@tot/public-runtime";
25
27
  import { reportTrackingChainHealth } from "../../measurement/tracking/interactionHealth.js";
26
28
  import { ASYNC_CSS_SWAP_JS } from "./asyncCss.js";
27
- import { ROOT_SERVED_HREF_NAMESPACES } from "../../publication/shared/basePath.js";
29
+ import { ROOT_SERVED_HREF_NAMESPACES, withBase } from "../../publication/shared/basePath.js";
28
30
 
29
31
  export { rawHtmlPolicy } from "@tot/public-runtime";
30
32
 
@@ -205,6 +207,44 @@ export function prefixApiFormActions(html: string, apiBasePath: string): string
205
207
  return html.replace(API_FORM_ACTION_RE, (_match, attr: string) => `${attr}="${apiBasePath}/api/`);
206
208
  }
207
209
 
210
+ /** True when a form target is an `/api` route. Those use {@link prefixApiFormActions}. */
211
+ function isApiFormTarget(href: string): boolean {
212
+ if (!href.startsWith("/")) return false;
213
+ const path = href.slice(1).split(/[?#]/, 1)[0] ?? "";
214
+ return path === "api" || path.startsWith("api/");
215
+ }
216
+
217
+ const PAGE_FORM_ACTION_RE = /\b(action|formaction)=("([^"]*)"|'([^']*)')/g;
218
+
219
+ /**
220
+ * Prefix same-origin page form targets (`action`/`formaction`) with `basePath`
221
+ * via {@link withBase}. A preview pin's header search (`/search`) stays inside
222
+ * that pin. `/api` targets are left for {@link prefixApiFormActions}. No-op
223
+ * when `basePath` is empty.
224
+ */
225
+ export function prefixPageFormActions(html: string, basePath: string): string {
226
+ if (!basePath) return html;
227
+ return html.replace(PAGE_FORM_ACTION_RE, (full, attr: string, _quoted: string, dq: string | undefined, sq: string | undefined) => {
228
+ const href = dq ?? sq ?? "";
229
+ if (isApiFormTarget(href)) return full;
230
+ const next = withBase(basePath, href);
231
+ if (next === href) return full;
232
+ const quote = dq !== undefined ? '"' : "'";
233
+ return `${attr}=${quote}${next}${quote}`;
234
+ });
235
+ }
236
+
237
+ /**
238
+ * Serve-time prefix for chrome HTML on both mounts (raw documents and Layout's
239
+ * shared-chrome splice). Anchors go through {@link prefixTenantLinks}. Page
240
+ * form actions go through {@link prefixPageFormActions} (`withBase`). `/api`
241
+ * form actions go through {@link prefixApiFormActions} and `apiBasePath`, never
242
+ * a preview pin.
243
+ */
244
+ export function prefixSharedChrome(html: string, basePath: string, apiBasePath: string): string {
245
+ return prefixApiFormActions(prefixPageFormActions(prefixTenantLinks(html, basePath), basePath), apiBasePath);
246
+ }
247
+
208
248
  /** Raw pages with a robots `noindex` directive remain routable but stay out of discovery. */
209
249
  export function isIndexableHtml(html: string): boolean {
210
250
  for (const match of html.matchAll(/<meta\b([^>]*)>/gi)) {
@@ -397,10 +437,7 @@ export function rawHtmlResponse(
397
437
  composed = injectBodyEnd(composed, interactionTrackingScript(opts.nonce));
398
438
  composed = injectBodyEnd(composed, asyncCssSwapScript(opts.nonce));
399
439
  composed = resolveCanonicalHref(composed, opts.canonicalUrl);
400
- const doc = prefixApiFormActions(
401
- prefixTenantLinks(composed, opts.basePath),
402
- opts.apiBasePath ?? "",
403
- );
440
+ const doc = prefixSharedChrome(composed, opts.basePath, opts.apiBasePath ?? "");
404
441
  return new Response(doc, {
405
442
  headers: { "content-type": "text/html; charset=utf-8" },
406
443
  });
@@ -0,0 +1,45 @@
1
+ /** KV projection of mirror balances. This module opens neither D1 nor env. */
2
+
3
+ export interface MirrorBalanceProjection {
4
+ balance: number;
5
+ asOf: string;
6
+ }
7
+
8
+ export function mirrorBalanceKey(tenantId: string, stage: "preview" | "publication"): string {
9
+ return `mirror-balance:${stage}:${tenantId}`;
10
+ }
11
+
12
+ export function publicMirrorStage(input: { previewHost: boolean; previewToken: boolean }): "preview" | "publication" {
13
+ return input.previewHost || input.previewToken ? "preview" : "publication";
14
+ }
15
+
16
+ export function parseMirrorBalances(raw: string | null | undefined): Map<string, MirrorBalanceProjection> | null {
17
+ if (raw == null || raw === "") return null;
18
+ let parsed: unknown = null;
19
+ try {
20
+ parsed = JSON.parse(raw);
21
+ } catch {
22
+ parsed = null;
23
+ }
24
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
25
+ const body = parsed as Record<string, unknown>;
26
+ if (body.v !== 1 || !Array.isArray(body.lines) || Object.keys(body).some((key) => key !== "v" && key !== "lines")) return null;
27
+ const map = new Map<string, MirrorBalanceProjection>();
28
+ for (const line of body.lines) {
29
+ if (!line || typeof line !== "object" || Array.isArray(line)) return null;
30
+ const row = line as Record<string, unknown>;
31
+ if (Object.keys(row).some((key) => key !== "sku" && key !== "balance" && key !== "asOf")) return null;
32
+ const balance = row.balance;
33
+ if (typeof row.sku !== "string" || !row.sku || typeof row.asOf !== "string" || typeof balance !== "number" || !Number.isSafeInteger(balance)) return null;
34
+ if (!Number.isFinite(Date.parse(row.asOf)) || map.has(row.sku)) return null;
35
+ map.set(row.sku, { balance, asOf: row.asOf });
36
+ }
37
+ return map;
38
+ }
39
+
40
+ export function serializeMirrorBalances(map: ReadonlyMap<string, MirrorBalanceProjection>): string {
41
+ const lines = [...map.entries()]
42
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
43
+ .map(([sku, row]) => ({ sku, balance: row.balance, asOf: row.asOf }));
44
+ return JSON.stringify({ v: 1, lines });
45
+ }
@@ -119,3 +119,155 @@ export async function readMirrorStock(
119
119
  asOf: row.as_of,
120
120
  };
121
121
  }
122
+
123
+ export const MIRROR_FRESH_MS = 15 * 60_000;
124
+ export const MIRROR_LOCATION_UNSPECIFIED = "unspecified";
125
+ export const CATALOG_IMPORT_ACTOR_ID = "catalog-import";
126
+ export const CATALOG_IMPORT_SOURCE = "catalog-import";
127
+
128
+ export interface MirrorBalance {
129
+ balance: number;
130
+ asOf: string;
131
+ }
132
+
133
+ export interface MirrorInventoryOverlay {
134
+ balances: ReadonlyMap<string, MirrorBalance>;
135
+ now: string;
136
+ maxAgeMs?: number;
137
+ }
138
+
139
+ interface BalanceKey {
140
+ tenantId: string;
141
+ stage: CommerceStage;
142
+ sku: string;
143
+ locationId: string;
144
+ }
145
+
146
+ /** Sum of movements, including a non-positive or stale balance. No rows is null, not zero. */
147
+ export async function readMirrorBalance(db: Queryable, input: BalanceKey): Promise<MirrorBalance | null> {
148
+ const row = await db.first<{ balance: number | null; as_of: string | null }>(
149
+ `SELECT SUM(quantity_delta) balance, MAX(as_of) as_of FROM commerce_inventory_movements
150
+ WHERE tenant_id=? AND stage=? AND sku=? AND location_id=?`,
151
+ input.tenantId,
152
+ input.stage,
153
+ input.sku,
154
+ input.locationId,
155
+ );
156
+ if (!row || row.as_of == null || row.balance == null || !Number.isSafeInteger(Number(row.balance))) return null;
157
+ return { balance: Number(row.balance), asOf: row.as_of };
158
+ }
159
+
160
+ export async function readMirrorBalances(
161
+ db: Queryable,
162
+ input: { tenantId: string; stage: CommerceStage; locationId: string },
163
+ ): Promise<Map<string, MirrorBalance>> {
164
+ const rows = await db.all<{ sku: string; balance: number | null; as_of: string | null }>(
165
+ `SELECT sku, SUM(quantity_delta) balance, MAX(as_of) as_of FROM commerce_inventory_movements
166
+ WHERE tenant_id=? AND stage=? AND location_id=? GROUP BY sku`,
167
+ input.tenantId,
168
+ input.stage,
169
+ input.locationId,
170
+ );
171
+ const balances = new Map<string, MirrorBalance>();
172
+ for (const row of rows) {
173
+ if (row.as_of == null || row.balance == null || !Number.isSafeInteger(Number(row.balance))) continue;
174
+ balances.set(row.sku, { balance: Number(row.balance), asOf: row.as_of });
175
+ }
176
+ return balances;
177
+ }
178
+
179
+ /**
180
+ * One source on-hand observation. The stored delta is on-hand minus the sum of every other
181
+ * movement, so a replay of this event does not add the snapshot twice.
182
+ */
183
+ export async function appendSourceOnHand(
184
+ db: Queryable,
185
+ input: Omit<MirrorMovement, "quantityDelta"> & { onHand: number },
186
+ ): Promise<void> {
187
+ if (!Number.isSafeInteger(input.onHand)) throw new InventoryMovementError("invalid-movement");
188
+ const prior = await db.first<{ quantity_delta: number }>(
189
+ `SELECT quantity_delta FROM commerce_inventory_movements
190
+ WHERE tenant_id=? AND stage=? AND source=? AND source_event_id=? AND sku=? AND location_id=?`,
191
+ input.tenantId,
192
+ input.stage,
193
+ input.source,
194
+ input.sourceEventId,
195
+ input.sku,
196
+ input.locationId,
197
+ );
198
+ const reading = await readMirrorBalance(db, input);
199
+ const currentSum = reading?.balance ?? 0;
200
+ const existingDelta = prior ? Number(prior.quantity_delta) : 0;
201
+ const { onHand, ...movement } = input;
202
+ await appendMirrorMovement(db, { ...movement, quantityDelta: onHand - (currentSum - existingDelta) });
203
+ }
204
+
205
+ export async function catalogImportEventId(runId: string, sku: string, onHand: number): Promise<string> {
206
+ const quantity = onHand < 0 ? `n${-onHand}` : String(onHand);
207
+ const raw = `import.${runId}.${sku}.${quantity}`;
208
+ if (ID.test(raw)) return raw;
209
+ const bytes = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(raw)));
210
+ const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
211
+ return `import.${hex.slice(0, 40)}`;
212
+ }
213
+
214
+ export async function importSourceOnHand(
215
+ db: Queryable,
216
+ input: {
217
+ tenantId: string;
218
+ stage: CommerceStage;
219
+ lines: readonly { sku: string; onHand: number }[];
220
+ runId: string;
221
+ asOf: string;
222
+ },
223
+ ): Promise<void> {
224
+ for (const line of input.lines) {
225
+ await appendSourceOnHand(db, {
226
+ tenantId: input.tenantId,
227
+ stage: input.stage,
228
+ sku: line.sku,
229
+ locationId: MIRROR_LOCATION_UNSPECIFIED,
230
+ source: CATALOG_IMPORT_SOURCE,
231
+ sourceEventId: await catalogImportEventId(input.runId, line.sku, line.onHand),
232
+ onHand: line.onHand,
233
+ asOf: input.asOf,
234
+ actorId: CATALOG_IMPORT_ACTOR_ID,
235
+ });
236
+ }
237
+ }
238
+
239
+ export function handoffStockFinding(balance: number | null): "stock-unavailable" | null {
240
+ return balance !== null && balance <= 0 ? "stock-unavailable" : null;
241
+ }
242
+
243
+ /** Ledger wins over the catalog stamp. A positive stale balance leaves the stamp alone. */
244
+ export function overlayMirrorInventory<T extends { available: boolean; quantity?: number }>(
245
+ stamp: T,
246
+ reading: MirrorBalance | null | undefined,
247
+ now: string,
248
+ maxAgeMs = MIRROR_FRESH_MS,
249
+ ): T {
250
+ if (!reading) return stamp;
251
+ if (reading.balance <= 0) return { ...stamp, available: false, quantity: reading.balance };
252
+ const age = Date.parse(now) - Date.parse(reading.asOf);
253
+ const fresh = Number.isFinite(age) && age >= 0 && Number.isFinite(maxAgeMs) && maxAgeMs >= 0 && age <= maxAgeMs;
254
+ if (fresh) return { ...stamp, available: true, quantity: reading.balance };
255
+ return stamp;
256
+ }
257
+
258
+ export function overlayProductInventory<T extends { variants: readonly { sku?: string | null; inventory: { available: boolean; quantity?: number } }[] }>(
259
+ product: T,
260
+ overlay: MirrorInventoryOverlay,
261
+ ): T {
262
+ return {
263
+ ...product,
264
+ variants: product.variants.map((variant) => {
265
+ const reading = variant.sku ? overlay.balances.get(variant.sku) : undefined;
266
+ if (!reading) return variant;
267
+ return {
268
+ ...variant,
269
+ inventory: overlayMirrorInventory(variant.inventory, reading, overlay.now, overlay.maxAgeMs),
270
+ };
271
+ }),
272
+ };
273
+ }
@@ -2,16 +2,18 @@
2
2
  * The control-plane branch: everything a route reaches only with authorization,
3
3
  * plus every route on a login-gated host.
4
4
  *
5
- * It owns the three request-time D1 reads (managed installs, consent, the catalog
6
- * edge replica), the sign-in gate and its admin landing, and the `/dev` alias —
7
- * then delegates to the shared render chain with those values supplied.
5
+ * It owns the request-time D1 reads (managed installs, consent, the catalog
6
+ * edge replica, and mirror-stock balances for this branch's catalog overlay),
7
+ * the sign-in gate and its admin landing, and the `/dev` alias — then delegates
8
+ * to the shared render chain with those values supplied.
8
9
  */
9
10
  import type { APIContext, MiddlewareNext } from "astro";
10
11
  import { readEnv, readD1, readKv } from "@/lib/publication/shared/env";
11
12
  import { fromD1, type D1Like } from "@/lib/catalog/d1/catalog";
12
13
  import { readCurrentTenantConsentConfig } from "@/lib/measurement/consent/consentConfigRead";
13
14
  import { loadActiveManagedInstallDeclarations } from "@/lib/integrations/integration/managedInstallDeclarations";
14
- import { buildTenantConsentBroker, verifyPreviewToken, storefrontModeRequiresSignIn } from "@tot/public-runtime";
15
+ import { buildTenantConsentBroker, commerceTenantId, verifyPreviewToken, storefrontModeRequiresSignIn } from "@tot/public-runtime";
16
+ import { MIRROR_LOCATION_UNSPECIFIED, readMirrorBalances as readLedgerBalances } from "@/lib/inventory/mirrorStock";
15
17
  import { tenantIsDeclaredPublic } from "@/config/tenants";
16
18
  import type {
17
19
  ConsentSnapshot,
@@ -288,6 +290,14 @@ export async function serveControlPlane(input: ControlPlaneInput): Promise<Respo
288
290
  // The public branch never resolves it at all — see `publicRender`.
289
291
  readCatalogD1: async (): Promise<D1Like | undefined> =>
290
292
  tenant.catalog?.serving === "d1" ? await readD1("CATALOG_DB") : undefined,
293
+ readMirrorBalances: async (stage) => {
294
+ if (!appsD1) return null;
295
+ return readLedgerBalances(fromD1(appsD1), {
296
+ tenantId: commerceTenantId(tenant),
297
+ stage,
298
+ locationId: MIRROR_LOCATION_UNSPECIFIED,
299
+ });
300
+ },
291
301
  internalContentGrant: input.internalContentGrant,
292
302
  });
293
303
  }
@@ -25,7 +25,7 @@ import { getTenantResolver } from "@/config/resolver";
25
25
  import { readEnv, readKv, readR2 } from "@/lib/publication/shared/runtimeEnv";
26
26
  import { resolveCdnForwardRouting, resolveTenantRouting } from "@/lib/access/shared/tenantRouting";
27
27
  import { crossSiteFormRefusal, verifyCdnForward } from "@/lib/publication/shared/cdnForward";
28
- import { SAMPLE_CART_HANDOFF_URL, compileRouteTierIndexV1, resolvePublicationRoutePlanV1, type RouteTierIndexV1 , storefrontModeRequiresSignIn } from "@tot/public-runtime";
28
+ import { SAMPLE_CART_HANDOFF_URL, commerceTenantId, compileRouteTierIndexV1, resolvePublicationRoutePlanV1, type RouteTierIndexV1 , storefrontModeRequiresSignIn } from "@tot/public-runtime";
29
29
  import type { PublicationArtifactBucketV1, TenantConfig } from "@tot/public-runtime";
30
30
  import { HTML_CACHE_POLICY_CAPTURE_PROBE } from "@tot/public-runtime";
31
31
  import type { RenditionRegistryBucket } from "@/lib/assets/registeredRenditions";
@@ -48,6 +48,7 @@ import {
48
48
  resolveMiddlewareBranchV1,
49
49
  } from "./routeBranch.js";
50
50
  import { servePublicRender } from "./publicRender.js";
51
+ import { mirrorBalanceKey, parseMirrorBalances, publicMirrorStage } from "@/lib/inventory/mirrorBalanceProjection";
51
52
  import { serveControlPlane } from "./controlPlane.js";
52
53
  import { KvSessionStore, SESSION_COOKIE } from "@/lib/access/auth/session";
53
54
  import { mintOpaqueBrokerAssertion } from "@/lib/access/auth/brokerAssertion";
@@ -368,6 +369,18 @@ async function serveRequest(
368
369
  const publicationSourcePath = discoveryRoot?.action === "render" ? "/" : renderPath;
369
370
 
370
371
  if (branch === "public-render") {
372
+ const mirrorStage = publicMirrorStage({
373
+ previewHost: host.toLowerCase().startsWith("preview."),
374
+ previewToken: url.searchParams.has("__preview") || !!context.cookies.get("tot_preview")?.value,
375
+ });
376
+ let mirrorBalances: ReturnType<typeof parseMirrorBalances> = null;
377
+ if (kv) {
378
+ try {
379
+ mirrorBalances = parseMirrorBalances(await kv.get(mirrorBalanceKey(commerceTenantId(tenant), mirrorStage)));
380
+ } catch {
381
+ mirrorBalances = null;
382
+ }
383
+ }
371
384
  return await servePublicRender({
372
385
  context,
373
386
  next,
@@ -384,6 +397,7 @@ async function serveRequest(
384
397
  publicationBucket: discoveryRoot?.action === "render"
385
398
  ? await readR2("TENANT_ASSETS") as unknown as PublicationArtifactBucketV1 | undefined
386
399
  : publicationBucket,
400
+ mirrorBalances,
387
401
  });
388
402
  }
389
403
 
@@ -4,7 +4,8 @@
4
4
  *
5
5
  * This module's whole job is to hand the shared chain the empty forms of every
6
6
  * D1-derived input, so a control-plane read cannot reach the public path by
7
- * accident. That absence is the contract, and it is held structurally rather
7
+ * accident. Mirror balances are not a read: the caller passes a map it already
8
+ * resolved, or nothing. That absence of a D1 read is the contract, and it is held structurally rather
8
9
  * than by review: `public-render-no-request-time-d1` (.dependency-cruiser.cjs)
9
10
  * fails `pnpm run check` if ANY module reachable from this one reaches
10
11
  * `lib/publication/shared/env.ts`, the sole accessor for a live D1 handle. The public path reaches
@@ -25,19 +26,25 @@
25
26
  * snapshot reaches a public page baked into the static bundle (the signed
26
27
  * materialize-mode header the shared chain still honors).
27
28
  * - catalog — the bundled JSON snapshot, never the D1 edge replica.
29
+ * - mirror balances — only a map the caller already resolved (KV). This module
30
+ * never opens D1. A missing map leaves the file stamp unchanged.
28
31
  */
29
32
  import { runRenderChain, type RenderChainInput } from "./renderChain.js";
30
33
 
31
34
  export type PublicRenderInput = Omit<
32
35
  RenderChainInput,
33
- "managedIntegrationDeclarations" | "readTenantConsentSnapshot" | "readCatalogD1"
34
- >;
36
+ "managedIntegrationDeclarations" | "readTenantConsentSnapshot" | "readCatalogD1" | "readMirrorBalances"
37
+ > & {
38
+ mirrorBalances?: ReadonlyMap<string, { balance: number; asOf: string }> | null;
39
+ };
35
40
 
36
41
  export function servePublicRender(input: PublicRenderInput): Promise<Response> {
42
+ const { mirrorBalances = null, ...rest } = input;
37
43
  return runRenderChain({
38
- ...input,
44
+ ...rest,
39
45
  managedIntegrationDeclarations: [],
40
46
  readTenantConsentSnapshot: null,
41
47
  readCatalogD1: null,
48
+ readMirrorBalances: mirrorBalances ? async () => mirrorBalances : null,
42
49
  });
43
50
  }
@@ -171,6 +171,11 @@ export interface RenderChainInput {
171
171
  * parameter rather than the D1 module, so this file names no D1 type at all.
172
172
  */
173
173
  readCatalogD1: (() => Promise<Parameters<typeof createToTClient>[1]>) | null;
174
+ /**
175
+ * Already-resolved mirror balances for this commerce stage. Null on the public
176
+ * branch: that render has no ledger handle, and publication HTML is CDN to storage.
177
+ */
178
+ readMirrorBalances?: ((stage: "preview" | "publication") => Promise<ReadonlyMap<string, { balance: number; asOf: string }> | null>) | null;
174
179
  /** Exact per-request grant for an F2-selected internal publication space. */
175
180
  internalContentGrant?: InternalContentGrant;
176
181
  /** Pin already verified for this tenant by the control-plane login gate. */
@@ -710,8 +715,15 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
710
715
  // are read through the SAME content source just resolved, so they follow the
711
716
  // request's selected version exactly as pages and the blog do. Only the control
712
717
  // plane supplies the D1 projection; the public branch never reads D1.
718
+ let mirrorOverlay: Parameters<typeof createToTClient>[3] = null;
719
+ if (input.readMirrorBalances) {
720
+ try {
721
+ const balances = await input.readMirrorBalances(customization.env === "live" ? "publication" : "preview");
722
+ if (balances) mirrorOverlay = { balances, now: new Date().toISOString() };
723
+ } catch { mirrorOverlay = null; }
724
+ }
713
725
  locals.tot = await timed(timing, "catalog", async () =>
714
- createToTClient(tenant, await input.readCatalogD1?.(), locals.content));
726
+ createToTClient(tenant, await input.readCatalogD1?.(), locals.content, mirrorOverlay));
715
727
  locals.themeStyle = themeToCssVars(theme);
716
728
  locals.canonicalBase = canonicalBase;
717
729
  locals.customizationEnv = customization.env;
@@ -10,7 +10,7 @@ import { fromD1 } from "@/lib/catalog/d1/catalog.js";
10
10
  import { readD1, readEnv, readKv } from "@/lib/publication/shared/env";
11
11
  import { evaluateFinalCart, type SeamView } from "@/lib/checkout/commerce/finalCartAuthorization.js";
12
12
  import { d1CommerceRunner, reserveAllocation } from "@/lib/inventory/allocations.js";
13
- import { readMirrorStock } from "@/lib/inventory/mirrorStock.js";
13
+ import { readMirrorBalance, readMirrorStock } from "@/lib/inventory/mirrorStock.js";
14
14
  import { admissionMatches, readCommerceAdmission, admissionSnapshotStatement } from "@/lib/checkout/commerce/commerceAdmission.js";
15
15
  import { evaluatePreflight } from "../compliance/preflight.js";
16
16
  import {
@@ -225,6 +225,12 @@ async function authorize(context: APIContext): Promise<Response> {
225
225
  if (tenant.inventory?.provider && tenant.inventory.provider !== "mirror") return deny("authority-unavailable");
226
226
  const now = new Date().toISOString();
227
227
  const queryable = fromD1(database);
228
+ for (const line of snapshot.lines) {
229
+ const reading = await readMirrorBalance(queryable, {
230
+ tenantId: snapshot.tenantId, stage: snapshot.stage, sku: line.sku, locationId: "unspecified",
231
+ });
232
+ if (reading && reading.balance <= 0) return deny("stock-unavailable");
233
+ }
228
234
  const prior = await queryable.first<{ authorization_id: string; state: string }>(
229
235
  `SELECT s.authorization_id,a.state FROM commerce_admission_snapshots s JOIN scrappy_final_cart_authorizations a
230
236
  ON a.tenant_id=s.tenant_id AND a.stage=s.stage AND a.authorization_id=s.authorization_id
@@ -240,8 +246,8 @@ async function authorize(context: APIContext): Promise<Response> {
240
246
  const requirements = await readCheckoutRequirements(tenant.tenant_id, contentEnv);
241
247
  if (!requirements) return deny("checkout-requirements-unreadable", 503);
242
248
  const policy = testStoreEvidencePolicy(requirements);
243
- // Stock is tracked only for a tenant that declares the `mirror` provider. An undeclared tenant
244
- // has no balance to read, so its stock evidence is honestly `not-required`, not a refusal.
249
+ // A declared mirror provider is admitted only against a fresh positive balance. An undeclared
250
+ // tenant with no movements records stock as `not-required`. A non-positive balance already refused.
245
251
  const stockTracked = tenant.inventory?.provider === "mirror";
246
252
  const authorities = [];
247
253
  if (stockTracked) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "4.2.1",
3
+ "version": "4.2.3",
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",