@blamejs/blamejs-shop 0.3.20 → 0.3.21

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.3.x
10
10
 
11
+ - v0.3.21 (2026-05-30) — **Customers can cancel an order from their account before it ships.** A signed-in customer can now cancel one of their own orders from the order page, as long as it has not started fulfillment. The Cancel control appears only while the order is still cancellable — a paid or pending order — and is absent once the order is fulfilling, shipped, delivered, already cancelled, or refunded. The action is tied to the signed-in customer, so a customer can only cancel their own order. Note: cancelling a paid order changes its status to cancelled but does not by itself refund the charge — issue the refund from the admin console as usual; a customer-initiated cancellation of a paid order is your signal to do so. **Added:** *Cancel an unfulfilled order from the account* — The order page now shows a Cancel button while an order is still in a pre-fulfillment state (paid or pending). Cancelling moves the order to cancelled and shows a confirmation; the control does not appear once an order is fulfilling, shipped, delivered, cancelled, or refunded, and a customer can only cancel their own order. A paid-order cancellation updates the status but does not automatically refund the captured payment — refund it from the console; treat a customer cancellation of a paid order as the prompt to do so.
12
+
11
13
  - v0.3.20 (2026-05-30) — **Served media carries hardened headers, primary-image changes are product-scoped, and image-by-URL imports are size-capped.** Three defense-in-depth fixes on product media. Media assets served from storage now carry X-Content-Type-Options: nosniff and Cross-Origin-Resource-Policy: same-origin, and SVG files additionally carry a content policy that prevents script from running when the file is opened directly while still rendering normally inside a product image. Setting a product's primary image now verifies the image actually belongs to the product named in the request rather than acting on the image alone. And importing a product image by URL now refuses a response larger than the same 10 MiB limit the file-upload path enforces, instead of buffering an unbounded download. **Changed:** *Setting the primary image is product-scoped* — The endpoint that makes an image a product's primary (hero) now verifies the image belongs to the product in the request path and returns not-found otherwise, instead of acting on the image regardless of the product named. A request whose product and image don't match is now rejected. · *Vendored runtime updated to v0.14.9* — The vendored blamejs runtime is updated to v0.14.9, a documentation-path and source-comment fix with no API, wire-format, or behavior changes. No operator action is required. **Security:** *Hardened response headers on served media* — Media served from storage now sets X-Content-Type-Options: nosniff and Cross-Origin-Resource-Policy: same-origin, so a mistyped or hostile upload can't be content-sniffed into something executable or embedded cross-origin. An SVG additionally carries a default-src 'none' sandbox content-security policy, so opening an SVG URL directly cannot run script (it still renders inside an img tag). This sits behind the existing SVG-upload sanitizer as a second layer. · *Importing an image by URL is size-capped* — Importing a product image from a URL now refuses a response larger than 10 MiB — the same limit a direct file upload uses — returning a clear error instead of buffering an unbounded download.
12
14
 
13
15
  - v0.3.19 (2026-05-30) — **Edit announcement bars, automatic-discount terms, and subscription plans from the console.** Three admin entities could be created but only partly edited from the console — the rest of their fields were reachable only through the API, so changing them meant archive-and-recreate or a manual API call. Each now has a detail screen with the full edit form. An announcement bar can have its message, links, schedule, audience, and dismissibility changed in place. An automatic discount can have its actual terms changed — the amount, percentage, threshold, or buy-x-get-y values — not just its title, priority, and on/off state. A subscription plan can have its price, billing interval, trial length, active state, and variant edited. Create and archive behavior is unchanged. **Fixed:** *Announcement bars are editable in place* — A detail screen now lets you change an announcement's message, link, theme, audience, start/end schedule, and whether it can be dismissed — instead of archiving it and creating a new one (which lost the slug and any recorded dismissal state). · *Automatic-discount terms are editable from the console* — The discount detail screen now edits the rule's trigger and value — the amount off, percent off, cart threshold, or buy-x-get-y terms — which previously could only be changed through the API. The inline priority and on/off controls are unchanged. · *Subscription plans have an edit screen* — A subscription plan's price, billing interval count, trial length, active state, and variant can now be edited from a detail screen. The Stripe-bound fields (the price id, interval unit, and currency) remain read-only, as they were always immutable.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.3.20",
2
+ "version": "0.3.21",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-1SIn6oAf1DjECbRfKENZasdKHJiywGdXR58wn0hsFGcdVHzUmvfgMkEz5ANIAZJ3",
package/lib/storefront.js CHANGED
@@ -4569,6 +4569,20 @@ function _orderEligibleForReorder(status) {
4569
4569
  return status !== "pending";
4570
4570
  }
