@blamejs/blamejs-shop 0.4.13 → 0.4.14
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/CHANGELOG.md +2 -0
- package/lib/asset-manifest.json +1 -1
- package/lib/security-middleware.js +28 -1
- package/lib/storefront.js +112 -48
- package/lib/webhooks.js +4 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.4.x
|
|
10
10
|
|
|
11
|
+
- v0.4.14 (2026-06-06) — **Gift wrap charges exactly the fee it displays, the per-order wrap cap is enforced, low-stock events reach subscribed webhooks, and the admin API accepts curl as documented.** Three storefront fixes and two admin-surface fixes. Gift wrap now charges the configured wrap fee rather than the wrap variant's catalog price, and a wrap's per-order cap is enforced at the cart with a clear message. The low-stock inventory event is now a registered webhook event, so endpoints subscribed to specific events receive it. The account profile explains plainly why an email address cannot be changed. And the admin JSON API now answers automation clients such as curl the way the documentation has always shown, with the bearer key as the deciding check. **Changed:** *The account profile explains why the email address cannot be changed* — The store keeps only a one-way hash of each account's email address, so an address can never be read back or rewritten — an email change is not possible by design. The profile screen now says this plainly where an email-change control would normally sit, and points to the alternatives: create a new account under the new address, or contact the store to reconcile order history. **Fixed:** *Gift wrap charges the configured fee, not the wrap variant's catalog price* — The gift-wrap selector displayed the wrap fee configured in the admin console, but checkout added the wrap to the order at the underlying catalog variant's price — when the two diverged, the customer was silently charged a different amount than the page showed. The cart now snapshots the configured fee onto the wrap line, so the displayed fee and the charged fee always agree. A regression test keeps the two amounts pinned together with deliberately different fee and catalog values. · *A gift wrap's per-order cap is enforced at the cart* — A wrap option's maximum-per-order value was validated, stored, and shown in the admin console but never enforced — any quantity could be applied. Applying a wrap beyond its cap is now rejected with a 422 and the cart page re-renders with a readable message; an application at the cap continues to succeed and the cart is left unchanged on rejection. · *The low-stock event is a registered webhook event* — Inventory low-stock alerts emitted `inventory.low_stock`, but the event was missing from the webhook event registry — endpoints subscribed to specific events never received it, and the admin endpoint form never offered it. The event is now registered, appears in the endpoint form's event checklist, and is delivered to endpoints that subscribe to it. Wildcard subscriptions, which already received it, are unchanged. · *Documented curl commands against the admin API work as written* — Every admin endpoint is bearer-key-gated JSON, and the documentation drives it with plain curl — but the bot guard's user-agent deny list refused automation clients on `/admin` before the key was ever checked, so the documented commands answered 403. The admin surface now runs the bot guard in tag mode: automation is recorded in the audit trail (`system.botguard.tag`) instead of refused, and the timing-safe bearer key remains the deciding check. A curl call with a valid key reaches the endpoint; one without a key is refused by the key check with a 401.
|
|
12
|
+
|
|
11
13
|
- v0.4.13 (2026-06-06) — **Security: a passkey can no longer be added to an existing account from the public register page, and the email sign-in link no longer reveals which addresses have accounts.** Two fixes to the account sign-in surface. The public passkey-registration endpoint reused an existing customer record when the submitted email already had an account, which let anyone who knew a registered email enroll their own authenticator onto that account and sign in as its owner. Registration now creates new accounts only: it refuses any email that already has an account — whether it signs in by passkey, by Google or Apple, or is a guest order awaiting a sign-in link — and directs the visitor to sign in instead. Separately, the email sign-in-link request did its account lookup, session creation, and email send before replying, so a registered address took measurably longer to answer than an unregistered one — a timing signal that revealed which addresses have accounts despite the identical on-screen confirmation. The reply is now sent first and the link is prepared and mailed afterward, so both cases answer at the same speed. **Security:** *Passkey registration can no longer enroll a credential onto someone else's account* — The public passkey-registration ceremony reused an existing customer record whenever the submitted email already had an account, then bound the registration challenge to that account — so a visitor who knew a registered email could complete the ceremony on their own device and be issued a sign-in session for the account's owner, without any proof of controlling the email. The path now creates new accounts only: it refuses any email that already has an account and binds no challenge to it, returning a clear "this email already has an account — sign in instead" response. The refusal covers every existing account, not only those with a passkey — a Google or Apple account and a guest order awaiting a sign-in link both have no passkey yet a real owner, and an attacker enrolling onto either would take it over just the same. An owner adding a second passkey continues to use the signed-in add-a-passkey flow, which has always required an active session and matches the challenge to the signed-in account; an owner who has no passkey, or whose first attempt was interrupted, signs in with the email link. · *The email sign-in link no longer leaks which addresses have accounts* — The "email me a sign-in link" request answered with the same confirmation whether or not the address matched an account, but it performed the account lookup, session creation, and email dispatch before replying when the address did match — so a registered address answered measurably more slowly than an unregistered one, a timing side channel that distinguished real accounts from non-accounts. The confirmation is now sent before any of that work; when the address matches an account, the sign-in link is prepared and mailed afterward, off the response path. A matched and an unmatched address now take the same time to answer, and the link still arrives for real accounts.
|
|
12
14
|
|
|
13
15
|
- v0.4.12 (2026-06-05) — **Blog posts render their Markdown, plural search queries match again, and the search-ranking metrics view gets the impression and click data it was built to read.** Four search-and-content fixes. Published blog posts were showing their raw Markdown source — headings, bold, and links rendered as literal text — even though the authoring screen has always promised Markdown; the edge now renders it through the same sanitized renderer the post primitive uses, so the live page matches the editor's promise. The query stemmer over-stripped plurals ending in a vowel plus "es" ("tees", "shoes", "does"), so those searches missed their synonym groups and matched unrelated products; it now strips those down to the right singular. The search-ranking event log finally has production writers — every search records an impression and every result click records a click against the active weight set, so the admin search-metrics view shows real click-through and conversion instead of an empty table. And the analytics search funnel now applies the same hygiene the autocomplete log does, so "Popular searches" and the analytics top-terms report agree. **Added:** *Search-ranking impressions and clicks are recorded, so the admin metrics view has data* — The search-ranking feature ships a weight-set metrics view that reads an event log to report click-through, conversion, and click-to-purchase per weight set — but nothing ever wrote those events, so the view was always empty. Two writers are now wired. Every search records one impression against the active weight set (a drop-silent, fire-and-forget write that never affects the search response). Every search result link carries a `?from=search` marker plus the query it was ranked for; when a shopper follows one to a product page, that page records a click against the active weight set. Both work with JavaScript off — the click signal is read server-side from the link, with no tracking beacon. Facet narrowing is recorded the same way, so the facet-usage rollup reflects real use. Purchase attribution is deliberately left out of this release: tying a completed order back to the search that led to it needs a session-to-order link the event schema doesn't carry, so the metrics view reports impressions, clicks, and the click-through ratio from real traffic while purchase counts and conversion stay at zero until that linkage ships. **Fixed:** *Blog posts render Markdown instead of showing the raw source* — A published post's body is authored in Markdown — the editor labels the field "Body (Markdown)" and the post primitive's renderer turns headings, lists, blockquotes, rules, inline code, bold, italic, and https-or-rooted links into HTML. But the edge-served public post page painted the body as plain escaped text, so a shopper saw literal `## Heading` and `**bold**` markers rather than formatted copy. The edge now renders the body through the same sanitized renderer the primitive uses, so the live page matches what the editor previews and the authoring screen promises. The security posture is unchanged: every operator-authored byte is HTML-escaped, raw HTML never passes through (any `<` lands as `<`), and link URLs are restricted to https or `/`-rooted paths — a `<script>` or `javascript:` link in a post body renders inert. · *Plural search queries ending in a vowel plus "es" match again* — The query stemmer tried its `-es` plural rule before its `-s` rule, so a word ending in a vowel plus "es" was over-stripped: "tees" became "te", "shoes" became "sho", "does" became "do". Two things broke as a result — the over-stripped term no longer matched its operator-curated synonym group (so a search for "tees" missed a "tee"/"t-shirt" grouping), and the truncated term fed a broad substring match that surfaced unrelated products. The stemmer now strips the full `-es` only when the stem ends in a sibilant ("boxes" → "box", "dishes" → "dish", "churches" → "church") and takes the bare `-s` otherwise ("tees" → "tee", "shoes" → "shoe", "does" → "doe"). The edge search mirror carries the identical rule, so the result is the same whether the page is served from the edge cache or the container. · *The analytics search funnel and the autocomplete log apply the same query hygiene* — Two search sinks recorded the typed query with different cleaning. The autocomplete query log stripped control bytes and lowercased before writing; the analytics funnel that feeds the top-search-terms report trimmed only — so a control byte could reach the analytics table raw, and the two reports counted differently. The storefront now normalizes the query once (strip control bytes, trim, lowercase) and hands the same value to both sinks. The analytics top-terms report also groups case-insensitively, so a term typed as "Hat" and "hat" collapses into one row — folding any mixed-case history written before this — matching how "Popular searches" has always counted. The consent gate on analytics recording is unchanged.
|
package/lib/asset-manifest.json
CHANGED
|
@@ -561,10 +561,21 @@ function globalRateLimitOpts() {
|
|
|
561
561
|
* are machine-to-machine and authenticate with the shared
|
|
562
562
|
* D1_BRIDGE_SECRET, so on those paths the secret gate — not a browser
|
|
563
563
|
* fingerprint — is the deciding check (see INTERNAL_BRIDGE_PATHS).
|
|
564
|
+
*
|
|
565
|
+
* `/admin` is skipped HERE but re-guarded in tag mode inside
|
|
566
|
+
* `mountRouteGuards`: every admin route is gated on the timing-safe
|
|
567
|
+
* bearer-key check, and the documented way to drive the admin JSON
|
|
568
|
+
* surface is curl — whose User-Agent is on the vendored deny list, and
|
|
569
|
+
* the UA check fires before auth is ever consulted. In block mode the
|
|
570
|
+
* README / onboarding curl examples answer 403 "Forbidden" instead of
|
|
571
|
+
* reaching the 401/200 bearer gate; in tag mode automation is audited
|
|
572
|
+
* (`system.botguard.tag` + `req.suspectedBot`) while the bearer key
|
|
573
|
+
* stays the deciding check. The regex matches `/admin` and
|
|
574
|
+
* `/admin/...` only — not other `/admin…`-prefixed names.
|
|
564
575
|
*/
|
|
565
576
|
function botGuardOpts() {
|
|
566
577
|
return {
|
|
567
|
-
skipPaths: INTERNAL_BRIDGE_PATHS.slice(),
|
|
578
|
+
skipPaths: INTERNAL_BRIDGE_PATHS.slice().concat([/^\/admin(\/|$)/]),
|
|
568
579
|
};
|
|
569
580
|
}
|
|
570
581
|
|
|
@@ -584,6 +595,22 @@ function _hasPrefix(pathname, prefixes) {
|
|
|
584
595
|
* @param r the blamejs Router passed to createApp's routes(r) callback.
|
|
585
596
|
*/
|
|
586
597
|
function mountRouteGuards(r) {
|
|
598
|
+
// --- bot-guard, tag mode, /admin only -------------------------------
|
|
599
|
+
//
|
|
600
|
+
// The app-level bot-guard (block mode) skips /admin — see
|
|
601
|
+
// botGuardOpts: curl is the documented admin client and the UA
|
|
602
|
+
// deny-list check fires before the bearer gate, so block mode turns
|
|
603
|
+
// every documented example into a 403. The surface still wants the
|
|
604
|
+
// signal, though: this tag-mode instance audits automation
|
|
605
|
+
// (system.botguard.tag + req.suspectedBot) without refusing it, and
|
|
606
|
+
// the timing-safe bearer key stays the deciding check.
|
|
607
|
+
var adminBotTag = b.middleware.botGuard({ mode: "tag" });
|
|
608
|
+
r.use(function adminBotTagGuard(req, res, next) {
|
|
609
|
+
var pathname = req.pathname || req.url || "/";
|
|
610
|
+
if (!/^\/admin(\/|$)/.test(pathname)) return next();
|
|
611
|
+
return adminBotTag(req, res, next);
|
|
612
|
+
});
|
|
613
|
+
|
|
587
614
|
// --- CSRF: double-submit token on authenticated state-changing POSTs ---
|
|
588
615
|
//
|
|
589
616
|
// The vendored createApp enforces CSRF by default; the entry points pass
|
package/lib/storefront.js
CHANGED
|
@@ -8085,9 +8085,18 @@ function _cartGiftBlock(opts) {
|
|
|
8085
8085
|
(w.wrap_sku === current ? " selected" : "") + ">" +
|
|
8086
8086
|
esc(String(w.title)) + " (" + esc(feeStr) + ")</option>";
|
|
8087
8087
|
}).join("");
|
|
8088
|
+
// Over-limit (or any rejected /cart/gift apply) renders an inline error
|
|
8089
|
+
// banner. The message carries the operator-authored wrap title, so it is
|
|
8090
|
+
// escaped at the sink — the cap value the route interpolates is a plain
|
|
8091
|
+
// integer, but escaping the whole string is the safe default. `role=alert`
|
|
8092
|
+
// so a screen reader announces the rejection.
|
|
8093
|
+
var giftError = (typeof opts.gift_error === "string" && opts.gift_error)
|
|
8094
|
+
? "<p class=\"cart-gift__error\" role=\"alert\">" + esc(opts.gift_error) + "</p>"
|
|
8095
|
+
: "";
|
|
8088
8096
|
return "<section class=\"cart-gift\">" +
|
|
8089
|
-
"<details class=\"cart-gift__details\"" + (current ? " open" : "") + ">" +
|
|
8097
|
+
"<details class=\"cart-gift__details\"" + (current || giftError ? " open" : "") + ">" +
|
|
8090
8098
|
"<summary class=\"cart-gift__summary\">Add a gift wrap</summary>" +
|
|
8099
|
+
giftError +
|
|
8091
8100
|
"<form method=\"post\" action=\"/cart/gift\" class=\"cart-gift__form\">" +
|
|
8092
8101
|
"<label class=\"form-field\"><span>Gift wrap</span>" +
|
|
8093
8102
|
"<select name=\"wrap_sku\">" + options + "</select>" +
|
|
@@ -10023,11 +10032,13 @@ function renderAddPaymentMethod(opts) {
|
|
|
10023
10032
|
// ---- profile edit ------------------------------------------------------
|
|
10024
10033
|
//
|
|
10025
10034
|
// Display-name edit for the signed-in customer. Email is stored hash-only
|
|
10026
|
-
// and is the OAuth account-linking
|
|
10027
|
-
//
|
|
10028
|
-
//
|
|
10029
|
-
//
|
|
10030
|
-
//
|
|
10035
|
+
// (a one-way hash, never the plaintext) and is the OAuth account-linking
|
|
10036
|
+
// key, so it is shown read-only and genuinely cannot be changed — there is
|
|
10037
|
+
// no readable address to edit. The field's hint tells the customer what to
|
|
10038
|
+
// do instead (sign in with the registered address; create a new account to
|
|
10039
|
+
// use a different one; contact support to move order history). The form is
|
|
10040
|
+
// a plain server-rendered POST with a PRG `?ok=updated` success notice,
|
|
10041
|
+
// matching the addresses pattern.
|
|
10031
10042
|
function renderProfile(opts) {
|
|
10032
10043
|
opts = opts || {};
|
|
10033
10044
|
var esc = b.template.escapeHtml;
|
|
@@ -10054,7 +10065,7 @@ function renderProfile(opts) {
|
|
|
10054
10065
|
"<div class=\"form-row\"><label class=\"form-field\"><span class=\"form-field__label\">Email</span>" +
|
|
10055
10066
|
"<input type=\"text\" value=\"Hidden for privacy — stored as a one-way hash\" disabled aria-describedby=\"email-note\"></label></div>" +
|
|
10056
10067
|
"<p id=\"email-note\" class=\"form-field__hint\">Your email address is never stored in readable form, so it can't be changed or shown here. " +
|
|
10057
|
-
"Sign in with the address you registered.</p>" +
|
|
10068
|
+
"Sign in with the address you registered. To use a different address, create a new account with it, or contact support if you need help moving your order history.</p>" +
|
|
10058
10069
|
"<div class=\"form-actions\"><button type=\"submit\" class=\"btn-primary\">Save changes</button> " +
|
|
10059
10070
|
"<a class=\"btn-ghost\" href=\"/account\">Cancel</a></div>" +
|
|
10060
10071
|
"</form>" +
|
|
@@ -13088,38 +13099,27 @@ function mount(router, deps) {
|
|
|
13088
13099
|
return res.end ? res.end(payload) : res.send(payload);
|
|
13089
13100
|
});
|
|
13090
13101
|
|
|
13091
|
-
|
|
13102
|
+
// Assemble the full cart-page render and emit it at `status`. Shared by
|
|
13103
|
+
// GET /cart and by POST /cart/gift's over-limit rejection (a 4xx that must
|
|
13104
|
+
// still show the customer their cart, with a gift error banner), so the two
|
|
13105
|
+
// surfaces never drift. `extraOpts` overlays render flags the caller knows
|
|
13106
|
+
// (the post-add banner, the coupon PRG notices, a gift_error message).
|
|
13107
|
+
async function _renderCartResponse(req, res, status, extraOpts) {
|
|
13108
|
+
extraOpts = extraOpts || {};
|
|
13092
13109
|
var ccy = await _currencyForReq(req);
|
|
13093
13110
|
var sid = _readSidCookie(req);
|
|
13094
|
-
// `?added=1` after a POST /cart/lines redirect — drives the
|
|
13095
|
-
// "Added to cart" status banner. Read from the parsed query when the
|
|
13096
|
-
// router populated it, else from the raw URL.
|
|
13097
|
-
var cartUrl = req.url ? new URL(req.url, "http://localhost") : null;
|
|
13098
|
-
var added = (req.query && req.query.added === "1") ||
|
|
13099
|
-
(cartUrl && cartUrl.searchParams.get("added") === "1") || false;
|
|
13100
|
-
// Coupon-entry PRG outcomes (set by POST /cart/coupon[/remove]). One of
|
|
13101
|
-
// applied / removed / err so the cart shows an inline notice. `?code_err`
|
|
13102
|
-
// carries no detail beyond "couldn't apply" — a uniform message, no
|
|
13103
|
-
// code-existence oracle.
|
|
13104
|
-
function _cartQp(name) {
|
|
13105
|
-
return (req.query && req.query[name] === "1") ||
|
|
13106
|
-
(cartUrl && cartUrl.searchParams.get(name) === "1") || false;
|
|
13107
|
-
}
|
|
13108
|
-
var codeApplied = _cartQp("code_applied");
|
|
13109
|
-
var codeRemoved = _cartQp("code_removed");
|
|
13110
|
-
var codeErr = _cartQp("code_err");
|
|
13111
13111
|
if (!sid) {
|
|
13112
|
-
return _send(res,
|
|
13112
|
+
return _send(res, status, renderCart(Object.assign({
|
|
13113
13113
|
lines: [], totals: { subtotal_minor: 0, grand_total_minor: 0, currency: "USD" },
|
|
13114
13114
|
shop_name: shopName, theme: theme,
|
|
13115
|
-
}, ccy)));
|
|
13115
|
+
}, ccy, extraOpts)));
|
|
13116
13116
|
}
|
|
13117
13117
|
var c = await deps.cart.bySession(sid);
|
|
13118
13118
|
if (!c) {
|
|
13119
|
-
return _send(res,
|
|
13119
|
+
return _send(res, status, renderCart(Object.assign({
|
|
13120
13120
|
lines: [], totals: { subtotal_minor: 0, grand_total_minor: 0, currency: "USD" },
|
|
13121
13121
|
shop_name: shopName, theme: theme,
|
|
13122
|
-
}, ccy)));
|
|
13122
|
+
}, ccy, extraOpts)));
|
|
13123
13123
|
}
|
|
13124
13124
|
var rawLines = await deps.cart.listLines(c.id);
|
|
13125
13125
|
// Reapply the active quantity-break for each line at its current
|
|
@@ -13197,7 +13197,7 @@ function mount(router, deps) {
|
|
|
13197
13197
|
// the primitive falls back to its weight-agnostic transit rows. Drop-
|
|
13198
13198
|
// silent → null, and the summary renders no estimate.
|
|
13199
13199
|
var cartEstimate = await _resolveDeliveryEstimate(req, { dest: estimateDest });
|
|
13200
|
-
_send(res,
|
|
13200
|
+
_send(res, status, renderCart(Object.assign({
|
|
13201
13201
|
lines: lines,
|
|
13202
13202
|
totals: totals,
|
|
13203
13203
|
totals_detail: totalsDetail,
|
|
@@ -13208,19 +13208,38 @@ function mount(router, deps) {
|
|
|
13208
13208
|
checkout_available: !!(deps.checkout && deps.order),
|
|
13209
13209
|
gift_wraps: giftWraps,
|
|
13210
13210
|
gift_wrap_in_cart: giftWrapInCart,
|
|
13211
|
-
added: added,
|
|
13212
13211
|
// Coupon entry: surfaced only when the discount engine is wired (the
|
|
13213
13212
|
// POST routes mount on the same condition). `applied_codes` echoes the
|
|
13214
13213
|
// typed codes so each gets a remove control; the *_notice flags drive
|
|
13215
13214
|
// the inline PRG banner.
|
|
13216
13215
|
coupon_enabled: !!(deps.autoDiscount && typeof deps.cart.listDiscountCodes === "function"),
|
|
13217
13216
|
applied_codes: appliedCodes,
|
|
13218
|
-
code_applied: codeApplied,
|
|
13219
|
-
code_removed: codeRemoved,
|
|
13220
|
-
code_err: codeErr,
|
|
13221
13217
|
shop_name: shopName,
|
|
13222
13218
|
theme: theme,
|
|
13223
|
-
}, ccy)));
|
|
13219
|
+
}, ccy, extraOpts)));
|
|
13220
|
+
}
|
|
13221
|
+
|
|
13222
|
+
router.get("/cart", async function (req, res) {
|
|
13223
|
+
// `?added=1` after a POST /cart/lines redirect — drives the
|
|
13224
|
+
// "Added to cart" status banner. Read from the parsed query when the
|
|
13225
|
+
// router populated it, else from the raw URL.
|
|
13226
|
+
var cartUrl = req.url ? new URL(req.url, "http://localhost") : null;
|
|
13227
|
+
var added = (req.query && req.query.added === "1") ||
|
|
13228
|
+
(cartUrl && cartUrl.searchParams.get("added") === "1") || false;
|
|
13229
|
+
// Coupon-entry PRG outcomes (set by POST /cart/coupon[/remove]). One of
|
|
13230
|
+
// applied / removed / err so the cart shows an inline notice. `?code_err`
|
|
13231
|
+
// carries no detail beyond "couldn't apply" — a uniform message, no
|
|
13232
|
+
// code-existence oracle.
|
|
13233
|
+
function _cartQp(name) {
|
|
13234
|
+
return (req.query && req.query[name] === "1") ||
|
|
13235
|
+
(cartUrl && cartUrl.searchParams.get(name) === "1") || false;
|
|
13236
|
+
}
|
|
13237
|
+
return await _renderCartResponse(req, res, 200, {
|
|
13238
|
+
added: added,
|
|
13239
|
+
code_applied: _cartQp("code_applied"),
|
|
13240
|
+
code_removed: _cartQp("code_removed"),
|
|
13241
|
+
code_err: _cartQp("code_err"),
|
|
13242
|
+
});
|
|
13224
13243
|
});
|
|
13225
13244
|
|
|
13226
13245
|
// ---- checkout flow -------------------------------------------------
|
|
@@ -15393,9 +15412,10 @@ function mount(router, deps) {
|
|
|
15393
15412
|
}
|
|
15394
15413
|
|
|
15395
15414
|
// Profile edit — display-name only. Email is hash-only + the OAuth
|
|
15396
|
-
// linking key, so
|
|
15397
|
-
//
|
|
15398
|
-
// ?ok=updated success notice, matching the addresses
|
|
15415
|
+
// linking key, so it genuinely can't be changed (no readable address to
|
|
15416
|
+
// edit); the form shows it read-only and the route never accepts an email
|
|
15417
|
+
// field. PRG with a ?ok=updated success notice, matching the addresses
|
|
15418
|
+
// pattern.
|
|
15399
15419
|
async function _renderProfilePage(req, res, auth, customer, notice, code) {
|
|
15400
15420
|
var cartCount = await _cartCountForReq(req);
|
|
15401
15421
|
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
@@ -19007,21 +19027,59 @@ function mount(router, deps) {
|
|
|
19007
19027
|
// a REAL cart line so its fee flows through pricing.totals and is charged
|
|
19008
19028
|
// by checkout.confirm (NEVER a post-commit hook — that would mis-charge).
|
|
19009
19029
|
// Selecting "No gift wrap" removes any wrap line. Selecting a wrap removes
|
|
19010
|
-
// any prior wrap line then adds the chosen one
|
|
19011
|
-
//
|
|
19012
|
-
//
|
|
19030
|
+
// any prior wrap line then adds the chosen one.
|
|
19031
|
+
//
|
|
19032
|
+
// Authoritative fee: the wrap line's price snapshot is the operator-
|
|
19033
|
+
// configured gift_wraps.fee_minor — NOT the wrap variant's catalog price.
|
|
19034
|
+
// fee_minor is the single source the admin sets and the cart selector
|
|
19035
|
+
// displays, so the charge must read it too (an explicit unit_amount_minor
|
|
19036
|
+
// snapshot on cart.addLine), or the displayed fee and the charged fee would
|
|
19037
|
+
// silently diverge whenever the catalog price differs from the wrap fee.
|
|
19038
|
+
//
|
|
19039
|
+
// Cap: gift_wraps.max_per_order bounds how many of that wrap one order may
|
|
19040
|
+
// carry. The request quantity (defensive reader, default 1) is checked
|
|
19041
|
+
// against it BEFORE any mutation — over the cap is rejected with a 422 and
|
|
19042
|
+
// the cart re-rendered with a customer-readable error, the cart unchanged.
|
|
19043
|
+
//
|
|
19044
|
+
// The message / recipient are collected at checkout (they need the order
|
|
19045
|
+
// id). Mounts only when the gift-options primitive is wired.
|
|
19013
19046
|
if (deps.giftOptions) {
|
|
19014
19047
|
router.post("/cart/gift", async function (req, res) {
|
|
19015
19048
|
var body = req.body || {};
|
|
19016
19049
|
var wrapSku = typeof body.wrap_sku === "string" ? body.wrap_sku.trim() : "";
|
|
19050
|
+
// Defensive request-shape reader: qty defaults to 1, and any non-
|
|
19051
|
+
// positive-integer field (blank, garbage, fractional, negative) falls
|
|
19052
|
+
// back to 1 rather than throwing — the cap check below is the only gate
|
|
19053
|
+
// that rejects, and it rejects on "too many", never on a malformed qty.
|
|
19054
|
+
var reqQty = 1;
|
|
19055
|
+
var rawQty = body.qty;
|
|
19056
|
+
if (rawQty != null && rawQty !== "") {
|
|
19057
|
+
var s = String(rawQty).trim();
|
|
19058
|
+
if (/^\d+$/.test(s)) {
|
|
19059
|
+
var n = Number(s);
|
|
19060
|
+
if (Number.isInteger(n) && n > 0) reqQty = n;
|
|
19061
|
+
}
|
|
19062
|
+
}
|
|
19017
19063
|
var resolved = await _getOrCreateCart(req, res, "USD");
|
|
19018
|
-
var
|
|
19064
|
+
var cart = resolved.cart;
|
|
19065
|
+
var cartId = cart.id;
|
|
19019
19066
|
try {
|
|
19020
19067
|
// Resolve the active wrap catalog so we know every wrap sku (to
|
|
19021
|
-
// remove a stale wrap line) and the selected wrap's cap.
|
|
19068
|
+
// remove a stale wrap line) and the selected wrap's fee + cap.
|
|
19022
19069
|
var wraps = await deps.giftOptions.listWraps({ active_only: true });
|
|
19023
19070
|
var wrapBySku = {};
|
|
19024
19071
|
for (var i = 0; i < wraps.length; i += 1) wrapBySku[wraps[i].wrap_sku] = wraps[i];
|
|
19072
|
+
var chosen = (wrapSku && wrapBySku[wrapSku]) ? wrapBySku[wrapSku] : null;
|
|
19073
|
+
// Enforce max_per_order before mutating anything. A null cap means
|
|
19074
|
+
// "no limit"; a set cap rejects a request for more wraps than the
|
|
19075
|
+
// order may carry. The cart is left untouched so the customer can
|
|
19076
|
+
// resubmit with a smaller quantity.
|
|
19077
|
+
if (chosen && chosen.max_per_order != null && reqQty > chosen.max_per_order) {
|
|
19078
|
+
return await _renderCartResponse(req, res, 422, {
|
|
19079
|
+
gift_error: "You can add at most " + chosen.max_per_order + " of “" +
|
|
19080
|
+
chosen.title + "” to one order. Please choose a smaller quantity.",
|
|
19081
|
+
});
|
|
19082
|
+
}
|
|
19025
19083
|
// Remove any existing wrap line first (every active wrap's sku).
|
|
19026
19084
|
var existingLines = await deps.cart.listLines(cartId);
|
|
19027
19085
|
for (var li = 0; li < existingLines.length; li += 1) {
|
|
@@ -19030,13 +19088,19 @@ function mount(router, deps) {
|
|
|
19030
19088
|
}
|
|
19031
19089
|
}
|
|
19032
19090
|
// Add the selected wrap (when one was chosen + it's a real active
|
|
19033
|
-
// wrap). The wrap_sku is a real catalog variant
|
|
19034
|
-
// id
|
|
19035
|
-
//
|
|
19036
|
-
|
|
19091
|
+
// wrap). The wrap_sku is a real catalog variant — resolve its variant
|
|
19092
|
+
// id, then add it as a line whose unit price is the AUTHORITATIVE
|
|
19093
|
+
// gift_wraps.fee_minor (the configured + displayed wrap fee), pinned
|
|
19094
|
+
// via an explicit price snapshot so the charge matches the display.
|
|
19095
|
+
if (chosen) {
|
|
19037
19096
|
var variant = await deps.catalog.variants.bySku(wrapSku);
|
|
19038
19097
|
if (variant) {
|
|
19039
|
-
await deps.cart.addLine(cartId, {
|
|
19098
|
+
await deps.cart.addLine(cartId, {
|
|
19099
|
+
variant_id: variant.id,
|
|
19100
|
+
qty: reqQty,
|
|
19101
|
+
unit_amount_minor: chosen.fee_minor,
|
|
19102
|
+
unit_currency: cart.currency || "USD",
|
|
19103
|
+
});
|
|
19040
19104
|
}
|
|
19041
19105
|
}
|
|
19042
19106
|
} catch (e) {
|
package/lib/webhooks.js
CHANGED
|
@@ -18,8 +18,9 @@
|
|
|
18
18
|
* `events` is either the wildcard `*` or a comma-separated allowlist
|
|
19
19
|
* of recognized event types — `order.mark_paid`,
|
|
20
20
|
* `order.start_fulfillment`, `order.mark_shipped`,
|
|
21
|
-
* `order.mark_delivered`, `order.cancel`, `order.refund
|
|
22
|
-
* only receive event types they
|
|
21
|
+
* `order.mark_delivered`, `order.cancel`, `order.refund`,
|
|
22
|
+
* `inventory.low_stock`. Endpoints only receive event types they
|
|
23
|
+
* subscribed to.
|
|
23
24
|
*
|
|
24
25
|
* `send(eventType, payload)` is fire-and-flag-failure: every active
|
|
25
26
|
* endpoint subscribed to the event gets a delivery row before the
|
|
@@ -65,6 +66,7 @@ var KNOWN_EVENTS = Object.freeze([
|
|
|
65
66
|
"order.mark_delivered",
|
|
66
67
|
"order.cancel",
|
|
67
68
|
"order.refund",
|
|
69
|
+
"inventory.low_stock",
|
|
68
70
|
]);
|
|
69
71
|
|
|
70
72
|
var SECRET_BYTES = 32;
|
package/package.json
CHANGED