@blamejs/blamejs-shop 0.4.13 → 0.4.15

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.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.13",
2
+ "version": "0.4.15",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-6k53cvkRrxMgmeStLIoLjVXZQHqIJgTmv1Izd8TYhh1HOC4POgE6GCvx1bsalyEP",
package/lib/catalog.js CHANGED
@@ -11,7 +11,7 @@
11
11
  * - `variants` — create, get, listForProduct, update, delete
12
12
  * - `prices` — set (versioned), current, history
13
13
  * - `inventory` — create, get, list, hold, decrement, release,
14
- * restock, setThreshold, checkLowStock
14
+ * restock, adjustOnHand, setThreshold, checkLowStock
15
15
  * - `media` — attach, get, listForProduct, listForVariant, delete,
16
16
  * reorder, setPrimary
17
17
  *
@@ -706,6 +706,56 @@ function _inventoryModule(query, opts) {
706
706
  return await this.get(sku);
707
707
  },
708
708
 
709
+ // Signed-delta adjustment of the storefront aggregate, fired by the
710
+ // inventory-ops back-office when a per-location move changes the
711
+ // total a single-bucket storefront sells against (a receive credits
712
+ // the aggregate, a write-off / shrinkage debits it). `restock` only
713
+ // grows on-hand and `decrement` only consumes a matching hold;
714
+ // neither expresses "the warehouse count just dropped by 5 because a
715
+ // pallet was damaged." A positive delta is an unconditional credit.
716
+ // A negative delta is an atomic conditional UPDATE — the
717
+ // `stock_on_hand - stock_held >= need` guard refuses (zero rows →
718
+ // `{ adjusted: false }`) when the debit would eat into stock that is
719
+ // already held for in-flight orders, so a write-off can never
720
+ // oversell a paid-but-unfulfilled line. Returns `{ adjusted, sku,
721
+ // delta }`; `null` when the SKU has no inventory row at all
722
+ // (un-tracked SKUs are unlimited everywhere in the storefront, so an
723
+ // aggregate adjustment simply doesn't apply). Fires the low-stock
724
+ // observer on success so a debit that crosses the threshold alerts.
725
+ adjustOnHand: async function (sku, delta) {
726
+ _sku(sku);
727
+ if (!Number.isInteger(delta) || delta === 0) {
728
+ throw new TypeError("catalog.inventory.adjustOnHand: delta must be a non-zero integer");
729
+ }
730
+ var ts = _now();
731
+ if (delta > 0) {
732
+ var rUp = await query(
733
+ "UPDATE inventory SET stock_on_hand = stock_on_hand + ?1, updated_at = ?2 WHERE sku = ?3",
734
+ [delta, ts, sku],
735
+ );
736
+ if (rUp.rowCount === 0) return null;
737
+ await _afterMutation(sku);
738
+ return { adjusted: true, sku: sku, delta: delta };
739
+ }
740
+ var need = -delta;
741
+ var rDown = await query(
742
+ "UPDATE inventory SET stock_on_hand = stock_on_hand - ?1, updated_at = ?2 " +
743
+ "WHERE sku = ?3 AND (stock_on_hand - stock_held) >= ?1",
744
+ [need, ts, sku],
745
+ );
746
+ if (rDown.rowCount === 1) {
747
+ await _afterMutation(sku);
748
+ return { adjusted: true, sku: sku, delta: delta };
749
+ }
750
+ // Zero rows: either no inventory row (un-tracked SKU) or the debit
751
+ // would eat into held stock. Distinguish so the caller can let an
752
+ // un-tracked SKU through while refusing a tracked-but-insufficient
753
+ // one.
754
+ var existing = await this.get(sku);
755
+ if (!existing) return null;
756
+ return { adjusted: false, sku: sku, delta: delta };
757
+ },
758
+
709
759
  release: async function (sku, qty) {
710
760
  _sku(sku);
711
761
  _positiveInt(qty, "qty");
@@ -32,12 +32,20 @@
32
32
  * duplicate code, invalid type, etc).
33
33
  * listLocations / getLocation / updateLocation / deactivateLocation
34
34
  * — operator CRUD over the location set.