4571
4571
 
4572
+ // Cancel is offered only while the order is still pre-fulfillment: the
4573
+ // order FSM (lib/order.js) accepts the `cancel` event from `pending`
4574
+ // (awaiting capture) and `paid` (captured, not yet picked) only. Once
4575
+ // the warehouse starts fulfilling — and through shipped / delivered —
4576
+ // the order is no longer the customer's to cancel; the terminal off-ramps
4577
+ // (cancelled / refunded) have no cancel edge either. Keeping this in lock-
4578
+ // step with the FSM's cancel edges means the button never offers a
4579
+ // transition the primitive would refuse. A cancel on a `paid` order does
4580
+ // NOT void the captured charge — the FSM only moves the status — so a paid
4581
+ // cancel leaves the operator to issue the refund from the console.
4582
+ function _orderEligibleForCancel(status) {
4583
+ return status === "pending" || status === "paid";
4584
+ }
4585
+
4572
4586
  // Render the lifecycle timeline. Every step up to and including the
4573
4587
  // current status is marked done; the current step is also marked
4574
4588
  // current. A terminal off-ramp (refunded / cancelled) collapses the rail
@@ -4669,6 +4683,12 @@ function _orderActionsBlock(o) {
4669
4683
  btns.push(
4670
4684
  "<a class=\"btn-secondary order-action\" href=\"/account/orders/" + esc(String(o.id)) + "/return\">Request a return</a>");
4671
4685
  }
4686
+ if (_orderEligibleForCancel(o.status)) {
4687
+ btns.push(
4688
+ "<form class=\"order-action\" method=\"post\" action=\"/orders/" + esc(String(o.id)) + "/cancel\">" +
4689
+ "<button type=\"submit\" class=\"btn-ghost\">Cancel order</button>" +
4690
+ "</form>");
4691
+ }
4672
4692
  if (!btns.length) return "";
4673
4693
  return "<div class=\"order-page__actions\">" + btns.join("") + "</div>";
4674
4694
  }
@@ -4733,6 +4753,7 @@ function renderOrder(opts) {
4733
4753
  actions_html: actionsHtml,
4734
4754
  can_return: _orderEligibleForReturn(o.status),
4735
4755
  can_reorder: _orderEligibleForReorder(o.status),
4756
+ can_cancel: _orderEligibleForCancel(o.status),
4736
4757
  recommendations: recs,
4737
4758
  has_recommendations: recs.length > 0,
4738
4759
  asset_css_main: opts.theme.assetUrl("css/main.css"),
@@ -4759,6 +4780,14 @@ function renderOrder(opts) {
4759
4780
  var reorderNotice = opts.reordered
4760
4781
  ? "<p class=\"form-notice form-notice--ok\" role=\"status\">Items from this order were added to your cart. <a href=\"/cart\">View cart →</a></p>"
4761
4782
  : "";
4783
+ // Confirmation banner after a successful cancel (the POST redirects to
4784
+ // ?cancelled=1). A paid order's captured charge is not auto-voided by the
4785
+ // cancel — the refund is the operator's call from the console — so the
4786
+ // copy stays neutral ("cancelled") rather than promising a refund the
4787
+ // status transition didn't perform.
4788
+ var cancelNotice = opts.cancelled
4789
+ ? "<p class=\"form-notice form-notice--ok\" role=\"status\">This order has been cancelled.</p>"
4790
+ : "";
4762
4791
  var body = _render(ORDER_PAGE, {
4763
4792
  order_id: o.id,
4764
4793
  status: o.status,
@@ -4768,7 +4797,7 @@ function renderOrder(opts) {
4768
4797
  shipping: shipping,
4769
4798
  total: total,
4770
4799
  }).replace("RAW_LINES", rows)
4771
- .replace("RAW_REORDER_NOTICE", reorderNotice)
4800
+ .replace("RAW_REORDER_NOTICE", reorderNotice + cancelNotice)
4772
4801
  .replace("RAW_ORDER_TIMELINE", timelineHtml)
4773
4802
  .replace("RAW_ORDER_TRACKING", trackingHtml)
4774
4803
  .replace("RAW_ORDER_ACTIONS", actionsHtml)
@@ -8557,6 +8586,7 @@ function mount(router, deps) {
8557
8586
  recommendations: recommendations,
8558
8587
  shipments: shipments,
8559
8588
  reordered: ordUrl ? ordUrl.searchParams.get("reordered") === "1" : false,
8589
+ cancelled: ordUrl ? ordUrl.searchParams.get("cancelled") === "1" : false,
8560
8590
  shop_name: shopName,
8561
8591
  theme: theme,
8562
8592
  }));
@@ -10895,6 +10925,68 @@ function mount(router, deps) {
10895
10925
  res.setHeader && res.setHeader("location", "/orders/" + encodeURIComponent(o.id) + "?reordered=1");
10896
10926
  return res.end ? res.end() : res.send("");
10897
10927
  });
