@blamejs/blamejs-shop 0.1.37 → 0.1.38

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/lib/storefront.js CHANGED
@@ -865,8 +865,8 @@ var FACET_GROUP_HEAD =
865
865
 
866
866
  var FACET_OPTION =
867
867
  "<li class=\"facet-option\">\n" +
868
- " <a class=\"facet-option__link{{selected_class}}\" href=\"{{href}}\" rel=\"nofollow\"{{aria_pressed}}>\n" +
869
- " <span class=\"facet-option__box\" aria-hidden=\"true\">{{box}}</span>\n" +
868
+ " <a class=\"facet-option__link{{selected_class}}\" href=\"{{href}}\" rel=\"nofollow\"{{aria_current}}>\n" +
869
+ " <span class=\"facet-option__box\" aria-hidden=\"true\">{{box}}</span>{{selected_cue}}\n" +
870
870
  " <span class=\"facet-option__label\">{{label}}</span>\n" +
871
871
  " <span class=\"facet-option__count\">{{count}}</span>\n" +
872
872
  " </a>\n" +
@@ -921,11 +921,13 @@ function _renderSearchFacets(facets, filters, q) {
921
921
  optionsHtml += _render(FACET_OPTION, {
922
922
  href: _searchUrl(q, _toggleFilter(filters, facet.key, opt.value)),
923
923
  selected_class: opt.selected ? " is-selected" : "",
924
- aria_pressed: "RAW_ARIA",
924
+ aria_current: "RAW_ARIA",
925
925
  box: opt.selected ? "✓" : "",
926
+ selected_cue: "RAW_CUE",
926
927
  label: opt.label,
927
928
  count: String(opt.count),
928
- }).replace("RAW_ARIA", opt.selected ? " aria-pressed=\"true\"" : " aria-pressed=\"false\"");
929
+ }).replace("RAW_ARIA", opt.selected ? " aria-current=\"true\"" : "")
930
+ .replace("RAW_CUE", opt.selected ? "<span class=\"sr-only\">Selected: </span>" : "");
929
931
  rendered += 1;
930
932
  }
931
933
  if (rendered === 0) continue;
@@ -1635,7 +1637,19 @@ function renderWishlist(opts) {
1635
1637
  }
1636
1638
  var inner = rowsHtml
1637
1639
  ? "<ul class=\"wishlist-list\">" + rowsHtml + "</ul>"
1638
- : "<p class=\"wishlist-empty\">You haven't saved anything yet. Browse the shop and tap <strong>Save to wishlist</strong> on products you want to keep an eye on.</p>";
1640
+ : "<div class=\"account-empty\">" +
1641
+ "<p class=\"account-empty__icon\" aria-hidden=\"true\">♡</p>" +
1642
+ "<p class=\"account-empty__lede\">You haven't saved anything yet. Browse the shop and tap <strong>Save to wishlist</strong> on products you want to keep an eye on.</p>" +
1643
+ "<a class=\"btn-secondary\" href=\"/\">Browse the shop →</a>" +
1644
+ "</div>";
1645
+ // Success confirmation after a wishlist toggle, surfaced via
1646
+ // role="status". Driven by the ?ok=<kind> redirect marker; unknown
1647
+ // keys render nothing so a forged query can't inject copy.
1648
+ var WISHLIST_OK = { added: "Saved to your wishlist.", removed: "Removed from your wishlist." };
1649
+ var okMsg = WISHLIST_OK[opts.notice];
1650
+ var notice = okMsg
1651
+ ? "<p class=\"form-notice form-notice--ok\" role=\"status\">" + esc(okMsg) + "</p>"
1652
+ : "";
1639
1653
  var body =
1640
1654
  "<section class=\"account-wishlist\">" +
1641
1655
  "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