35
- * setStock — absolute set (overwrite).
36
- * adjustStock — relative delta with audit row.
37
- * transferStock atomic two-row write moving qty
38
- * from one location to another; if
39
- * the source doesn't have enough, the
40
- * whole transfer refuses (no money
35
+ * setStock — absolute set (overwrite) via an
36
+ * atomic upsert.
37
+ * adjustStock relative delta with audit row. Debits
38
+ * run as a single conditional UPDATE
39
+ * (`quantity >= need`); credits as an
40
+ * upsert. No read-then-write window — a
41
+ * debit that would drive the row below
42
+ * zero matches no rows and refuses.
43
+ * transferStock — moves qty from one location to
44
+ * another; the source debit is the same
45
+ * conditional UPDATE guard, so a transfer
46
+ * racing a checkout debit (or another
47
+ * transfer) for the last unit can't
48
+ * oversell — the loser refuses (no money
41
49
  * created, no row half-committed).
42
50
  * stockForSku — `{ total, by_location: [...] }`.
43
51
  * totalForSku — sum across every location.
@@ -521,20 +529,21 @@ function create(opts) {
521
529
  throw new TypeError("inventory-locations.setStock: location_code " +
522
530
  JSON.stringify(input.location_code) + " not found");
523
531
  }
532
+ // Absolute set is last-writer-wins by contract — the operator is
533
+ // declaring the authoritative count, not applying a relative
534
+ // change. The prior value is read only to record the signed
535
+ // delta on the audit row; the upsert itself is a single atomic
536
+ // statement so a first-touch insert and an overwrite never
537
+ // collide on the composite PK.
524
538
  var prev = await _getStockRow(input.sku, input.location_code);
525
539
  var prevQty = prev ? prev.quantity : 0;
526
540
  var ts = _now();
527
- if (prev) {
528
- await query(
529
- "UPDATE inventory_stock SET quantity = ?1, updated_at = ?2 WHERE sku = ?3 AND location_code = ?4",
530
- [input.quantity, ts, input.sku, input.location_code],
531
- );
532
- } else {
533
- await query(
534
- "INSERT INTO inventory_stock (sku, location_code, quantity, updated_at) VALUES (?1, ?2, ?3, ?4)",
535
- [input.sku, input.location_code, input.quantity, ts],
536
- );
537
- }
541
+ await query(
542
+ "INSERT INTO inventory_stock (sku, location_code, quantity, updated_at) " +
543
+ "VALUES (?1, ?2, ?3, ?4) " +
544
+ "ON CONFLICT(sku, location_code) DO UPDATE SET quantity = ?3, updated_at = ?4",
545
+ [input.sku, input.location_code, input.quantity, ts],
546
+ );
538
547
  var delta = input.quantity - prevQty;
539
548
  await _audit(input.sku, input.location_code, delta, _reason(input.reason));
540
549
  return { sku: input.sku, location_code: input.location_code, quantity: input.quantity, delta: delta };
@@ -545,6 +554,18 @@ function create(opts) {
545
554
  // row below zero refuses the whole operation. Inserts a row
546
555
  // at quantity 0 + delta if the (sku, location_code) pair has
547
556
  // never been touched before (and delta is positive).
557
+ //
558
+ // Concurrency: the negative-delta path is a single atomic
559
+ // conditional UPDATE — `SET quantity = quantity + delta WHERE
560
+ // quantity + delta >= 0` — mirroring the catalog hold/decrement
561
+ // guards. D1 evaluates the predicate and applies the write inside
562
+ // one statement, so two racing debits against the last unit can't
563
+ // both succeed: the second racer's predicate sees the first's
564
+ // decrement and matches zero rows. The positive-delta path is an
565
+ // upsert (ON CONFLICT DO UPDATE) so a credit and a fresh-row
566
+ // insert never collide. Both paths re-read the post-write quantity
567
+ // for the audit row + return value rather than trusting a stale
568
+ // pre-read.
548
569
  adjustStock: async function (input) {
549
570
  if (!input || typeof input !== "object") {
550
571
  throw new TypeError("inventory-locations.adjustStock: input object required");
@@ -560,28 +581,42 @@ function create(opts) {
560
581
  throw new TypeError("inventory-locations.adjustStock: location_code " +
561
582
  JSON.stringify(input.location_code) + " not found");
562
583
  }
563
- var prev = await _getStockRow(input.sku, input.location_code);
564
- var prevQty = prev ? prev.quantity : 0;
565
- var next = prevQty + input.delta;
566
- if (next < 0) {
567
- throw new TypeError("inventory-locations.adjustStock: delta " + input.delta +
568
- " would drive stock below zero (current=" + prevQty + ", sku=" + input.sku +
569
- ", location=" + input.location_code + ")");
570
- }
571
584
  var ts = _now();
572
- if (prev) {
585
+ if (input.delta > 0) {
586
+ // Credit: upsert in one atomic statement. A concurrent credit
587
+ // + first-touch insert serialize on the composite PK.
573
588
  await query(
574
- "UPDATE inventory_stock SET quantity = ?1, updated_at = ?2 WHERE sku = ?3 AND location_code = ?4",
575
- [next, ts, input.sku, input.location_code],
589
+ "INSERT INTO inventory_stock (sku, location_code, quantity, updated_at) " +
590
+ "VALUES (?1, ?2, ?3, ?4) " +
591
+ "ON CONFLICT(sku, location_code) DO UPDATE SET " +
592
+ "quantity = quantity + ?3, updated_at = ?4",
593
+ [input.sku, input.location_code, input.delta, ts],
576
594
  );
577
595
  } else {
578
- await query(
579
- "INSERT INTO inventory_stock (sku, location_code, quantity, updated_at) VALUES (?1, ?2, ?3, ?4)",
580
- [input.sku, input.location_code, next, ts],
596
+ // Debit: atomic conditional UPDATE. The WHERE predicate is the
597
+ // serialization point it refuses the write (zero rows) when
598
+ // the shelf lacks enough to cover the debit, so the row never
599
+ // goes negative even under concurrent debits. A SKU with no
600
+ // row at this location can't be debited (no negative
601
+ // first-touch); zero rows there is the insufficient case too.
602
+ var need = -input.delta;
603
+ var r = await query(
604
+ "UPDATE inventory_stock SET quantity = quantity + ?1, updated_at = ?2 " +
605
+ "WHERE sku = ?3 AND location_code = ?4 AND quantity >= ?5",
606
+ [input.delta, ts, input.sku, input.location_code, need],
581
607
  );
608
+ if (r.rowCount === 0) {
609
+ var cur = await _getStockRow(input.sku, input.location_code);
610
+ var have = cur ? cur.quantity : 0;
611
+ throw new TypeError("inventory-locations.adjustStock: delta " + input.delta +
612
+ " would drive stock below zero (current=" + have + ", sku=" + input.sku +
613
+ ", location=" + input.location_code + ")");
614
+ }
582
615
  }
616
+ var after = await _getStockRow(input.sku, input.location_code);
617
+ var nextQty = after ? after.quantity : 0;
583
618
  await _audit(input.sku, input.location_code, input.delta, _reason(input.reason));
584
- return { sku: input.sku, location_code: input.location_code, quantity: next, delta: input.delta };
619
+ return { sku: input.sku, location_code: input.location_code, quantity: nextQty, delta: input.delta };
585
620
  },
586
621
 
587
622
  // Atomic two-row move. Reads the source quantity; if
@@ -612,44 +647,44 @@ function create(opts) {
612
647
  JSON.stringify(input.to_location) + " not found");
613
648
  }
614
649
 
615
- var fromRow = await _getStockRow(input.sku, input.from_location);
616
- var fromQty = fromRow ? fromRow.quantity : 0;
617
- if (fromQty < input.quantity) {
618
- throw new TypeError("inventory-locations.transferStock: insufficient stock at " +
619
- input.from_location + " (have " + fromQty + ", need " + input.quantity + ")");
620
- }
621
- var toRow = await _getStockRow(input.sku, input.to_location);
622
- var toQty = toRow ? toRow.quantity : 0;
623
-
624
650
  var ts = _now();
625
- // Decrement source first. If the destination upsert below
626
- // fails for any reason, the rollback path re-increments the
627
- // source so the invariant (total quantity unchanged) holds.
628
- await query(
629
- "UPDATE inventory_stock SET quantity = ?1, updated_at = ?2 WHERE sku = ?3 AND location_code = ?4",
630
- [fromQty - input.quantity, ts, input.sku, input.from_location],
651
+ // Debit the source with a single atomic conditional UPDATE. The
652
+ // `quantity >= need` predicate is the serialization point: two
653
+ // racing transfers (or a transfer racing a checkout debit)
654
+ // against the last unit can't both succeed — the second sees the
655
+ // first's decrement and matches zero rows, which we surface as
656
+ // the insufficient-stock refusal. No read-then-write window.
657
+ var srcDebit = await query(
658
+ "UPDATE inventory_stock SET quantity = quantity - ?1, updated_at = ?2 " +
659
+ "WHERE sku = ?3 AND location_code = ?4 AND quantity >= ?1",
660
+ [input.quantity, ts, input.sku, input.from_location],
631
661
  );
662
+ if (srcDebit.rowCount === 0) {
663
+ var fromRow = await _getStockRow(input.sku, input.from_location);
664
+ var fromHave = fromRow ? fromRow.quantity : 0;
665
+ throw new TypeError("inventory-locations.transferStock: insufficient stock at " +
666
+ input.from_location + " (have " + fromHave + ", need " + input.quantity + ")");
667
+ }
632
668
  try {
633
- if (toRow) {
634
- await query(
635
- "UPDATE inventory_stock SET quantity = ?1, updated_at = ?2 WHERE sku = ?3 AND location_code = ?4",
636
- [toQty + input.quantity, ts, input.sku, input.to_location],
637
- );
638
- } else {
639
- await query(
640
- "INSERT INTO inventory_stock (sku, location_code, quantity, updated_at) VALUES (?1, ?2, ?3, ?4)",
641
- [input.sku, input.to_location, input.quantity, ts],
642
- );
643
- }
669
+ // Credit the destination via an atomic upsert. A concurrent
670
+ // credit + first-touch insert serialize on the composite PK.
671
+ await query(
672
+ "INSERT INTO inventory_stock (sku, location_code, quantity, updated_at) " +
673
+ "VALUES (?1, ?2, ?3, ?4) " +
674
+ "ON CONFLICT(sku, location_code) DO UPDATE SET " +
675
+ "quantity = quantity + ?3, updated_at = ?4",
676
+ [input.sku, input.to_location, input.quantity, ts],
677
+ );
644
678
  } catch (e) {
645
- // Best-effort compensating restore on the source so the
646
- // pair stays balanced. If THIS write also fails the
647
- // operator will see two adjacent audit rows with no
648
- // counterpart the caller can replay or reconcile.
679
+ // Best-effort compensating restore on the source so the pair
680
+ // stays balanced. The restore is itself an atomic increment
681
+ // (no read-then-write), so it can't clobber a concurrent
682
+ // mutation that landed between the debit and this catch.
649
683
  try {
650
684
  await query(
651
- "UPDATE inventory_stock SET quantity = ?1, updated_at = ?2 WHERE sku = ?3 AND location_code = ?4",
652
- [fromQty, _now(), input.sku, input.from_location],
685
+ "UPDATE inventory_stock SET quantity = quantity + ?1, updated_at = ?2 " +
686
+ "WHERE sku = ?3 AND location_code = ?4",
687
+ [input.quantity, _now(), input.sku, input.from_location],
653
688
  );
654
689
  } catch (_e2) { /* drop-silent — the original error is what the caller needs to fix */ }
655
690
  throw e;
@@ -659,13 +694,15 @@ function create(opts) {
659
694
  await _audit(input.sku, input.from_location, -input.quantity, reason);
660
695
  await _audit(input.sku, input.to_location, input.quantity, reason);
661
696
 
697
+ var fromAfterRow = await _getStockRow(input.sku, input.from_location);
698
+ var toAfterRow = await _getStockRow(input.sku, input.to_location);
662
699
  return {
663
700
  sku: input.sku,
664
701
  from_location: input.from_location,
665
702
  to_location: input.to_location,
666
703
  quantity: input.quantity,
667
- from_after: fromQty - input.quantity,
668
- to_after: toQty + input.quantity,
704
+ from_after: fromAfterRow ? fromAfterRow.quantity : 0,
705
+ to_after: toAfterRow ? toAfterRow.quantity : 0,
669
706
  };
670
707
  },
671
708
 
@@ -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.15",
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": {