10928
+
10929
+ // POST /orders/:id/cancel — customer-initiated cancellation of an
10930
+ // order that hasn't been fulfilled yet. The order FSM (lib/order.js)
10931
+ // accepts the `cancel` event from `pending` and `paid` only; this
10932
+ // route gates on the same eligibility (_orderEligibleForCancel) so a
10933
+ // shipped / delivered / already-cancelled / refunded order can't be
10934
+ // cancelled here. Two refusals guard against IDOR: the order must
10935
+ // belong to the signed-in customer (a foreign or guest-owned order is
10936
+ // a clean 404, never acted on and never leaked), and only then is the
10937
+ // transition attempted. A cancel the FSM still refuses (a status that
10938
+ // raced past `paid` between the page render and the POST) maps to a
10939
+ // clean redirect back to the order rather than a 500. Cancelling a
10940
+ // `paid` order moves the status only — the captured charge is NOT
10941
+ // auto-voided by the transition, so the refund remains the operator's
10942
+ // action from the console.
10943
+ router.post("/orders/:order_id/cancel", async function (req, res) {
10944
+ var orderId = req.params && req.params.order_id;
10945
+ // Cancel is a logged-in-customer action — resolve the session first
10946
+ // so an unauthenticated POST goes to login, never near the order.
10947
+ var cancelAuth = _currentCustomerEnv(req);
10948
+ if (!cancelAuth) {
10949
+ res.status(303); res.setHeader && res.setHeader("location", "/account/login");
10950
+ return res.end ? res.end() : res.send("");
10951
+ }
10952
+ var o;
10953
+ try { o = orderId ? await deps.order.get(orderId) : null; }
10954
+ catch (e) { if (e instanceof TypeError) { o = null; } else throw e; }
10955
+ // Ownership gate against IDOR: a missing order, a malformed id, an
10956
+ // order owned by another customer, OR a guest order with no owner
10957
+ // all 404 — the cancel never touches an order the caller doesn't own.
10958
+ if (!o || !o.customer_id || o.customer_id !== cancelAuth.customer_id) {
10959
+ return _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
10960
+ }
10961
+ // Eligibility gate mirrors the FSM's cancel edges (pending | paid).
10962
+ // A non-cancellable status (fulfilling / shipped / delivered /
10963
+ // cancelled / refunded) bounces back to the order page unchanged —
10964
+ // a clean 303, no transition attempted, no 500.
10965
+ if (!_orderEligibleForCancel(o.status)) {
10966
+ res.status(303);
10967
+ res.setHeader && res.setHeader("location", "/orders/" + encodeURIComponent(o.id));
10968
+ return res.end ? res.end() : res.send("");
10969
+ }
10970
+ try {
10971
+ await deps.order.transition(o.id, "cancel", { reason: "customer-requested" });
10972
+ } catch (e) {
10973
+ // The FSM refuses the event (a status that advanced out of the
10974
+ // cancellable window between render and POST). order.transition
10975
+ // tags the refusal with .code = ORDER_TRANSITION_REFUSED; surface
10976
+ // it as a clean redirect to the order page rather than a 500.
10977
+ if (e && e.code === "ORDER_TRANSITION_REFUSED") {
10978
+ res.status(303);
10979
+ res.setHeader && res.setHeader("location", "/orders/" + encodeURIComponent(o.id));
10980
+ return res.end ? res.end() : res.send("");
10981
+ }
10982
+ throw e;
10983
+ }
10984
+ // PRG back to the order page with a confirmation banner (a refresh
10985
+ // doesn't re-fire the cancel).
10986
+ res.status(303);
10987
+ res.setHeader && res.setHeader("location", "/orders/" + encodeURIComponent(o.id) + "?cancelled=1");
10988
+ return res.end ? res.end() : res.send("");
10989
+ });
10898
10990
  }
10899
10991
 
10900
10992
  // POST /cart/lines — add a line. Reads variant_id + qty from the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.3.20",
3
+ "version": "0.3.21",
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": {