@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 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 `&lt;`), 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.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.13",
2
+ "version": "0.4.14",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-6k53cvkRrxMgmeStLIoLjVXZQHqIJgTmv1Izd8TYhh1HOC4POgE6GCvx1bsalyEP",
@@ -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 key, so it's shown read-only (masked)
10027
- // and cannot be changed here the primitive refuses an email patch until
10028
- // a verification ceremony exists. The form is a plain server-rendered
10029
- // POST with a PRG `?ok=updated` success notice, matching the addresses
10030
- // pattern.
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
- router.get("/cart", async function (req, res) {
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, 200, renderCart(Object.assign({
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, 200, renderCart(Object.assign({
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, 200, renderCart(Object.assign({
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 the primitive refuses an email change without a
15397
- // verification ceremony; the form shows it read-only. PRG with a
15398
- // ?ok=updated success notice, matching the addresses pattern.
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 (qty 1, capped by
19011
- // max_per_order). The message / recipient are collected at checkout (they
19012
- // need the order id). Mounts only when the gift-options primitive is wired.
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 cartId = resolved.cart.id;
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; resolve its variant
19034
- // id and add it as a line the price snapshot comes from the catalog
19035
- // price, so the fee is charged through the normal quote path.
19036
- if (wrapSku && wrapBySku[wrapSku]) {
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, { variant_id: variant.id, qty: 1 });
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`. Endpoints
22
- * only receive event types they subscribed to.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.13",
3
+ "version": "0.4.14",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {