@tokenoftrust/storefront-runner 4.2.1 → 4.2.2

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.
@@ -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, {
@@ -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.2",
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",