@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.
- package/apps/storefront/src/components/content/storefront/chrome/SiteFooter.astro +1 -1
- package/apps/storefront/src/components/content/storefront/chrome/SiteHeader.astro +10 -9
- package/apps/storefront/src/components/content/storefront/chrome/UtilityNav.astro +1 -1
- package/apps/storefront/src/layouts/Layout.astro +9 -4
- 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/content/shared/rawMarketingHtml.ts +45 -8
- 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 +9 -3
- package/package.json +1 -1
|
@@ -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 &&
|
|
209
|
+
!minimal && !compact && searchFormAction && (
|
|
209
210
|
<div class="hidden flex-1 md:flex">
|
|
210
211
|
<SearchTypeahead
|
|
211
212
|
client:idle
|
|
212
|
-
action={
|
|
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 &&
|
|
224
|
+
compact && searchFormAction && (
|
|
224
225
|
<a
|
|
225
|
-
href={
|
|
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 &&
|
|
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={
|
|
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,
|
|
391
|
-
<a href={
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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, {
|
|
@@ -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.
|
|
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
|
|
10
|
-
* the raw doc bypasses the Layout's client
|
|
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 =
|
|
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
|
|
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 {
|
|
@@ -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
|
-
//
|
|
244
|
-
//
|
|
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.
|
|
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",
|