@tokenoftrust/storefront-runner 4.4.0 → 4.6.0
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/agency-kit/tools/lib/catalog-model/files.mjs +30 -3
- package/agency-kit/tools/lib/catalog-model/sku.d.mts +16 -0
- package/agency-kit/tools/lib/catalog-model/sku.mjs +146 -0
- package/apps/storefront/src/components/catalog/storefront/plp/FacetSidebar.astro +29 -13
- package/apps/storefront/src/components/content/storefront/chrome/SiteHeader.astro +20 -2
- package/apps/storefront/src/layouts/Layout.astro +20 -2
- package/apps/storefront/src/lib/checkout/commerce/finalCartAuthorization.ts +9 -0
- package/apps/storefront/src/lib/checkout/shared/lazyCheckoutLoader.ts +120 -29
- package/apps/storefront/src/lib/content/shared/rawChrome.ts +15 -2
- package/apps/storefront/src/lib/content/shared/rawMarketingHtml.ts +26 -0
- package/apps/storefront/src/lib/inventory/allocations.ts +3 -3
- package/apps/storefront/src/lib/inventory/mirrorStock.ts +3 -1
- package/apps/storefront/src/lib/orders/admin/orderSource.ts +342 -0
- package/apps/storefront/src/lib/orders/admin/services/orders.ts +1 -1
- package/apps/storefront/src/lib/orders/data/orderMapper.ts +10 -4
- package/apps/storefront/src/lib/orders/data/ordersStore.ts +48 -0
- package/apps/storefront/src/lib/publication/shared/url.ts +19 -0
- package/apps/storefront/src/middleware/renderChain.ts +3 -0
- package/apps/storefront/src/pages/api/admin/orders/[id].ts +15 -30
- package/apps/storefront/src/pages/api/admin/orders.ts +15 -32
- package/apps/storefront/src/pages/api/commerce/final-cart-authorization.ts +39 -15
- package/apps/storefront/src/pages/tenants/[id]/[...path].ts +12 -2
- package/package.json +1 -1
- package/packages/cli/src/declared-config.mjs +43 -7
- package/packages/public-runtime/package.json +2 -0
- package/packages/public-runtime/src/checkout.ts +13 -3
- package/packages/public-runtime/src/preview-url.d.mts +10 -0
- package/packages/public-runtime/src/preview-url.mjs +54 -0
- package/packages/public-runtime/src/sku.ts +19 -0
- package/packages/public-runtime/src/tenant.ts +3 -0
|
@@ -17,10 +17,24 @@
|
|
|
17
17
|
*
|
|
18
18
|
* A cart-origin link click or form submit that beats the drawer is held, not
|
|
19
19
|
* lost: it is cancelled, the loader is fetched, and the same control is activated
|
|
20
|
-
* again once the drawer reports ready
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
20
|
+
* again once the drawer client reports `ready.done`, so it opens in the drawer
|
|
21
|
+
* exactly as it would have. Foxy binds its cart-link handler in `FC.client.init`
|
|
22
|
+
* and initialises the side cart on `ready.done`; before that a cart link is a
|
|
23
|
+
* plain navigation to the full cart page. So a held action waits for that signal,
|
|
24
|
+
* not a clock: it follows the link to the cart page only when the drawer cannot
|
|
25
|
+
* come up (the loader or bundle errors, no client appears, or nothing reports
|
|
26
|
+
* within LAZY_CHECKOUT_READY_TIMEOUT_MS). A held action never outlives the page
|
|
27
|
+
* that held it. Holding runs in the window bubble phase, after every page
|
|
28
|
+
* handler, so an action another handler already took over (the compliance gate,
|
|
29
|
+
* or the drawer itself) is left alone.
|
|
30
|
+
*
|
|
31
|
+
* Every cart action that reaches the window still unhandled once the drawer has
|
|
32
|
+
* settled is about to navigate to the cart page. That is recorded: a
|
|
33
|
+
* `[tot-cart]` console warning, a `tot:cart-fallthrough` window event, and
|
|
34
|
+
* `data-cart-fallthrough="<reason>[:<cause>]"` on the root element. The detail
|
|
35
|
+
* carries only the reason, the cause, elapsed ms, whether the action was held,
|
|
36
|
+
* whether `FC.client` existed, Foxy's own full-page and bypass decisions, the
|
|
37
|
+
* document ready state and viewport/screen/body widths — never a URL or session.
|
|
24
38
|
*
|
|
25
39
|
* Every cart open waits for this store's Foxy session. The id comes from the
|
|
26
40
|
* same-origin session endpoint (an HttpOnly cookie the browser cannot read) and
|
|
@@ -43,8 +57,12 @@ import { CART_UNAVAILABLE_MESSAGE } from "@/lib/checkout/commerce/cartSession";
|
|
|
43
57
|
|
|
44
58
|
export const LAZY_CHECKOUT_LOADER_IDLE_MS = 8000;
|
|
45
59
|
|
|
46
|
-
/**
|
|
47
|
-
|
|
60
|
+
/**
|
|
61
|
+
* Upper bound on waiting for the drawer client to report ready before a held action
|
|
62
|
+
* follows its link to the full cart page. Long enough for a cold drawer bundle on a
|
|
63
|
+
* slow phone; reached only when the drawer is not coming up at all.
|
|
64
|
+
*/
|
|
65
|
+
export const LAZY_CHECKOUT_READY_TIMEOUT_MS = 12000;
|
|
48
66
|
|
|
49
67
|
export const LAZY_CHECKOUT_LOADER_EVENTS = ["pointerdown", "keydown", "touchstart"] as const;
|
|
50
68
|
|
|
@@ -81,7 +99,11 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
|
|
|
81
99
|
var referrerPolicy = ${literal(CHECKOUT_NAVIGATION_REFERRER_POLICY)};
|
|
82
100
|
var storeNotPublic = ${options.storeNotPublic ? "true" : "false"};
|
|
83
101
|
var events = ${literal(LAZY_CHECKOUT_LOADER_EVENTS)};
|
|
102
|
+
// idle → loading → ready (the drawer client reported ready.done) | failed (it cannot come up).
|
|
84
103
|
var state = 'idle';
|
|
104
|
+
var failure = null;
|
|
105
|
+
var armedAt = 0;
|
|
106
|
+
var replaying = null;
|
|
85
107
|
var waiters = [];
|
|
86
108
|
var sessionId = null;
|
|
87
109
|
var sessionPromise = null;
|
|
@@ -143,7 +165,10 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
|
|
|
143
165
|
document.addEventListener("visibilitychange", function () {
|
|
144
166
|
if (document.visibilityState === "hidden") endCartNavigation();
|
|
145
167
|
});
|
|
146
|
-
window.addEventListener("pagehide",
|
|
168
|
+
window.addEventListener("pagehide", function () {
|
|
169
|
+
endCartNavigation();
|
|
170
|
+
dropHeld();
|
|
171
|
+
});
|
|
147
172
|
window.addEventListener("pageshow", function (event) {
|
|
148
173
|
if (event.persisted) endCartNavigation();
|
|
149
174
|
});
|
|
@@ -201,20 +226,75 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
|
|
|
201
226
|
});
|
|
202
227
|
return sessionPromise;
|
|
203
228
|
}
|
|
204
|
-
function
|
|
229
|
+
function release(held) {
|
|
230
|
+
if (held.el && held.el.removeAttribute) held.el.removeAttribute('aria-busy');
|
|
231
|
+
}
|
|
232
|
+
function dropHeld() {
|
|
233
|
+
var dropped = waiters;
|
|
205
234
|
waiters = [];
|
|
235
|
+
for (var i = 0; i < dropped.length; i++) release(dropped[i]);
|
|
236
|
+
}
|
|
237
|
+
function markSessionUnavailable() {
|
|
238
|
+
dropHeld();
|
|
206
239
|
state = 'idle';
|
|
207
240
|
document.documentElement.setAttribute('data-cart-session', 'unavailable');
|
|
208
241
|
for (var i = 0; i < events.length; i++) {
|
|
209
242
|
document.addEventListener(events[i], onInput, { capture: true, passive: true });
|
|
210
243
|
}
|
|
211
244
|
}
|
|
245
|
+
function settled() {
|
|
246
|
+
return state === 'ready' || state === 'failed';
|
|
247
|
+
}
|
|
248
|
+
function runHeld() {
|
|
249
|
+
var run = waiters;
|
|
250
|
+
waiters = [];
|
|
251
|
+
for (var i = 0; i < run.length; i++) {
|
|
252
|
+
release(run[i]);
|
|
253
|
+
run[i].fn();
|
|
254
|
+
}
|
|
255
|
+
}
|
|
212
256
|
function ready() {
|
|
213
257
|
if (state === 'ready') return;
|
|
214
258
|
state = 'ready';
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
259
|
+
failure = null;
|
|
260
|
+
runHeld();
|
|
261
|
+
}
|
|
262
|
+
function fail(cause) {
|
|
263
|
+
if (state !== 'loading') return;
|
|
264
|
+
state = 'failed';
|
|
265
|
+
failure = cause;
|
|
266
|
+
runHeld();
|
|
267
|
+
}
|
|
268
|
+
// A cart action reaching the window unhandled after the drawer settled navigates
|
|
269
|
+
// to the full cart page. Record why, with no URL or session in the record.
|
|
270
|
+
function fallthrough(kind, el) {
|
|
271
|
+
var fc = window.FC || {};
|
|
272
|
+
var fullpage = null;
|
|
273
|
+
try {
|
|
274
|
+
if (fc.sidecart && typeof fc.sidecart.shouldUseFullpageCart === 'function') fullpage = !!fc.sidecart.shouldUseFullpageCart();
|
|
275
|
+
} catch (e) {}
|
|
276
|
+
var body = document.body;
|
|
277
|
+
function bypassed(node) {
|
|
278
|
+
return !!(node && node.classList && (node.classList.contains('fc-sidecart--bypass') || node.hasAttribute('data-fc-sidecart-bypass')));
|
|
279
|
+
}
|
|
280
|
+
var reason = state === 'ready' ? 'drawer-declined' : 'drawer-unavailable';
|
|
281
|
+
var detail = {
|
|
282
|
+
reason: reason,
|
|
283
|
+
cause: failure,
|
|
284
|
+
kind: kind,
|
|
285
|
+
held: replaying === el,
|
|
286
|
+
elapsedMs: armedAt ? Date.now() - armedAt : null,
|
|
287
|
+
fcClient: !!fc.client,
|
|
288
|
+
fullpage: fullpage,
|
|
289
|
+
bypass: bypassed(body) || bypassed(el),
|
|
290
|
+
docState: document.readyState,
|
|
291
|
+
viewport: window.innerWidth,
|
|
292
|
+
screenWidth: window.screen ? window.screen.availWidth : null,
|
|
293
|
+
bodyWidth: body ? body.clientWidth : null
|
|
294
|
+
};
|
|
295
|
+
document.documentElement.setAttribute('data-cart-fallthrough', reason + (failure ? ':' + failure : ''));
|
|
296
|
+
try { console.warn('[tot-cart] cart fell through to the cart page', detail); } catch (e) {}
|
|
297
|
+
try { window.dispatchEvent(new window.CustomEvent('tot:cart-fallthrough', { detail: detail })); } catch (e) {}
|
|
218
298
|
}
|
|
219
299
|
function armDrawer(id) {
|
|
220
300
|
document.documentElement.removeAttribute('data-cart-session');
|
|
@@ -225,23 +305,25 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
|
|
|
225
305
|
window.FC.settings.cookiepath = cookiePath;
|
|
226
306
|
window.FC.settings.sitedomain = location.hostname;
|
|
227
307
|
};
|
|
228
|
-
|
|
308
|
+
armedAt = Date.now();
|
|
309
|
+
setTimeout(function () { fail('ready-timeout'); }, ${LAZY_CHECKOUT_READY_TIMEOUT_MS});
|
|
229
310
|
var s = document.createElement('script');
|
|
230
311
|
s.src = src;
|
|
231
312
|
s.async = true;
|
|
232
|
-
// The loader appends the drawer bundle and exposes it as fc_script
|
|
233
|
-
//
|
|
313
|
+
// The loader appends the drawer bundle and exposes it as fc_script. The bundle's
|
|
314
|
+
// FC.client.init binds the cart-link handler and ready.done initialises the side
|
|
315
|
+
// cart, so ready.done is the only signal that a cart link will open the drawer.
|
|
234
316
|
s.addEventListener('load', function () {
|
|
235
317
|
var bundle = window.fc_script;
|
|
236
|
-
if (!bundle) return
|
|
318
|
+
if (!bundle) return fail('no-bundle');
|
|
237
319
|
bundle.addEventListener('load', function () {
|
|
238
320
|
var client = window.FC && window.FC.client;
|
|
239
|
-
if (!client || typeof client.on !== 'function') return
|
|
321
|
+
if (!client || typeof client.on !== 'function') return fail('no-client');
|
|
240
322
|
client.on('ready.done', ready);
|
|
241
323
|
});
|
|
242
|
-
bundle.addEventListener('error',
|
|
324
|
+
bundle.addEventListener('error', function () { fail('bundle-error'); });
|
|
243
325
|
});
|
|
244
|
-
s.addEventListener('error',
|
|
326
|
+
s.addEventListener('error', function () { fail('loader-error'); });
|
|
245
327
|
document.body.appendChild(s);
|
|
246
328
|
}
|
|
247
329
|
function start() {
|
|
@@ -253,11 +335,16 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
|
|
|
253
335
|
armDrawer(id);
|
|
254
336
|
});
|
|
255
337
|
}
|
|
256
|
-
function whenReady(fn) {
|
|
257
|
-
if (
|
|
258
|
-
|
|
338
|
+
function whenReady(el, fn) {
|
|
339
|
+
if (settled()) return fn();
|
|
340
|
+
el.setAttribute('aria-busy', 'true');
|
|
341
|
+
waiters.push({ el: el, fn: fn });
|
|
259
342
|
start();
|
|
260
343
|
}
|
|
344
|
+
function replay(el, fn) {
|
|
345
|
+
replaying = el;
|
|
346
|
+
try { fn(); } finally { replaying = null; }
|
|
347
|
+
}
|
|
261
348
|
function onInput() {
|
|
262
349
|
if (commerceReachable()) start();
|
|
263
350
|
}
|
|
@@ -292,16 +379,19 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
|
|
|
292
379
|
var anchor = event.target && event.target.closest ? event.target.closest('a[href]') : null;
|
|
293
380
|
if (!anchor || anchor.target === '_blank' || !targetsCart(anchor.href)) return;
|
|
294
381
|
anchor.referrerPolicy = referrerPolicy;
|
|
295
|
-
if (
|
|
382
|
+
if (settled()) {
|
|
296
383
|
stampAnchor(anchor);
|
|
297
|
-
if (!event.defaultPrevented)
|
|
384
|
+
if (!event.defaultPrevented) {
|
|
385
|
+
fallthrough('link', anchor);
|
|
386
|
+
revealForNavigation();
|
|
387
|
+
}
|
|
298
388
|
return;
|
|
299
389
|
}
|
|
300
390
|
if (event.defaultPrevented) return;
|
|
301
391
|
event.preventDefault();
|
|
302
|
-
whenReady(function () {
|
|
392
|
+
whenReady(anchor, function () {
|
|
303
393
|
stampAnchor(anchor);
|
|
304
|
-
anchor.click();
|
|
394
|
+
replay(anchor, function () { anchor.click(); });
|
|
305
395
|
});
|
|
306
396
|
});
|
|
307
397
|
|
|
@@ -352,10 +442,11 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
|
|
|
352
442
|
var cartForm = targetsCart(form.getAttribute('action'));
|
|
353
443
|
if (!managed && !cartForm) return;
|
|
354
444
|
if (form.dataset.cartSession === '1') {
|
|
355
|
-
if (!cartForm ||
|
|
356
|
-
if (!event.defaultPrevented &&
|
|
445
|
+
if (!cartForm || settled()) {
|
|
446
|
+
if (!event.defaultPrevented && settled()) {
|
|
357
447
|
form.referrerPolicy = referrerPolicy;
|
|
358
448
|
if (cartForm) {
|
|
449
|
+
fallthrough('form', form);
|
|
359
450
|
form.setAttribute("referrerpolicy", referrerPolicy);
|
|
360
451
|
loosenReferrer();
|
|
361
452
|
revealForNavigation();
|
|
@@ -364,7 +455,7 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
|
|
|
364
455
|
return;
|
|
365
456
|
}
|
|
366
457
|
event.preventDefault();
|
|
367
|
-
whenReady(function () { stampForm(form); resubmit(form); });
|
|
458
|
+
whenReady(form, function () { stampForm(form); replay(form, function () { resubmit(form); }); });
|
|
368
459
|
return;
|
|
369
460
|
}
|
|
370
461
|
event.preventDefault();
|
|
@@ -372,7 +463,7 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
|
|
|
372
463
|
openSessionThen(form, function () {
|
|
373
464
|
if (cartForm) {
|
|
374
465
|
stampForm(form);
|
|
375
|
-
whenReady(function () { resubmit(form); });
|
|
466
|
+
whenReady(form, function () { replay(form, function () { resubmit(form); }); });
|
|
376
467
|
return;
|
|
377
468
|
}
|
|
378
469
|
resubmit(form);
|
|
@@ -24,7 +24,12 @@
|
|
|
24
24
|
* stray quote/angle-bracket can't break out of an attribute or element; this is
|
|
25
25
|
* defense-in-depth, not a sanitizer for untrusted input.
|
|
26
26
|
*/
|
|
27
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
BRAND_H1_CLASS,
|
|
29
|
+
CHROME_BODY_MARKER,
|
|
30
|
+
CHROME_HEAD_MARKER,
|
|
31
|
+
isHomePath,
|
|
32
|
+
} from "./rawMarketingHtml.js";
|
|
28
33
|
import { isCurrentNavHref } from "../chrome/navCurrent.js";
|
|
29
34
|
import { ctaIdentityAttr } from "../chrome/ctaIdentity.js";
|
|
30
35
|
import { CHROME_PLACEMENT } from "../chrome/trackingPlacements.js";
|
|
@@ -325,6 +330,14 @@ function renderActionLinks(links: ChromeHeaderActionLink[]): string {
|
|
|
325
330
|
}
|
|
326
331
|
|
|
327
332
|
function renderHeader(header: ChromeHeader, variant: HeaderVariant, currentPath?: string): string {
|
|
333
|
+
// One-h1 contract: on the home route (header only, never the footer) the brand
|
|
334
|
+
// renders inside <h1 class="brand-h1">. When the spliced page body supplies its
|
|
335
|
+
// own <h1>, wrapFragmentInChrome / Layout unwrap this one (demoteBrandH1), so
|
|
336
|
+
// every page carries exactly one h1.
|
|
337
|
+
const brandLink = renderBrand(header.brand);
|
|
338
|
+
const brandBlock = isHomePath(currentPath)
|
|
339
|
+
? `<h1 class="${BRAND_H1_CLASS}" style="margin:0;font:inherit">${brandLink}</h1>`
|
|
340
|
+
: brandLink;
|
|
328
341
|
const search = header.search ? renderSearchForm(header.search) : "";
|
|
329
342
|
const memberCue = header.memberCue
|
|
330
343
|
? `<a class="member-cue" href="${esc(header.memberCue.href)}" aria-label="${esc(header.memberCue.label)}">${esc(header.memberCue.label)}</a>`
|
|
@@ -424,7 +437,7 @@ function renderHeader(header: ChromeHeader, variant: HeaderVariant, currentPath?
|
|
|
424
437
|
` <div class="container header-row">\n` +
|
|
425
438
|
(menuLeft && hamburger ? ` ${hamburger}\n` : "") +
|
|
426
439
|
(compact ? ` ${primaryNav}\n` : "") +
|
|
427
|
-
` ${
|
|
440
|
+
` ${brandBlock}\n` +
|
|
428
441
|
(!minimal && !compact && search ? ` ${search}\n` : "") +
|
|
429
442
|
` <div class="header-actions">${memberCue}${ctas}${compactSearch}${actionLinks}${account}${cart}${menuLeft ? "" : hamburger}</div>\n` +
|
|
430
443
|
` </div>\n` +
|
|
@@ -56,13 +56,39 @@ export function isFullDocument(html: string): boolean {
|
|
|
56
56
|
return /^\s*<!doctype/i.test(html) || /<html[\s>]/i.test(html);
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/** Class of the home-route header brand heading (`<h1 class="brand-h1">`). */
|
|
60
|
+
export const BRAND_H1_CLASS = "brand-h1";
|
|
61
|
+
|
|
62
|
+
/** True for the home route ("/" with optional query/hash); false when no path is known. */
|
|
63
|
+
export function isHomePath(path: string | undefined): boolean {
|
|
64
|
+
return path !== undefined && (path.split(/[?#]/)[0] ?? "") === "/";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** True when page content already supplies an `<h1>`. */
|
|
68
|
+
export function hasContentH1(html: string): boolean {
|
|
69
|
+
return /<h1[\s>]/i.test(html);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Unwrap the home brand heading (keeping the brand link) so a page whose own
|
|
74
|
+
* content supplies an `<h1>` ends up with exactly one h1.
|
|
75
|
+
*/
|
|
76
|
+
export function demoteBrandH1(html: string): string {
|
|
77
|
+
return html.replace(
|
|
78
|
+
new RegExp(`<h1 class="${BRAND_H1_CLASS}"[^>]*>([\\s\\S]*?)</h1>`),
|
|
79
|
+
"$1",
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
59
83
|
/**
|
|
60
84
|
* Splice a body-only fragment into a tenant chrome wrapper at CHROME_BODY_MARKER.
|
|
85
|
+
* When the fragment carries its own `<h1>`, the wrapper's brand h1 is unwrapped.
|
|
61
86
|
* If the wrapper has no marker (misconfigured), the fragment is returned unwrapped
|
|
62
87
|
* so a page never disappears.
|
|
63
88
|
*/
|
|
64
89
|
export function wrapFragmentInChrome(fragment: string, chromeWrapper: string): string {
|
|
65
90
|
if (!chromeWrapper.includes(CHROME_BODY_MARKER)) return fragment;
|
|
91
|
+
if (hasContentH1(fragment)) chromeWrapper = demoteBrandH1(chromeWrapper);
|
|
66
92
|
// A fragment may lead with a <head>…</head> of page-specific SEO tags. Split it
|
|
67
93
|
// out so the shared wrapper keeps per-page title/meta/canonical/JSON-LD.
|
|
68
94
|
let head = "";
|
|
@@ -15,12 +15,12 @@ import {
|
|
|
15
15
|
type PaymentState,
|
|
16
16
|
type StockState,
|
|
17
17
|
} from "@tot/public-runtime";
|
|
18
|
+
import { isStoredSku } from "@tot/public-runtime/sku";
|
|
18
19
|
|
|
19
20
|
export const RESERVATION_AUTHORITY_HEADER = "x-tot-reservation-authority";
|
|
20
21
|
export const RESERVATION_AUTHORITY_NONE = "none";
|
|
21
22
|
export const VERIFICATION_PROVIDER_OWNER = "tot-9577-tot-verification-provider";
|
|
22
23
|
|
|
23
|
-
const SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,80}$/;
|
|
24
24
|
const ID_RE = /^[a-z0-9][a-z0-9-]{0,80}$/;
|
|
25
25
|
const TENANT_RE = /^[a-z0-9][a-z0-9.-]{0,253}$/;
|
|
26
26
|
const LOCATION_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,80}$/;
|
|
@@ -153,7 +153,7 @@ export function readMirrorAuthority(value: unknown): MirrorStockAuthority | null
|
|
|
153
153
|
const record = value as Record<string, unknown>;
|
|
154
154
|
if (record.provider !== "mirror" || record.readable !== true) return null;
|
|
155
155
|
if (typeof record.locationId !== "string" || !LOCATION_RE.test(record.locationId)) return null;
|
|
156
|
-
if (typeof record.sku !== "string" || !
|
|
156
|
+
if (typeof record.sku !== "string" || !isStoredSku(record.sku)) return null;
|
|
157
157
|
if (typeof record.onHand !== "number" || !Number.isSafeInteger(record.onHand) || record.onHand < 0) return null;
|
|
158
158
|
if (typeof record.asOf !== "string" || record.asOf.trim() === "" || record.asOf.length > 40) return null;
|
|
159
159
|
return {
|
|
@@ -252,7 +252,7 @@ function reserveShape(input: ReserveInput): ReserveResult | null {
|
|
|
252
252
|
if (!Number.isSafeInteger(input.amountMinor) || input.amountMinor < 0) return { ok: false, finding: "malformed-request" };
|
|
253
253
|
if (input.holds.length < 1) return { ok: false, finding: "malformed-request" };
|
|
254
254
|
for (const hold of input.holds) {
|
|
255
|
-
if (!
|
|
255
|
+
if (!isStoredSku(hold.sku) || !LOCATION_RE.test(hold.locationId)) return { ok: false, finding: "malformed-request" };
|
|
256
256
|
if (!Number.isSafeInteger(hold.quantity) || hold.quantity < 1) return { ok: false, finding: "malformed-request" };
|
|
257
257
|
if (!Number.isSafeInteger(hold.onHand) || hold.onHand < 0) return { ok: false, finding: "malformed-request" };
|
|
258
258
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** Tenant-scoped inventory authority. Catalog availability never supplies a balance. */
|
|
2
2
|
import type { Queryable } from "../catalog/d1/catalog.js";
|
|
3
3
|
import type { CommerceStage } from "@tot/public-runtime";
|
|
4
|
+
import { isStoredSku } from "@tot/public-runtime/sku";
|
|
4
5
|
import type { MirrorStockAuthority } from "./allocations.js";
|
|
5
6
|
|
|
6
7
|
const ID = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
@@ -25,9 +26,10 @@ export interface MirrorMovement {
|
|
|
25
26
|
}
|
|
26
27
|
export async function appendMirrorMovement(db: Queryable, input: MirrorMovement): Promise<void> {
|
|
27
28
|
if (
|
|
28
|
-
![input.tenantId, input.source, input.sourceEventId, input.
|
|
29
|
+
![input.tenantId, input.source, input.sourceEventId, input.locationId, input.actorId].every((v) =>
|
|
29
30
|
ID.test(v),
|
|
30
31
|
) ||
|
|
32
|
+
!isStoredSku(input.sku) ||
|
|
31
33
|
!Number.isSafeInteger(input.quantityDelta) ||
|
|
32
34
|
!Number.isFinite(Date.parse(input.asOf)) ||
|
|
33
35
|
!["preview", "publication"].includes(input.stage)
|