@@ -1643,6 +1657,7 @@ function renderWishlist(opts) {
1643
1657
  "<li aria-current=\"page\">Saved items</li>" +
1644
1658
  "</ol></nav>" +
1645
1659
  "<h1 class=\"account-wishlist__title\">Saved items</h1>" +
1660
+ notice +
1646
1661
  inner +
1647
1662
  "</section>";
1648
1663
  return _wrap({
@@ -1871,7 +1886,19 @@ function renderSaved(opts) {
1871
1886
  }
1872
1887
  var inner = rowsHtml
1873
1888
  ? "<ul class=\"saved-list\">" + rowsHtml + "</ul>"
1874
- : "<p class=\"saved-empty\">Nothing saved for later. Use <strong>Save for later</strong> on a cart item to move it here without losing it.</p>";
1889
+ : "<div class=\"account-empty\">" +
1890
+ "<p class=\"account-empty__icon\" aria-hidden=\"true\">🔖</p>" +
1891
+ "<p class=\"account-empty__lede\">Nothing saved for later. Use <strong>Save for later</strong> on a cart item to move it here without losing it.</p>" +
1892
+ "<a class=\"btn-secondary\" href=\"/cart\">View your cart →</a>" +
1893
+ "</div>";
1894
+ // Success confirmation after a saved-list mutation (currently the
1895
+ // Remove action), surfaced via role="status". Unknown ?ok=<kind> keys
1896
+ // render nothing so a forged query can't inject copy.
1897
+ var SAVED_OK = { removed: "Removed from your saved items.", moved: "Moved to your cart." };
1898
+ var savedMsg = SAVED_OK[opts.notice];
1899
+ var notice = savedMsg
1900
+ ? "<p class=\"form-notice form-notice--ok\" role=\"status\">" + esc(savedMsg) + "</p>"
1901
+ : "";
1875
1902
  var body =
1876
1903
  "<section class=\"account-saved\">" +
1877
1904
  "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
@@ -1879,6 +1906,7 @@ function renderSaved(opts) {
1879
1906
  "<li aria-current=\"page\">Saved for later</li>" +
1880
1907
  "</ol></nav>" +
1881
1908
  "<h1 class=\"account-saved__title\">Saved for later</h1>" +
1909
+ notice +
1882
1910
  inner +
1883
1911
  "</section>";
1884
1912
  return _wrap({
@@ -1958,16 +1986,32 @@ function renderAddresses(opts) {
1958
1986
  "<a class=\"btn-ghost btn-ghost--sm\" href=\"/account/addresses/" + esc(a.id) + "/edit\">Edit</a>" +
1959
1987
  (Number(a.is_default_shipping) === 1 ? "" : "<form method=\"post\" action=\"/account/addresses/" + esc(a.id) + "/default-shipping\"><button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">Set default shipping</button></form>") +
1960
1988
  (Number(a.is_default_billing) === 1 ? "" : "<form method=\"post\" action=\"/account/addresses/" + esc(a.id) + "/default-billing\"><button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">Set default billing</button></form>") +
1961
- "<form method=\"post\" action=\"/account/addresses/" + esc(a.id) + "/archive\"><button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">Remove</button></form>" +
1989
+ "<a class=\"btn-ghost btn-ghost--sm\" href=\"/account/addresses/" + esc(a.id) + "/remove\">Remove</a>" +
1962
1990
  "</div>" +
1963
1991
  "</li>";
1964
1992
  }
1965
1993
  var listHtml = rowsHtml
1966
1994
  ? "<ul class=\"address-list\">" + rowsHtml + "</ul>"
1967
- : "<p class=\"address-empty\">No saved addresses yet. Add one below to speed up checkout.</p>";
1995
+ : "<div class=\"account-empty\">" +
1996
+ "<p class=\"account-empty__icon\" aria-hidden=\"true\">🏠</p>" +
1997
+ "<p class=\"account-empty__lede\">No saved addresses yet. Add one below to speed up checkout.</p>" +
1998
+ "</div>";
1968
1999
  var notice = opts.notice
1969
2000
  ? "<p class=\"form-notice form-notice--error\" role=\"alert\">" + esc(String(opts.notice)) + "</p>"
1970
2001
  : "";
2002
+ // Success confirmation after an address mutation (add / edit / remove /
2003
+ // set-default), surfaced at the top of the list via role="status" so a
2004
+ // screen reader announces it without stealing focus. Driven by the
2005
+ // ?ok=<kind> redirect the POST handlers set on success. A remove also
2006
+ // threads ?undo=<id> so the notice can offer a one-click un-archive.
2007
+ var undoForm = opts.undo_id
2008
+ ? " <form class=\"form-notice__undo\" method=\"post\" action=\"/account/addresses/" + esc(String(opts.undo_id)) + "/unarchive\">" +
2009
+ "<button type=\"submit\" class=\"btn-link\">Undo</button>" +
2010
+ "</form>"
2011
+ : "";
2012
+ var success = opts.success
2013
+ ? "<div class=\"form-notice form-notice--ok\" role=\"status\"><span>" + esc(String(opts.success)) + "</span>" + undoForm + "</div>"
2014
+ : "";
1971
2015
  var editing = opts.edit || null;
1972
2016
  var formHeading = editing ? "Edit address" : "Add an address";
1973
2017
  var formAction = editing ? ("/account/addresses/" + editing.id) : "/account/addresses";
@@ -1978,6 +2022,7 @@ function renderAddresses(opts) {
1978
2022
  "<li aria-current=\"page\">Addresses</li>" +
1979
2023
  "</ol></nav>" +
1980
2024
  "<h1 class=\"account-addresses__title\">Addresses</h1>" +
2025
+ success +
1981
2026
  listHtml +
1982
2027
  "<h2 class=\"account-addresses__form-title\">" + esc(formHeading) + "</h2>" +
1983
2028
  notice +
@@ -1992,6 +2037,45 @@ function renderAddresses(opts) {
1992
2037
  });
1993
2038
  }
1994
2039
 
2040
+ // Server-rendered confirmation step for removing a saved address. CSP
2041
+ // forbids an inline confirm() dialog, so the destructive action is gated
2042
+ // by a second page: a POST form that actually archives, plus a Cancel
2043
+ // link back to the list. JS-off-native — no client script involved.
2044
+ function renderAddressRemoveConfirm(opts) {
2045
+ var esc = b.template.escapeHtml;
2046
+ var a = opts.address || {};
2047
+ var lines = [a.recipient_name, a.company, a.street_line1, a.street_line2,
2048
+ [a.city, a.region, a.postal_code].filter(Boolean).join(", "), a.country, a.phone]
2049
+ .filter(function (x) { return x != null && String(x).length; })
2050
+ .map(function (x) { return "<span>" + esc(String(x)) + "</span>"; }).join("");
2051
+ var body =
2052
+ "<section class=\"account-confirm\">" +
2053
+ "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
2054
+ "<li><a href=\"/account\">Account</a></li>" +
2055
+ "<li><a href=\"/account/addresses\">Addresses</a></li>" +
2056
+ "<li aria-current=\"page\">Remove address</li>" +
2057
+ "</ol></nav>" +
2058
+ "<h1 class=\"account-confirm__title\">Remove this address?</h1>" +
2059
+ (a.label ? "<p class=\"address-card__label\">" + esc(a.label) + "</p>" : "") +
2060
+ "<address class=\"address-card__body\">" + lines + "</address>" +
2061
+ "<p class=\"account-confirm__lede\">This removes the address from your address book. " +
2062
+ "You can add it again later.</p>" +
2063
+ "<div class=\"account-confirm__actions\">" +
2064
+ "<form method=\"post\" action=\"/account/addresses/" + esc(a.id) + "/archive\">" +
2065
+ "<button type=\"submit\" class=\"btn-primary\">Remove address</button>" +
2066
+ "</form>" +
2067
+ "<a class=\"btn-ghost\" href=\"/account/addresses\">Cancel</a>" +
2068
+ "</div>" +
2069
+ "</section>";
2070
+ return _wrap({
2071
+ title: "Remove address",
2072
+ shop_name: opts.shop_name || "blamejs.shop",
2073
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
2074
+ theme_css: opts.theme_css,
2075
+ body: body,
2076
+ });
2077
+ }
2078
+
1995
2079
  // Storefront collection index — operator-curated + smart product lists.
1996
2080
  function renderCollectionList(opts) {
1997
2081
  var esc = b.template.escapeHtml;
@@ -2302,7 +2386,21 @@ function renderReturns(opts) {
2302
2386
  }
2303
2387
  var inner = rowsHtml
2304
2388
  ? "<ul class=\"return-list\">" + rowsHtml + "</ul>"
2305
- : "<p class=\"return-empty\">No returns yet. Start one from an order in your account.</p>";
2389
+ : "<div class=\"account-empty\">" +
2390
+ "<p class=\"account-empty__icon\" aria-hidden=\"true\">↩</p>" +
2391
+ "<p class=\"account-empty__lede\">No returns yet. Start one from an order in your account.</p>" +
2392
+ "<a class=\"btn-secondary\" href=\"/account/orders\">View your orders →</a>" +
2393
+ "</div>";
2394
+ // Success confirmation after submitting a return request. The RMA code
2395
+ // round-trips on the ?ok=<code> redirect so the notice can echo it back
2396
+ // — the operator-readable handle the customer references in support.
2397
+ // Validate the shape (RMA-YYMMDD-AAAAA) so a forged query can't inject
2398
+ // arbitrary copy into the status message.
2399
+ var notice = "";
2400
+ if (opts.rma_code && /^RMA-\d{6}-[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{5}$/.test(String(opts.rma_code))) {
2401
+ notice = "<p class=\"form-notice form-notice--ok\" role=\"status\">Return request received — RMA " +
2402
+ esc(String(opts.rma_code)) + ". We'll email you when it's reviewed.</p>";
2403
+ }
2306
2404
  var body =
2307
2405
  "<section class=\"account-returns\">" +
2308
2406
  "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
@@ -2310,6 +2408,7 @@ function renderReturns(opts) {
2310
2408
  "<li aria-current=\"page\">Returns</li>" +
2311
2409
  "</ol></nav>" +
2312
2410
  "<h1 class=\"account-returns__title\">Returns</h1>" +
2411
+ notice +
2313
2412
  inner +
2314
2413
  "</section>";
2315
2414
  return _wrap({
@@ -2692,13 +2791,12 @@ function renderAccountSubscriptions(opts) {
2692
2791
  }
2693
2792
  var cancelControl = "";
2694
2793
  if (canCancel && _subscriptionIsCancelable(s)) {
2794
+ // The cancel decision (and its "immediate vs. at period end"
2795
+ // consequence) is confirmed on a dedicated page — CSP forbids an
2796
+ // inline confirm() dialog, so the destructive step is a second
2797
+ // server-rendered screen rather than a one-click POST.
2695
2798
  cancelControl =
2696
- "<form class=\"subscription-card__cancel\" method=\"post\" action=\"/account/subscriptions/" + esc(s.id) + "/cancel\">" +
2697
- "<label class=\"subscription-card__when\">" +
2698
- "<input type=\"checkbox\" name=\"immediate\" value=\"1\"> Cancel immediately (skip the rest of the period)" +
2699
- "</label>" +
2700
- "<button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">Cancel subscription</button>" +
2701
- "</form>";
2799
+ "<a class=\"btn-ghost btn-ghost--sm subscription-card__cancel-link\" href=\"/account/subscriptions/" + esc(s.id) + "/cancel\">Cancel subscription</a>";
2702
2800
  }
2703
2801
  rowsHtml +=
2704
2802
  "<li class=\"subscription-card\">" +
@@ -2715,7 +2813,11 @@ function renderAccountSubscriptions(opts) {
2715
2813
  : "<p class=\"subscription-note\">Cancellation isn't available on this store yet. Contact support to make changes.</p>";
2716
2814
  var inner = rowsHtml
2717
2815
  ? note + "<ul class=\"subscription-list\">" + rowsHtml + "</ul>"
2718
- : "<p class=\"subscription-empty\">You have no active subscriptions.</p>";
2816
+ : "<div class=\"account-empty\">" +
2817
+ "<p class=\"account-empty__icon\" aria-hidden=\"true\">↻</p>" +
2818
+ "<p class=\"account-empty__lede\">You have no active subscriptions.</p>" +
2819
+ "<a class=\"btn-secondary\" href=\"/\">Browse the shop →</a>" +
2820
+ "</div>";
2719
2821
  var body =
2720
2822
  "<section class=\"account-subscriptions\">" +
2721
2823
  "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
@@ -2735,6 +2837,79 @@ function renderAccountSubscriptions(opts) {
2735
2837
  });
2736
2838
  }
2737
2839
 
2840
+ // Server-rendered confirmation step for canceling a subscription. CSP
2841
+ // forbids an inline confirm() dialog, so the destructive choice is gated
2842
+ // by this page: it spells out the period-end date and, for the
2843
+ // "cancel immediately" path, the days of access forfeited. Two POST
2844
+ // forms (at-period-end / immediate) plus a Cancel link back to the list.
2845
+ // JS-off-native.
2846
+ function renderSubscriptionCancelConfirm(opts) {
2847
+ var esc = b.template.escapeHtml;
2848
+ var s = opts.subscription || {};
2849
+ var plan = s.plan || null;
2850
+ var planSummary = "this subscription";
2851
+ if (plan) {
2852
+ var ccy = String(plan.currency || "usd").toUpperCase();
2853
+ var every = Number(plan.interval_count) > 1
2854
+ ? "every " + Number(plan.interval_count) + " " + String(plan.interval) + "s"
2855
+ : "per " + String(plan.interval);
2856
+ var priceStr = "";
2857
+ try { priceStr = pricing.format(Number(plan.amount_minor), ccy) + " "; } catch (_e) { priceStr = ""; }
2858
+ planSummary = priceStr + every;
2859
+ }
2860
+ var periodEndMs = Number(s.current_period_end) || 0;
2861
+ var periodEnd = periodEndMs ? new Date(periodEndMs).toISOString().slice(0, 10) : "";
2862
+ // Days of paid access forfeited if canceled immediately rather than at
2863
+ // period end — computed from now to the period boundary, floored at 0.
2864
+ var forfeitDays = 0;
2865
+ if (periodEndMs) {
2866
+ forfeitDays = Math.max(0, Math.ceil((periodEndMs - Date.now()) / b.constants.TIME.days(1)));
2867
+ }
2868
+ var periodLine = periodEnd
2869
+ ? "<p class=\"account-confirm__lede\">Your current period is paid through " +
2870
+ "<time datetime=\"" + esc(periodEnd) + "\">" + esc(periodEnd) + "</time>.</p>"
2871
+ : "";
2872
+ var atPeriodEndForm =
2873
+ "<form method=\"post\" action=\"/account/subscriptions/" + esc(s.id) + "/cancel\">" +
2874
+ "<p class=\"account-confirm__option\">Cancel at period end" +
2875
+ (periodEnd ? " — keep access until <time datetime=\"" + esc(periodEnd) + "\">" + esc(periodEnd) + "</time>, then it won't renew." : ", so it won't renew.") +
2876
+ "</p>" +
2877
+ "<button type=\"submit\" class=\"btn-primary\">Cancel at period end</button>" +
2878
+ "</form>";
2879
+ var immediateForm =
2880
+ "<form method=\"post\" action=\"/account/subscriptions/" + esc(s.id) + "/cancel\">" +
2881
+ "<input type=\"hidden\" name=\"immediate\" value=\"1\">" +
2882
+ "<p class=\"account-confirm__option\">Cancel immediately" +
2883
+ (forfeitDays > 0
2884
+ ? " — access ends now and you forfeit the remaining " + forfeitDays + " day" + (forfeitDays === 1 ? "" : "s") + " of this period."
2885
+ : " — access ends now.") +
2886
+ "</p>" +
2887
+ "<button type=\"submit\" class=\"btn-ghost\">Cancel immediately</button>" +
2888
+ "</form>";
2889
+ var body =
2890
+ "<section class=\"account-confirm\">" +
2891
+ "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
2892
+ "<li><a href=\"/account\">Account</a></li>" +
2893
+ "<li><a href=\"/account/subscriptions\">Subscriptions</a></li>" +
2894
+ "<li aria-current=\"page\">Cancel</li>" +
2895
+ "</ol></nav>" +
2896
+ "<h1 class=\"account-confirm__title\">Cancel " + esc(planSummary) + "?</h1>" +
2897
+ periodLine +
2898
+ "<div class=\"account-confirm__actions\">" +
2899
+ atPeriodEndForm +
2900
+ immediateForm +
2901
+ "<a class=\"btn-ghost\" href=\"/account/subscriptions\">Keep my subscription</a>" +
2902
+ "</div>" +
2903
+ "</section>";
2904
+ return _wrap({
2905
+ title: "Cancel subscription",
2906
+ shop_name: opts.shop_name || "blamejs.shop",
2907
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
2908
+ theme_css: opts.theme_css,
2909
+ body: body,
2910
+ });
2911
+ }
2912
+
2738
2913
  // Product-level "Save to wishlist" control + social-proof count.
2739
2914
  // Byte-compatible with the edge renderer (`worker/render/product.js`)
2740
2915
  // so both paths emit identical markup. Action-only label — the toggle
@@ -5801,6 +5976,20 @@ function mount(router, deps) {
5801
5976
  if (!orderId) return _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
5802
5977
  var o = await deps.order.get(orderId);
5803
5978
  if (!o) return _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
5979
+ // Ownership gate against IDOR: an order's confirmation page exposes
5980
+ // the customer's name, address, and line items by UUID alone. An order
5981
+ // that BELONGS to a customer (customer_id set) is viewable only by that
5982
+ // signed-in customer — anyone else (a different customer OR an
5983
+ // unauthenticated request) 404s rather than leaking it. A guest order
5984
+ // carries no customer_id and remains reachable via its unguessable URL
5985
+ // (the capability-URL model), so BOTH the just-placed-as-guest path AND
5986
+ // the signed-in-shopper-with-an-anonymous-cart path (checkout.confirm
5987
+ // derives the order from a cart that has no customer_id) still render
5988
+ // their own confirmation here.
5989
+ var orderAuth = _currentCustomerEnv(req);
5990
+ if (o.customer_id && (!orderAuth || o.customer_id !== orderAuth.customer_id)) {
5991
+ return _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
5992
+ }
5804
5993
  // Same variant_id → {product, hero_media} lookup pattern as the
5805
5994
  // cart route, applied to the order's frozen line items so the
5806
5995
  // post-checkout page shows what the customer bought visually.
@@ -6557,22 +6746,26 @@ function mount(router, deps) {
6557
6746
  return res.end ? res.end() : res.send("");
6558
6747
  }
6559
6748
  var productId = (req.body || {}).product_id;
6749
+ var removed = false;
6560
6750
  try {
6561
6751
  var already = await deps.wishlist.isWishlisted({ customer_id: auth.customer_id, product_id: productId });
6562
- if (already) await deps.wishlist.remove({ customer_id: auth.customer_id, product_id: productId });
6752
+ if (already) { await deps.wishlist.remove({ customer_id: auth.customer_id, product_id: productId }); removed = true; }
6563
6753
  else await deps.wishlist.add({ customer_id: auth.customer_id, product_id: productId });
6564
6754
  } catch (e) {
6565
6755
  res.status(e instanceof TypeError ? 400 : 500);
6566
6756
  return res.end ? res.end((e && e.message) || "Error") : res.send((e && e.message) || "Error");
6567
6757
  }
6758
+ var okKind = removed ? "removed" : "added";
6568
6759
  var rt = (req.body || {}).return_to;
6569
6760
  var dest;
6570
6761
  if (typeof rt === "string" && /^\/[^/]/.test(rt)) {
6571
- dest = rt;
6762
+ // Only thread the success marker when returning to the wishlist
6763
+ // page (the PDP heart toggle is its own visible cue).
6764
+ dest = rt.indexOf("/account/wishlist") === 0 ? "/account/wishlist?ok=" + okKind : rt;
6572
6765
  } else {
6573
6766
  var product = null;
6574
6767
  try { product = await deps.catalog.products.get(productId); } catch (_e) { product = null; }
6575
- dest = product ? ("/products/" + encodeURIComponent(product.slug)) : "/account/wishlist";
6768
+ dest = product ? ("/products/" + encodeURIComponent(product.slug)) : ("/account/wishlist?ok=" + okKind);
6576
6769
  }
6577
6770
  res.status(303); res.setHeader && res.setHeader("location", dest);
6578
6771
  return res.end ? res.end() : res.send("");
@@ -6603,8 +6796,10 @@ function mount(router, deps) {
6603
6796
  items.push({ product: product, hero_media: media.length ? media[0] : null });
6604
6797
  }
6605
6798
  var cartCount = await _cartCountForReq(req);
6799
+ var wlUrl = req.url ? new URL(req.url, "http://localhost") : null;
6606
6800
  _send(res, 200, renderWishlist({
6607
6801
  items: items,
6802
+ notice: wlUrl ? wlUrl.searchParams.get("ok") : null,
6608
6803
  shop_name: shopName,
6609
6804
  cart_count: cartCount,
6610
6805
  asset_prefix: deps.asset_prefix || "/assets/",
@@ -6675,8 +6870,10 @@ function mount(router, deps) {
6675
6870
  items.push({ save: row, product: product, hero_media: media.length ? media[0] : null });
6676
6871
  }
6677
6872
  var cartCount = await _cartCountForReq(req);
6873
+ var savedUrl = req.url ? new URL(req.url, "http://localhost") : null;
6678
6874
  _send(res, 200, renderSaved({
6679
6875
  items: items,
6876
+ notice: savedUrl ? savedUrl.searchParams.get("ok") : null,
6680
6877
  shop_name: shopName,
6681
6878
  cart_count: cartCount,
6682
6879
  asset_prefix: deps.asset_prefix || "/assets/",
@@ -6734,7 +6931,7 @@ function mount(router, deps) {
6734
6931
  res.status(e instanceof TypeError ? 400 : 500);
6735
6932
  return res.end ? res.end((e && e.message) || "Error") : res.send((e && e.message) || "Error");
6736
6933
  }
6737
- res.status(303); res.setHeader && res.setHeader("location", "/account/saved");
6934
+ res.status(303); res.setHeader && res.setHeader("location", "/account/saved?ok=removed");
6738
6935
  return res.end ? res.end() : res.send("");
6739
6936
  });
6740
6937
  }
@@ -6774,13 +6971,33 @@ function mount(router, deps) {
6774
6971
  }
6775
6972
  return addr;
6776
6973
  }
6974
+ // Map the ?ok=<kind> redirect marker the POST handlers set on
6975
+ // success to the human confirmation copy rendered (role="status")
6976
+ // at the top of the list. Unknown markers degrade to no notice.
6977
+ function _addrSuccessCopy(kind) {
6978
+ if (kind === "added") return "Address saved.";
6979
+ if (kind === "updated") return "Address updated.";
6980
+ if (kind === "removed") return "Address removed.";
6981
+ if (kind === "restored") return "Address restored.";
6982
+ if (kind === "default-shipping") return "Default shipping address updated.";
6983
+ if (kind === "default-billing") return "Default billing address updated.";
6984
+ return null;
6985
+ }
6777
6986
  async function _renderAddrPage(req, res, auth, editAddr, notice, code) {
6778
6987
  var rows = await deps.addresses.listForCustomer(auth.customer_id, { limit: 50 });
6779
6988
  var cartCount = await _cartCountForReq(req);
6989
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
6990
+ var okKind = url ? url.searchParams.get("ok") : null;
6991
+ var success = _addrSuccessCopy(okKind);
6992
+ // An Undo control is only offered for a just-removed row, and only
6993
+ // when the ?undo=<id> marker round-trips a real owned address id.
6994
+ var undoId = (okKind === "removed" && url) ? url.searchParams.get("undo") : null;
6780
6995
  _send(res, code || 200, renderAddresses({
6781
6996
  addresses: rows,
6782
6997
  edit: editAddr || null,
6783
6998
  notice: notice || null,
6999
+ success: success,
7000
+ undo_id: undoId || null,
6784
7001
  shop_name: shopName,
6785
7002
  cart_count: cartCount,
6786
7003
  }));
@@ -6824,7 +7041,7 @@ function mount(router, deps) {
6824
7041
  if (e instanceof TypeError) return _renderAddrPage(req, res, auth, null, (e && e.message) || "Please check the address.", 400);
6825
7042
  throw e;
6826
7043
  }
6827
- res.status(303); res.setHeader && res.setHeader("location", "/account/addresses");
7044
+ res.status(303); res.setHeader && res.setHeader("location", "/account/addresses?ok=added");
6828
7045
  return res.end ? res.end() : res.send("");
6829
7046
  });
6830
7047
 
@@ -6840,11 +7057,11 @@ function mount(router, deps) {
6840
7057
  }
6841
7058
  throw e;
6842
7059
  }
6843
- res.status(303); res.setHeader && res.setHeader("location", "/account/addresses");
7060
+ res.status(303); res.setHeader && res.setHeader("location", "/account/addresses?ok=updated");
6844
7061
  return res.end ? res.end() : res.send("");
6845
7062
  });
6846
7063
 
6847
- function _addrAction(verb, fn) {
7064
+ function _addrAction(verb, okKind, fn) {
6848
7065
  router.post("/account/addresses/:id/" + verb, async function (req, res) {
6849
7066
  var auth = _addrAuth(req, res); if (!auth) return;
6850
7067
  var addr = await _ownedAddress(req, res, auth); if (!addr) return;
@@ -6853,13 +7070,64 @@ function mount(router, deps) {
6853
7070
  res.status(e instanceof TypeError ? 400 : 500);
6854
7071
  return res.end ? res.end((e && e.message) || "Error") : res.send((e && e.message) || "Error");
6855
7072
  }
6856
- res.status(303); res.setHeader && res.setHeader("location", "/account/addresses");
7073
+ res.status(303); res.setHeader && res.setHeader("location", "/account/addresses?ok=" + okKind);
6857
7074
  return res.end ? res.end() : res.send("");
6858
7075
  });
6859
7076
  }
6860
- _addrAction("default-shipping", function (id) { return deps.addresses.setDefaultShipping(id); });
6861
- _addrAction("default-billing", function (id) { return deps.addresses.setDefaultBilling(id); });
6862
- _addrAction("archive", function (id) { return deps.addresses.archive(id); });
7077
+ _addrAction("default-shipping", "default-shipping", function (id) { return deps.addresses.setDefaultShipping(id); });
7078
+ _addrAction("default-billing", "default-billing", function (id) { return deps.addresses.setDefaultBilling(id); });
7079
+
7080
+ // Remove is destructive and CSP forbids a confirm() dialog, so it
7081
+ // routes through a server-rendered confirm page first; the POST that
7082
+ // actually archives lives behind that page. The list then surfaces a
7083
+ // success notice with an Undo (unarchive) control.
7084
+ router.get("/account/addresses/:id/remove", async function (req, res) {
7085
+ var auth = _addrAuth(req, res); if (!auth) return;
7086
+ var addr = await _ownedAddress(req, res, auth); if (!addr) return;
7087
+ var cartCount = await _cartCountForReq(req);
7088
+ _send(res, 200, renderAddressRemoveConfirm({
7089
+ address: addr,
7090
+ shop_name: shopName,
7091
+ cart_count: cartCount,
7092
+ }));
7093
+ });
7094
+
7095
+ router.post("/account/addresses/:id/archive", async function (req, res) {
7096
+ var auth = _addrAuth(req, res); if (!auth) return;
7097
+ var addr = await _ownedAddress(req, res, auth); if (!addr) return;
7098
+ try { await deps.addresses.archive(addr.id); }
7099
+ catch (e) {
7100
+ res.status(e instanceof TypeError ? 400 : 500);
7101
+ return res.end ? res.end((e && e.message) || "Error") : res.send((e && e.message) || "Error");
7102
+ }
7103
+ res.status(303);
7104
+ res.setHeader && res.setHeader("location", "/account/addresses?ok=removed&undo=" + encodeURIComponent(addr.id));
7105
+ return res.end ? res.end() : res.send("");
7106
+ });
7107
+
7108
+ // Undo path for a just-removed address. Unlike the by-id routes
7109
+ // above, this one resolves the row WITH archived rows included (the
7110
+ // address is archived by definition here) but still enforces
7111
+ // customer ownership before un-archiving.
7112
+ router.post("/account/addresses/:id/unarchive", async function (req, res) {
7113
+ var auth = _addrAuth(req, res); if (!auth) return;
7114
+ var addr;
7115
+ try { addr = await deps.addresses.get(req.params && req.params.id); }
7116
+ catch (e) {
7117
+ if (e instanceof TypeError) { _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme })); return; }
7118
+ throw e;
7119
+ }
7120
+ if (!addr || addr.customer_id !== auth.customer_id) {
7121
+ return _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
7122
+ }
7123
+ try { await deps.addresses.unarchive(addr.id); }
7124
+ catch (e) {
7125
+ res.status(e instanceof TypeError ? 400 : 500);
7126
+ return res.end ? res.end((e && e.message) || "Error") : res.send((e && e.message) || "Error");
7127
+ }
7128
+ res.status(303); res.setHeader && res.setHeader("location", "/account/addresses?ok=restored");
7129
+ return res.end ? res.end() : res.send("");
7130
+ });
6863
7131
  }
6864
7132
 
6865
7133
  // Subscription self-management — the signed-in customer views their
@@ -6950,6 +7218,31 @@ function mount(router, deps) {
6950
7218
  // Cancel mounts only when payment is wired (cancel composes Stripe).
6951
7219
  // Without payment the list above stays read-only with a note.
6952
7220
  if (deps.payment) {
7221
+ // Confirmation step (GET) ahead of the destructive POST. Renders
7222
+ // the period-end date + the days-forfeited consequence of an
7223
+ // immediate cancel. A subscription that isn't cancelable
7224
+ // (already canceled / winding down) redirects back to the list.
7225
+ router.get("/account/subscriptions/:id/cancel", async function (req, res) {
7226
+ var auth = _subsAuth(req, res); if (!auth) return;
7227
+ var sub = await _ownedSubscription(req, res, auth); if (!sub) return;
7228
+ if (!_subscriptionIsCancelable(sub)) {
7229
+ res.status(303); res.setHeader && res.setHeader("location", "/account/subscriptions");
7230
+ return res.end ? res.end() : res.send("");
7231
+ }
7232
+ // Join the plan for the confirm-page summary (best-effort — a
7233
+ // missing plan degrades the heading to a generic phrase).
7234
+ if (sub.plan_id != null) {
7235
+ try { sub.plan = await subscriptions.plans.get(sub.plan_id); }
7236
+ catch (_e) { sub.plan = null; }
7237
+ }
7238
+ var cartCount = await _cartCountForReq(req);
7239
+ _send(res, 200, renderSubscriptionCancelConfirm({
7240
+ subscription: sub,
7241
+ shop_name: shopName,
7242
+ cart_count: cartCount,
7243
+ }));
7244
+ });
7245
+
6953
7246
  router.post("/account/subscriptions/:id/cancel", async function (req, res) {
6954
7247
  var auth = _subsAuth(req, res); if (!auth) return;
6955
7248
  var sub = await _ownedSubscription(req, res, auth); if (!sub) return;
@@ -7016,7 +7309,13 @@ function mount(router, deps) {
7016
7309
  var auth = _returnsAuth(req, res); if (!auth) return;
7017
7310
  var page = await deps.returns.listForCustomer(auth.customer_id, { limit: 50 });
7018
7311
  var cartCount = await _cartCountForReq(req);
7019
- _send(res, 200, renderReturns({ rmas: page.rows, shop_name: shopName, cart_count: cartCount }));
7312
+ var retUrl = req.url ? new URL(req.url, "http://localhost") : null;
7313
+ _send(res, 200, renderReturns({
7314
+ rmas: page.rows,
7315
+ rma_code: retUrl ? retUrl.searchParams.get("ok") : null,
7316
+ shop_name: shopName,
7317
+ cart_count: cartCount,
7318
+ }));
7020
7319
  });
7021
7320
 
7022
7321
  router.get("/account/orders/:order_id/return", async function (req, res) {
@@ -7049,8 +7348,9 @@ function mount(router, deps) {
7049
7348
  shop_name: shopName, cart_count: cartCount,
7050
7349
  }));
7051
7350
  }
7351
+ var requested;
7052
7352
  try {
7053
- await deps.returns.request({
7353
+ requested = await deps.returns.request({
7054
7354
  order_id: order.id,
7055
7355
  customer_id: auth.customer_id,
7056
7356
  reason: body.reason,
@@ -7066,7 +7366,11 @@ function mount(router, deps) {
7066
7366
  }
7067
7367
  throw e;
7068
7368
  }
7069
- res.status(303); res.setHeader && res.setHeader("location", "/account/returns");
7369
+ // Round-trip the RMA code on the redirect so the list page can
7370
+ // confirm the request and echo the operator-readable handle.
7371
+ var rmaCode = requested && requested.rma_code ? requested.rma_code : "";
7372
+ res.status(303);
7373
+ res.setHeader && res.setHeader("location", "/account/returns?ok=" + encodeURIComponent(rmaCode));
7070
7374
  return res.end ? res.end() : res.send("");
7071
7375
  });
7072
7376
  }
@@ -3,8 +3,8 @@
3
3
  "_about": "blamejs.shop vendors a single framework — blamejs — which itself bundles every server-side crypto/identity dependency. The transitive packages blamejs ships are surfaced in its own MANIFEST.json at lib/vendor/blamejs/lib/vendor/MANIFEST.json — Trivy / Grype rely on that nested data for CVE attribution.",
4
4
  "packages": {
5
5
  "blamejs": {
6
- "version": "0.13.6",
7
- "tag": "v0.13.6",
6
+ "version": "0.13.8",
7
+ "tag": "v0.13.8",
8
8
  "license": "Apache-2.0",
9
9
  "author": "blamejs contributors",
10
10
  "source": "https://github.com/blamejs/blamejs",
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.13.x
10
10
 
11
+ - v0.13.8 (2026-05-26) — **In-memory archive extraction for read-only / serverless filesystems.** Archive readers gain an in-memory extraction path so an uploaded archive can be opened and its contents read without writing anything to disk — the case a read-only or ephemeral serverless filesystem requires. b.archive.read.zip(...).extractEntries() and b.archive.read.tar(...).extractEntries() are async generators that yield each regular file entry as { name, bytes, size }, applying the same bomb-policy caps, b.guardArchive metadata cascade (which refuses a Zip-Slip / traversal archive wholesale), and entry-type-policy refusals as the disk extract() — only the disk realpath agreement check is omitted, since nothing is written and the caller owns where the returned bytes land. Directory and link entries carry no content and are not yielded. The guard cascade is factored into one shared path so disk and in-memory extraction refuse identically. Also a documentation fix: b.archive.gz no longer claims a b.archive.zip().toGzip() convenience exists — a ZIP is already DEFLATE-compressed per entry, so gzip-wrapping it gains nothing; gzip the uncompressed tar stream (the canonical .tar.gz) instead. **Added:** *`extractEntries()` — in-memory archive extraction (ZIP + tar)* — `b.archive.read.zip(source).extractEntries(opts?)` and `b.archive.read.tar(source).extractEntries(opts?)` are async generators yielding `{ name, bytes, size }` per regular file entry, never touching disk — for serverless / read-only filesystems where the disk `extract({ destination })` path cannot run. Same bomb-policy, guard-archive cascade, and entry-type-policy refusals as disk extraction; the bytes are byte-identical to what `extract()` writes. **Fixed:** *Removed the inaccurate `b.archive.zip().toGzip()` doc claim* — The `b.archive.gz` documentation described a `b.archive.zip().toGzip()` convenience method that does not (and should not) exist: a ZIP is already DEFLATE-compressed per entry, so gzip-wrapping it would compress already-compressed data for no benefit. `b.archive.tar().toGzip()` (the real `.tar.gz`) is unchanged.
12
+
13
+ - v0.13.7 (2026-05-26) — **Documentation accuracy — several primitives described shipped features as deferred.** A documentation sweep corrected primitive descriptions that still called features deferred after they shipped, so the wiki and inline docs now match the code. b.mdoc documents that device authentication (the ISO 18013-5 §9.1.3 signature variant, verifyDeviceAuth) is verified, not deferred — only the COSE_Mac0 device-auth variant remains refused. b.network.dns.dnssec documents that the root-to-zone chain walk against the IANA trust anchors (verifyChain) and NSEC / NSEC3 denial of existence (verifyDenial / nsec3Hash) ship, where the card previously said they were deferred. b.cose lists COSE_Mac0 and COSE_Encrypt0 among what it ships. The JMAP server documents its push channel, blob upload/download, and EmailSubmission handlers as present, and the submission server documents CHUNKING / BDAT as supported. A new test detector keeps this class of drift from recurring: it fails the build when a comment promises a feature lands in a version that has already shipped. **Fixed:** *Corrected `deferred`/`does-not-ship` docs for features that have shipped* — `b.mdoc` (device authentication, §9.1.3), `b.network.dns.dnssec` (chain walk + NSEC/NSEC3), `b.cose` (COSE_Mac0 + COSE_Encrypt0), the JMAP server (push, blob, EmailSubmission), and the submission server (BDAT/CHUNKING) all carried `@card`/`@intro` text describing shipped capabilities as deferred or not-shipped. The descriptions now match the implemented surface; genuinely-deferred items (the mdoc MAC variant, DNSSEC in-RDATA name canonicalization, COSE multi-signer/multi-recipient) remain documented as such. **Detectors:** *Overdue-defer detector in the codebase-pattern gate* — A new check fails the build when a comment promises a feature "lands in" / is "deferred to" / is "not supported in" a version that the package has already reached — catching stale deferral notes (a feature that shipped but whose comment still says otherwise, or a missed deadline) before they reach a release. An allowlist records the deliberate defer-with-condition exceptions.
14
+
11
15
  - v0.13.6 (2026-05-26) — **`b.ai.frontierModelProtocol` — California SB 53 frontier-AI obligations.** b.ai.frontierModelProtocol assesses a developer's obligations under California's Transparency in Frontier Artificial Intelligence Act — SB 53, Cal. Bus. & Prof. Code §22757.10, effective 2026-01-01 — from a model's training compute and the developer's revenue. It reports whether the model crosses the frontier threshold (more than 10^26 training FLOPs), whether the developer is a large frontier developer (prior-year revenue, with affiliates, above $500M), and the resulting obligations: every frontier developer must report critical safety incidents and publish a transparency report, and a large frontier developer must additionally publish an annual safety framework and disclose its catastrophic-risk assessment. Passing a candidate safety framework reports which required elements (risk identification, mitigation, governance, cybersecurity, standards alignment) are missing. b.ai.frontierModelProtocol.incidentReport validates a critical-incident type against the Act's four categories and computes the notification deadline to the California Office of Emergency Services — 15 days from discovery, or 24 hours when there is an imminent risk of death or serious physical injury. The ca-tfaia compliance posture was already in the catalog. **Added:** *`b.ai.frontierModelProtocol` — SB 53 threshold classification, obligations, and incident reporting* — `b.ai.frontierModelProtocol({ trainingFlops, annualRevenueUsd, framework? })` returns `isFrontierModel`, `isLargeFrontierDeveloper`, the `obligations` list, and (when a framework is supplied) its `frameworkGaps`. `b.ai.frontierModelProtocol.incidentReport({ type, discoveredAt, imminentRiskToLife? })` builds a critical-safety-incident report with the California OES recipient and a `dueAt` / `deadlineHours` computed from the 15-day (or 24-hour imminent-risk) statutory window; `type` must be one of the four categories in `INCIDENT_TYPES`. Throws `FrontierProtocolError` on malformed input.
12
16
 
13
17
  - v0.13.5 (2026-05-26) — **`b.ai.aedtBiasAudit` — NYC Local Law 144 bias audit.** b.ai.aedtBiasAudit computes the bias-audit figures New York City Local Law 144 requires before an Automated Employment Decision Tool may screen candidates (NYC Admin. Code §20-870 et seq.; DCWP rules 6 RCNY §5-300). Given the per-category counts an independent auditor collected — selected/total for a pass-fail tool, or scored-above-the-overall-median/total for a continuous-score tool — it returns the selection (or scoring) rate, the impact ratio (each group's rate divided by the most-selected group's rate), and an adverse-impact flag (impact ratio below the EEOC four-fifths threshold of 0.8) for every group, across the sex, race/ethnicity, and intersectional dimensions, plus the most-selected group per dimension and an overall flag. Categories under 2% of the audited data are marked excluded per DCWP discretion. It is a pure calculation that produces exactly the figures the annual published summary must contain — the law mandates the calculation, not any particular remediation. The relevant compliance postures (nyc-ll144, and ca-tfaia for California SB 53) were already in the catalog. **Added:** *`b.ai.aedtBiasAudit` — Local Law 144 selection/scoring rates and four-fifths impact ratios* — `b.ai.aedtBiasAudit({ type, metadata, categories, minCategoryShare? })` where `type` is `"selection"` (group entries `{ selected, total }`) or `"scoring"` (`{ scoredAboveMedian, total }`). Returns per-group rate, impact ratio, and `adverseImpact` flag across the `sex`, `raceEthnicity`, and `intersectional` dimensions, plus the most-selected group per dimension and an `anyAdverseImpact` summary. Categories below `minCategoryShare` (2% default) are excluded from the impact-ratio basis. Throws `AedtBiasAuditError` on malformed input.
@@ -228,7 +228,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
228
228
  - **i18n** — CLDR plural rules, Accept-Language negotiation, Intl formatters, RTL (`b.i18n`)
229
229
  - **CSV** — RFC 4180 with Excel formula-injection prevention (`b.csv`)
230
230
  - **IDs + slugs** — RFC 9562 UUID v4 + v7 (`b.uuid`); URL-safe slugs (`b.slug`)
231
- - **Time + archive** — TZ-aware datetime (`b.time`); ZIP creation + adversarial-safe read with bomb caps + path-traversal + LFH/CD-skew defense (`b.archive` + `b.archive.read.zip`); one-liner quarantine extraction (`b.safeArchive.extract`); fs / objectStore / http / buffer / trusted-stream adapter contract (`b.archive.adapters`)
231
+ - **Time + archive** — TZ-aware datetime (`b.time`); ZIP creation + adversarial-safe read with bomb caps + path-traversal + LFH/CD-skew defense (`b.archive` + `b.archive.read.zip`); one-liner quarantine extraction (`b.safeArchive.extract`); in-memory extraction with no disk write for read-only / serverless filesystems (`b.archive.read.zip(...).extractEntries()` / `.tar`); fs / objectStore / http / buffer / trusted-stream adapter contract (`b.archive.adapters`)
232
232
  - **Pagination + forms** — HMAC-signed cursor pagination (`b.pagination`); HTML form rendering + validation + CSRF (`b.forms`)
233
233
 
234
234
  ### Production