@tokenoftrust/storefront-runner 4.2.0 → 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.
- package/apps/storefront/src/lib/catalog/tot/ToTClient.ts +58 -16
- package/apps/storefront/src/lib/checkout/commerce/checkoutHandoffRoute.ts +45 -1
- package/apps/storefront/src/lib/inventory/mirrorBalanceProjection.ts +45 -0
- package/apps/storefront/src/lib/inventory/mirrorStock.ts +152 -0
- package/apps/storefront/src/middleware/controlPlane.ts +14 -4
- package/apps/storefront/src/middleware/index.ts +15 -1
- package/apps/storefront/src/middleware/publicRender.ts +11 -4
- package/apps/storefront/src/middleware/renderChain.ts +13 -1
- package/apps/storefront/src/pages/api/commerce/final-cart-authorization.ts +18 -6
- package/package.json +1 -1
|
@@ -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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
|
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 ||
|
|
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
|
|
6
|
-
* edge replica
|
|
7
|
-
*
|
|
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.
|
|
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
|
-
...
|
|
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 {
|
|
@@ -167,13 +167,19 @@ async function authorize(context: APIContext): Promise<Response> {
|
|
|
167
167
|
const kv = await readKv("TENANT_CACHE");
|
|
168
168
|
const kvGet = async (key: string): Promise<string | null> => kv ? kv.get(key) : null;
|
|
169
169
|
const contentEnv = snapshot.stage === "publication" ? "live" : "test";
|
|
170
|
+
const contentVersionId = snapshot.stage === "preview" && snapshot.contentVersionId ? snapshot.contentVersionId : undefined;
|
|
170
171
|
let tenant: TenantConfig;
|
|
171
|
-
try {
|
|
172
|
+
try {
|
|
173
|
+
tenant = await withDeclaredTenantConfig(registered, kvGet, {
|
|
174
|
+
env: contentEnv,
|
|
175
|
+
...(contentVersionId ? { pinnedVersionId: contentVersionId } : {}),
|
|
176
|
+
});
|
|
177
|
+
} catch (err) {
|
|
172
178
|
if (err instanceof DeclaredTenantConfigUnreadError) return deny("config-unreadable", 503);
|
|
173
179
|
throw err;
|
|
174
180
|
}
|
|
175
|
-
const content =
|
|
176
|
-
? createContentProvider(tenant, undefined, { get: kvGet, versionId:
|
|
181
|
+
const content = contentVersionId
|
|
182
|
+
? createContentProvider(tenant, undefined, { get: kvGet, versionId: contentVersionId })
|
|
177
183
|
: createContentProvider(tenant, { get: kvGet, env: contentEnv });
|
|
178
184
|
const tot = await createToTClient(tenant, await readD1("CATALOG_DB"), content);
|
|
179
185
|
const authorityContext = { ...context, locals: { ...context.locals, tenant, tot, content } } as APIContext;
|
|
@@ -219,6 +225,12 @@ async function authorize(context: APIContext): Promise<Response> {
|
|
|
219
225
|
if (tenant.inventory?.provider && tenant.inventory.provider !== "mirror") return deny("authority-unavailable");
|
|
220
226
|
const now = new Date().toISOString();
|
|
221
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
|
+
}
|
|
222
234
|
const prior = await queryable.first<{ authorization_id: string; state: string }>(
|
|
223
235
|
`SELECT s.authorization_id,a.state FROM commerce_admission_snapshots s JOIN scrappy_final_cart_authorizations a
|
|
224
236
|
ON a.tenant_id=s.tenant_id AND a.stage=s.stage AND a.authorization_id=s.authorization_id
|
|
@@ -234,8 +246,8 @@ async function authorize(context: APIContext): Promise<Response> {
|
|
|
234
246
|
const requirements = await readCheckoutRequirements(tenant.tenant_id, contentEnv);
|
|
235
247
|
if (!requirements) return deny("checkout-requirements-unreadable", 503);
|
|
236
248
|
const policy = testStoreEvidencePolicy(requirements);
|
|
237
|
-
//
|
|
238
|
-
//
|
|
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.
|
|
239
251
|
const stockTracked = tenant.inventory?.provider === "mirror";
|
|
240
252
|
const authorities = [];
|
|
241
253
|
if (stockTracked) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "4.2.
|
|
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",
|