@blamejs/blamejs-shop 0.3.9 → 0.3.11

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,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.3.x
10
10
 
11
+ - v0.3.11 (2026-05-30) — **Webhook endpoints reject internal addresses; admin errors stay clean and typed.** Outbound webhook endpoints can no longer be pointed at loopback, private, link-local, or cloud-metadata addresses, closing a server-side request forgery path from operator-supplied URLs. Several admin endpoints that previously returned a 500 on bad input now return the correct 4xx — a duplicate slug or SKU is a 409 Conflict, a missing referenced record and malformed JSON are a 400 — and none of them echo internal database or parser text back in the response. Gift cards now reject a currency that isn't a real ISO 4217 code, and the returns refund action reports a malformed id the same way its sibling actions do. No configuration change is required; upgrading picks these up automatically. **Fixed:** *Admin errors return the right status and don't leak internals* — Creating a product, variant, or inventory row with a duplicate key now returns 409 Conflict instead of 500; attaching media to a missing product, and pasting malformed JSON into the shipping-zone editor, now return 400. None of these responses include raw database or JSON-parser text — the detail is a generic, operator-facing message and the underlying error is recorded server-side. The same applies to the receipt and packing-slip routes. · *Gift cards validate the currency* — Issuing a gift card with a currency that isn't a real ISO 4217 code is now rejected with a 400 instead of creating a card in a non-existent currency. · *Consistent error status for refunds* — The returns refund action reports a malformed return id with the same 400 its approve, receive, and reject siblings use; a well-formed id that doesn't exist is still a 404. **Security:** *Webhook endpoints can't target internal addresses* — A webhook endpoint URL that points at a loopback, private (RFC 1918), link-local, reserved, or cloud-metadata address — by IP literal or by a known name such as localhost, metadata.google.internal, or any *.internal host — is now refused when the subscription is created, on both the delivery and outbound-registration paths. A trailing-dot form of those hosts (for example localhost. or 169.254.169.254.) is normalized before the check so it can't slip past. Public https endpoints are unaffected.
12
+
13
+ - v0.3.10 (2026-05-30) — **Order discounts are split across lines for accurate partial refunds.** When an order carries a cart-level discount, the admin console now records how that discount split across the order's lines — proportional to each line's value — so a later partial refund of one line knows that line's discounted share rather than the whole-order amount. A new read-only Discount splits screen shows the per-line breakdown for an order. This is back-office bookkeeping recorded after the order is placed; it has no effect on what the shopper is charged, and an order with no discount records nothing. **Added:** *Per-line discount allocation + admin view* — An order that carried an automatic cart discount now gets a recorded breakdown of how that discount split across its lines — proportional to each line's subtotal, summing exactly to the discount. A read-only Discount splits admin screen shows the breakdown for an order. The recording runs after the order is placed and never affects the charged amount; an order with no discount records nothing.
14
+
11
15
  - v0.3.9 (2026-05-30) — **Automatic discounts now apply at checkout.** The automatic-discount rules you define in the admin console — an amount or percentage off a cart that meets a threshold — now reduce the order total at checkout. When a rule matches, the discount is applied (capped so it never exceeds the order subtotal) and shown in the quote the shopper pays; when no rule matches, or none are defined, the total is exactly as before, so a store that has not set up rules is unaffected. The admin screen for managing rules already shipped; this wires it through to the checkout quote and the charge. **Added:** *Automatic cart discounts at checkout* — When a cart matches a configured automatic-discount rule, the checkout reduces the order total by the rule's saving — capped to the order subtotal so the total is never negative — and the reduced amount is what the shopper is charged. With no matching rule, or no rules defined, the order total is unchanged, so the change stays invisible until you set rules up; any rule-engine error degrades to no discount rather than blocking checkout.
12
16
 
13
17
  - v0.3.8 (2026-05-30) — **Update the vendored blamejs runtime to v0.14.6.** Refreshes the vendored blamejs runtime from v0.14.5 to v0.14.6, a maintenance update. The createApp request-lifecycle security stack the shop composes — CSRF, fetch-metadata, body-parser, cookies, rate limiting — is unchanged in default behavior. v0.14.6 adds opt-in RFC 9457 problem+json and onDeny response hooks to the access-refusal middleware (defaults unchanged, so nothing the shop relies on shifts) and corrects a few internal export and bearer-scope paths. No operator action is required. **Changed:** *Vendored blamejs runtime updated to v0.14.6* — The pinned blamejs runtime moves from v0.14.5 to v0.14.6. The middleware the storefront and admin compose keeps its current default behavior; the release adds opt-in problem-details / onDeny response hooks to the refusal middleware and fixes internal export and bearer-scope paths. No behavior change for the shop.
package/lib/admin.js CHANGED
@@ -411,7 +411,34 @@ function _wrap(handler, opts) {
411
411
  return result;
412
412
  } catch (e) {
413
413
  if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
414
- return _problem(res, 500, "internal-error", (e && e.message) || String(e));
414
+ // Malformed JSON body the body parser raises a SyntaxError whose
415
+ // message echoes the parser's position ("...JSON at position 1").
416
+ // Surface a clean 400 with a generic detail rather than leaking the
417
+ // parser internals (also the defense-in-depth net for any call site
418
+ // that JSON.parses an operator-supplied field without its own guard).
419
+ if (e instanceof SyntaxError) return _problem(res, 400, "bad-request", "Invalid JSON in request.");
420
+ // Storage-engine constraint violations reach here as plain Errors
421
+ // whose message names the table/column/SQL ("UNIQUE constraint
422
+ // failed: products.slug"). Map the class to a clean 4xx with a
423
+ // GENERIC operator-facing detail — never echo the table/column/SQL.
424
+ var msg = (e && e.message) || "";
425
+ if (/UNIQUE constraint failed/i.test(msg) || /FOREIGN KEY constraint failed/i.test(msg)) {
426
+ return _problem(res, 409, "conflict",
427
+ /FOREIGN KEY/i.test(msg) ? "A referenced record does not exist." : "That value is already in use.");
428
+ }
429
+ if (/(?:CHECK|NOT NULL) constraint failed/i.test(msg)) {
430
+ return _problem(res, 400, "bad-request", "A required value is missing or invalid.");
431
+ }
432
+ // Genuine unknown error — record it server-side via the framework
433
+ // audit (drop-silent) so operators can correlate, then return a
434
+ // generic 500 with NO detail. The raw message never reaches the
435
+ // client.
436
+ b.audit.safeEmit({
437
+ action: AUDIT_NAMESPACE + "." + (opts.audit || "request") + ".error",
438
+ outcome: "failure",
439
+ metadata: { message: msg || String(e) },
440
+ });
441
+ return _problem(res, 500, "internal-error");
415
442
  }
416
443
  };
417
444
  }
@@ -451,7 +478,7 @@ function mount(router, deps) {
451
478
  // `reports` is always present in the nav (read-only sales summary needs no
452
479
  // extra dep); its route mounts unconditionally and renders an unconfigured
453
480
  // notice when the salesReports primitive isn't wired.
454
- var navAvailable = { returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, customerSurveys: !!deps.customerSurveys, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, autoDiscount: !!deps.autoDiscount, quantityDiscounts: !!deps.quantityDiscounts, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels };
481
+ var navAvailable = { returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, customerSurveys: !!deps.customerSurveys, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, autoDiscount: !!deps.autoDiscount, discountAllocation: !!deps.discountAllocation, quantityDiscounts: !!deps.quantityDiscounts, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels };
455
482
 
456
483
  try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
457
484
 
@@ -2455,6 +2482,7 @@ function mount(router, deps) {
2455
2482
  try {
2456
2483
  result = await _refundOrder(o, req.body || {});
2457
2484
  } catch (e) {
2485
+ // allow:admin-5xx-echoes-raw-error-message — 502 surfaces the PAYMENT PROVIDER's refund-failure reason (e.g. "charge already refunded"), an operator-actionable upstream message, not a server/storage internal.
2458
2486
  return _problem(res, 502, "stripe-refund-failed", (e && e.message) || String(e));
2459
2487
  }
2460
2488
  _json(res, 200, result);
@@ -3017,12 +3045,11 @@ function mount(router, deps) {
3017
3045
  router.post("/admin/returns/:id/refund", _pageOrApi(false,
3018
3046
  W("return.refund", async function (req, res) {
3019
3047
  var body = req.body || {};
3020
- var rma;
3021
- try { rma = await returns.get(req.params.id); }
3022
- catch (e) {
3023
- if (e instanceof TypeError) return _problem(res, 404, "return-not-found", e.message);
3024
- throw e;
3025
- }
3048
+ // A malformed rma id is a bad request, not a missing record — let
3049
+ // the guardUuid TypeError surface as a clean 400 via _wrap, matching
3050
+ // the approve/received/reject siblings. Only a well-formed id that
3051
+ // resolves to no row (rma === null) is a genuine 404.
3052
+ var rma = await returns.get(req.params.id);
3026
3053
  if (!rma) return _problem(res, 404, "return-not-found");
3027
3054
  var rmaCtx = await _rmaProviderContext(rma);
3028
3055
  // Provider-backed path: move money first, then record the RMA.
@@ -3032,6 +3059,7 @@ function mount(router, deps) {
3032
3059
  catch (e) {
3033
3060
  var ce = _returnsClientError(e);
3034
3061
  if (ce) return _problem(res, ce.status, ce.slug, e.message);
3062
+ // allow:admin-5xx-echoes-raw-error-message — 502 surfaces the PAYMENT PROVIDER's refund-failure reason, an operator-actionable upstream message, not a server/storage internal.
3035
3063
  return _problem(res, 502, "provider-refund-failed", (e && e.message) || String(e));
3036
3064
  }
3037
3065
  _json(res, 200, result.rma);
@@ -3741,6 +3769,59 @@ function mount(router, deps) {
3741
3769
  ));
3742
3770
  }
3743
3771
 
3772
+ // ---- discount allocations -------------------------------------------
3773
+
3774
+ // Read-only console for the per-line discount-allocation audit trail.
3775
+ // When a placed order carried a cart-level discount, checkout records
3776
+ // (post-commit) how that discount split across the order's lines, so a
3777
+ // later partial refund knows each line's discounted share. Allocations
3778
+ // are SYSTEM-WRITTEN — there's no create/edit here; the operator looks
3779
+ // up an order id and reads back the recorded breakdown. Endpoints are
3780
+ // omitted entirely when no discountAllocation primitive is wired.
3781
+ var discountAllocation = deps.discountAllocation || null;
3782
+ if (discountAllocation) {
3783
+ // Look an order's recorded allocations up by id. Content-negotiates:
3784
+ // bearer → JSON (the raw rows, unchanged for tooling); signed-in
3785
+ // browser → the lookup form plus the rendered breakdown tables.
3786
+ //
3787
+ // The order id is a defensive request-shape reader — an empty / over-
3788
+ // long / control-byte id makes allocationsForOrder throw a TypeError,
3789
+ // which surfaces as an empty result + a notice, never a 500.
3790
+ async function _lookupAllocations(orderId) {
3791
+ if (typeof orderId !== "string" || orderId === "") return [];
3792
+ try {
3793
+ return await discountAllocation.allocationsForOrder(orderId);
3794
+ } catch (e) {
3795
+ if (e instanceof TypeError) return []; // malformed id — treat as "nothing recorded"
3796
+ throw e;
3797
+ }
3798
+ }
3799
+
3800
+ router.get("/admin/discount-allocation", _pageOrApi(true,
3801
+ R(async function (req, res) {
3802
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
3803
+ var orderId = (url && url.searchParams.get("order_id")) || "";
3804
+ var rows = await _lookupAllocations(orderId);
3805
+ _json(res, 200, { order_id: orderId, rows: rows });
3806
+ }),
3807
+ async function (req, res) {
3808
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
3809
+ var orderId = (url && url.searchParams.get("order_id")) || "";
3810
+ var rows = orderId ? await _lookupAllocations(orderId) : [];
3811
+ var notice = (orderId && rows.length === 0)
3812
+ ? "No discount allocations recorded for that order."
3813
+ : null;
3814
+ _sendHtml(res, 200, renderAdminDiscountAllocation({
3815
+ shop_name: deps.shop_name,
3816
+ nav_available: navAvailable,
3817
+ order_id: orderId,
3818
+ allocations: rows,
3819
+ notice: notice,
3820
+ }));
3821
+ },
3822
+ ));
3823
+ }
3824
+
3744
3825
  // ---- announcements --------------------------------------------------
3745
3826
  // Sitewide promo/notice strip. Content-negotiated like the other console
3746
3827
  // screens: bearer → the JSON contract; signed-in browser → the HTML
@@ -4261,7 +4342,10 @@ function mount(router, deps) {
4261
4342
  shop_name: deps.shop_name, nav_available: navAvailable, orders: [], notice: "Order not found.",
4262
4343
  }));
4263
4344
  }
4264
- return _problem(res, 500, "internal-error", (e && e.message) || String(e));
4345
+ // Record the real error server-side; return a generic 500 with no
4346
+ // detail so a renderer fault never echoes its internals to the client.
4347
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".order.receipt.error", outcome: "failure", metadata: { message: (e && e.message) || String(e) } });
4348
+ return _problem(res, 500, "internal-error");
4265
4349
  }
4266
4350
  _sendHtml(res, 200, _injectMasthead(html, await _shopMastheadHtml()));
4267
4351
  });
@@ -4282,7 +4366,10 @@ function mount(router, deps) {
4282
4366
  shop_name: deps.shop_name, nav_available: navAvailable, orders: [], notice: "Order not found.",
4283
4367
  }));
4284
4368
  }
4285
- return _problem(res, 500, "internal-error", (e && e.message) || String(e));
4369
+ // Record the real error server-side; return a generic 500 with no
4370
+ // detail so a renderer fault never echoes its internals to the client.
4371
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".order.packing_slip.error", outcome: "failure", metadata: { message: (e && e.message) || String(e) } });
4372
+ return _problem(res, 500, "internal-error");
4286
4373
  }
4287
4374
  _sendHtml(res, 200, _injectMasthead(html, await _shopMastheadHtml()));
4288
4375
  });
@@ -4676,6 +4763,16 @@ function mount(router, deps) {
4676
4763
  // the amount; the giftcards primitive enforces the rest.
4677
4764
  async function _issueGiftCard(body) {
4678
4765
  var input = { currency: typeof body.currency === "string" ? body.currency.trim().toUpperCase() : body.currency };
4766
+ // The giftcards primitive only shape-checks the currency (/^[A-Z]{3}$/),
4767
+ // so a well-formed-but-nonexistent code like "ZZZ" would issue a card in
4768
+ // a currency the rest of the shop can't price. Validate against the
4769
+ // framework's ISO 4217 catalog (the same b.money.CURRENCIES surface the
4770
+ // currency-rounding + display primitives compose) and refuse unknown
4771
+ // codes with a clean 400.
4772
+ if (typeof input.currency === "string" && /^[A-Z]{3}$/.test(input.currency) &&
4773
+ !Object.prototype.hasOwnProperty.call(b.money.CURRENCIES, input.currency)) {
4774
+ throw new TypeError("giftcards: currency " + JSON.stringify(input.currency) + " is not a known ISO 4217 code");
4775
+ }
4679
4776
  if (body.amount_minor != null && body.amount_minor !== "") {
4680
4777
  input.amount_minor = _strictMinorInt(body.amount_minor, "giftcards", "amount_minor (minor units)");
4681
4778
  }
@@ -5293,12 +5390,18 @@ function mount(router, deps) {
5293
5390
  // via the hidden marker so a partial JSON edit doesn't flip active.
5294
5391
  if (body.active_present === "1") patch.active = (body.active === "on" || body.active === "1");
5295
5392
  if (typeof body.regions_json === "string" && body.regions_json.trim() !== "") {
5296
- patch.regions = JSON.parse(body.regions_json);
5393
+ // A pasted JSON blob that doesn't parse is operator input, not a
5394
+ // server fault — throw a TypeError so both surfaces (bearer via
5395
+ // _wrap, cookie via the htmlHandler's TypeError catch) degrade to a
5396
+ // clean 400 instead of a 500 that echoes the parser's position.
5397
+ try { patch.regions = JSON.parse(body.regions_json); }
5398
+ catch (_e) { throw new TypeError("shippingZones: regions_json must be valid JSON"); }
5297
5399
  } else if (Array.isArray(body.regions)) {
5298
5400
  patch.regions = body.regions;
5299
5401
  }
5300
5402
  if (typeof body.rates_json === "string" && body.rates_json.trim() !== "") {
5301
- patch.rates = JSON.parse(body.rates_json);
5403
+ try { patch.rates = JSON.parse(body.rates_json); }
5404
+ catch (_e) { throw new TypeError("shippingZones: rates_json must be valid JSON"); }
5302
5405
  } else if (Array.isArray(body.rates)) {
5303
5406
  patch.rates = body.rates;
5304
5407
  }
@@ -6135,6 +6238,7 @@ var ADMIN_NAV_ITEMS = [
6135
6238
  { key: "subscriptions", href: "/admin/subscription-plans", label: "Subscriptions", requires: "subscriptions" },
6136
6239
  { key: "collections", href: "/admin/collections", label: "Collections", requires: "collections" },
6137
6240
  { key: "discounts", href: "/admin/discounts", label: "Discounts", requires: "autoDiscount" },
6241
+ { key: "discount-allocation", href: "/admin/discount-allocation", label: "Discount splits", requires: "discountAllocation" },
6138
6242
  { key: "quantity-discounts", href: "/admin/quantity-discounts", label: "Quantity breaks", requires: "quantityDiscounts" },
6139
6243
  { key: "tax", href: "/admin/tax-rates", label: "Tax", requires: "taxRates" },
6140
6244
  { key: "tax-filings", href: "/admin/tax-filings", label: "Tax filings", requires: "salesTaxFilings" },
@@ -8674,6 +8778,60 @@ function renderAdminCollections(opts) {
8674
8778
  return _renderAdminShell(opts.shop_name, "Collections", bodyHtml, "collections", opts.nav_available);
8675
8779
  }
8676
8780
 
8781
+ // Read-only view of the per-line discount-allocation audit trail for one
8782
+ // order. The operator looks an order up by id; each recorded allocation
8783
+ // renders as a header (source, kind, total, recorded-at) plus a table of
8784
+ // the per-line breakdown. Allocations are system-written by checkout —
8785
+ // there's no create/edit affordance here. Amounts are exact minor-unit
8786
+ // integers (the audit row carries no currency; the figures are the same
8787
+ // integers the refund math reads back), so the operator sees the precise
8788
+ // recorded split rather than a rounded display value.
8789
+ function renderAdminDiscountAllocation(opts) {
8790
+ opts = opts || {};
8791
+ var orderId = typeof opts.order_id === "string" ? opts.order_id : "";
8792
+ var allocations = opts.allocations || [];
8793
+ var notice = opts.notice ? "<div class=\"banner banner--warn\">" + _htmlEscape(opts.notice) + "</div>" : "";
8794
+
8795
+ var lookupForm =
8796
+ "<div class=\"panel mw-40\">" +
8797
+ "<h3 class=\"subhead\">Look up an order</h3>" +
8798
+ "<p class=\"meta\">When an order carried a cart-level discount, the split across its lines is recorded here for refund precision. Enter an order id to see its recorded allocations.</p>" +
8799
+ "<form method=\"get\" action=\"/admin/discount-allocation\">" +
8800
+ _setupField("Order id", "order_id", orderId, "text", "The order's id (shown on the order detail page).", " maxlength=\"200\"") +
8801
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Look up</button></div>" +
8802
+ "</form>" +
8803
+ "</div>";
8804
+
8805
+ var results = "";
8806
+ if (allocations.length) {
8807
+ results = allocations.map(function (a) {
8808
+ var breakdown = Array.isArray(a.breakdown) ? a.breakdown : [];
8809
+ var lineRows = breakdown.map(function (l) {
8810
+ return "<tr>" +
8811
+ "<td><code class=\"order-id\">" + _htmlEscape(String(l.line_id)) + "</code></td>" +
8812
+ "<td class=\"num\">" + _htmlEscape(String(l.allocated_minor)) + "</td>" +
8813
+ "<td class=\"num\">" + _htmlEscape(String(l.remaining_minor)) + "</td>" +
8814
+ "</tr>";
8815
+ }).join("");
8816
+ var lineTable = breakdown.length
8817
+ ? "<table><thead><tr><th scope=\"col\">Line</th><th scope=\"col\" class=\"num\">Allocated (minor)</th><th scope=\"col\" class=\"num\">Remaining (minor)</th></tr></thead><tbody>" + lineRows + "</tbody></table>"
8818
+ : "<p class=\"empty\">This allocation has no recorded lines.</p>";
8819
+ return "<div class=\"panel mt\">" +
8820
+ "<h3 class=\"subhead\">" + _htmlEscape(a.source || "—") + "</h3>" +
8821
+ "<p class=\"meta\">" +
8822
+ "Split: <strong>" + _htmlEscape(a.kind || "—") + "</strong>" +
8823
+ " &middot; Total: <strong>" + _htmlEscape(String(a.total_minor)) + "</strong> minor units" +
8824
+ " &middot; Recorded: " + _htmlEscape(_fmtDate(a.applied_at)) +
8825
+ "</p>" +
8826
+ lineTable +
8827
+ "</div>";
8828
+ }).join("");
8829
+ }
8830
+
8831
+ var bodyHtml = "<section><h2>Discount splits</h2>" + notice + lookupForm + results + "</section>";
8832
+ return _renderAdminShell(opts.shop_name, "Discount splits", bodyHtml, "discount-allocation", opts.nav_available);
8833
+ }
8834
+
8677
8835
  // A <datetime-local> form value → epoch ms (integer) for the lib's
8678
8836
  // starts_at/expires_at, or null when blank/unparseable (open-ended). The
8679
8837
  // announcement primitive validates the result; a blank schedule field just
@@ -9564,6 +9722,7 @@ module.exports = {
9564
9722
  renderAdminShipping: renderAdminShipping,
9565
9723
  renderAdminShippingZone: renderAdminShippingZone,
9566
9724
  renderAdminDiscounts: renderAdminDiscounts,
9725
+ renderAdminDiscountAllocation: renderAdminDiscountAllocation,
9567
9726
  renderAdminQuantityDiscounts: renderAdminQuantityDiscounts,
9568
9727
  renderAdminQuantityDiscount: renderAdminQuantityDiscount,
9569
9728
  renderAdminConfirm: renderAdminConfirm,
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.3.9",
2
+ "version": "0.3.11",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-1SIn6oAf1DjECbRfKENZasdKHJiywGdXR58wn0hsFGcdVHzUmvfgMkEz5ANIAZJ3",
package/lib/checkout.js CHANGED
@@ -157,6 +157,16 @@ function create(deps) {
157
157
  // rule's savings are a shipping concern and are left to the shipping
158
158
  // primitive, not folded into the subtotal discount here.
159
159
  var autoDiscount = deps.autoDiscount || null;
160
+ // Optional discount-allocation recorder. When wired, a placed order
161
+ // that carried a cart-level discount gets a per-line breakdown of how
162
+ // that discount split across the order lines, recorded as a back-office
163
+ // audit row so a later partial refund knows each line's discounted
164
+ // share. POST-COMMIT + drop-silent: it runs after the order exists, in
165
+ // its own try/catch, so a recording failure can never fail, reverse, or
166
+ // change the charge of a placed order. No discount -> nothing recorded.
167
+ // Disabled when absent — the buy path is byte-identical to the un-wired
168
+ // flow.
169
+ var discountAllocation = deps.discountAllocation || null;
160
170
 
161
171
  // Reprice a list of cart lines through the quantity-discount engine.
162
172
  // Returns a shallow copy with `unit_amount_minor` overwritten by the
@@ -440,6 +450,72 @@ function create(deps) {
440
450
  }
441
451
  }
442
452
 
453
+ // Record HOW a cart-level order discount split across the order lines,
454
+ // for accounting + partial-refund precision. The recorded breakdown's
455
+ // per-line `allocated_minor` values sum exactly to the discount, so a
456
+ // later partial refund of one line can remove that line's share rather
457
+ // than the whole-order amount.
458
+ //
459
+ // POST-COMMIT + drop-silent — by design: the order is already placed
460
+ // (and, in the charged path, the PaymentIntent already exists) by the
461
+ // time this runs. The entire body is wrapped in one try/catch so a
462
+ // recording failure (bad input, DB error, an unmigrated table) can
463
+ // NEVER fail, reverse, or change the charge of a placed order. This is
464
+ // pure back-office bookkeeping with zero impact on the charged amount.
465
+ //
466
+ // No-op when: the recorder isn't wired, the order carried no discount
467
+ // (discount_minor <= 0), or the order has no lines to split across.
468
+ // The default split is `proportional` (by line subtotal) — the
469
+ // operator-readable "each line absorbs its share of the savings"
470
+ // distribution.
471
+ async function _recordDiscountAllocation(quote, orderLines, orderId) {
472
+ if (!discountAllocation ||
473
+ typeof discountAllocation.allocate !== "function" ||
474
+ typeof discountAllocation.recordAllocation !== "function") return;
475
+ try {
476
+ var discount = quote && quote.totals ? quote.totals.discount_minor : 0;
477
+ if (!Number.isInteger(discount) || discount <= 0) return; // no discount -> record nothing
478
+ if (!Array.isArray(orderLines) || orderLines.length === 0) return;
479
+
480
+ // Build the allocate() line shape from the placed order lines. The
481
+ // line_id is the PERSISTED order-line id, so a later partial refund —
482
+ // which operates on order lines — can apply the breakdown directly
483
+ // without re-mapping by (variant, sku). The per-line subtotal is
484
+ // unit_amount_minor * qty — the same per-line
485
+ // figure pricing.subtotal summed into quote.totals.subtotal_minor,
486
+ // so the discount (clamped to <= subtotal upstream) never exceeds
487
+ // the total line weight.
488
+ var lines = [];
489
+ for (var i = 0; i < orderLines.length; i += 1) {
490
+ var l = orderLines[i];
491
+ var lineId = l.line_id != null ? l.line_id : l.id;
492
+ if (lineId == null) return; // no stable id to key the breakdown on
493
+ lines.push({
494
+ line_id: String(lineId),
495
+ subtotal_minor: l.unit_amount_minor * l.qty,
496
+ quantity: l.qty,
497
+ });
498
+ }
499
+
500
+ var breakdown = discountAllocation.allocate({
501
+ lines: lines,
502
+ discount_minor: discount,
503
+ kind: "proportional",
504
+ });
505
+ await discountAllocation.recordAllocation({
506
+ order_id: orderId,
507
+ discount_source: "auto_discount",
508
+ kind: "proportional",
509
+ breakdown: breakdown,
510
+ total_minor: discount,
511
+ });
512
+ } catch (_e) {
513
+ // drop-silent — by design: the order is already placed; a failed
514
+ // allocation record is back-office bookkeeping and must never
515
+ // surface to the buyer or affect the charge.
516
+ }
517
+ }
518
+
443
519
  // Compose a quote from a cart + ship-to + (optional) selected
444
520
  // shipping service. Pure read — no DB writes.
445
521
  async function _buildQuote(input) {
@@ -619,6 +695,10 @@ function create(deps) {
619
695
  // placed order. Runs post-commit so a recording failure can't
620
696
  // reverse an order that's already paid.
621
697
  await _recordAutoDiscounts(quote, paidOrder.id, cartRow.customer_id || null);
698
+ // Best-effort: record how the cart-level discount split across the
699
+ // order lines (accounting + refund precision). Post-commit +
700
+ // drop-silent — no impact on the already-settled order.
701
+ await _recordDiscountAllocation(quote, paidOrder.lines, paidOrder.id);
622
702
  await cart.setStatus(quote.cart_id, "converted");
623
703
  var settled = await order.transition(paidOrder.id, "mark_paid", {
624
704
  reason: loy && !gc ? "loyalty:full" : "gift_card:full",
@@ -676,6 +756,11 @@ function create(deps) {
676
756
  // placed order. Runs post-commit so a recording failure can't
677
757
  // reverse or fail an order whose PaymentIntent already exists.
678
758
  await _recordAutoDiscounts(quote, createdOrder.id, cartRow.customer_id || null);
759
+ // Best-effort: record how the cart-level discount split across the
760
+ // order lines (accounting + refund precision). Post-commit +
761
+ // drop-silent — the PaymentIntent already exists; this never
762
+ // changes the charged amount.
763
+ await _recordDiscountAllocation(quote, createdOrder.lines, createdOrder.id);
679
764
 
680
765
  // Mark the cart converted so a refresh of the storefront
681
766
  // doesn't accidentally re-quote the same cart.
@@ -116,6 +116,15 @@ function _ownerId(s) {
116
116
  return s;
117
117
  }
118
118
 
119
+ // Hostnames that name an internal/metadata destination by name rather
120
+ // than by IP literal — the same denylist the delivery primitive
121
+ // (lib/webhooks.js) applies. A literal-IP host is classified directly via
122
+ // b.ssrfGuard.classify (no DNS); these names cover the by-name reach.
123
+ var SUBSCRIPTION_BLOCKED_HOSTS = Object.freeze({
124
+ "localhost": 1,
125
+ "metadata.google.internal": 1,
126
+ });
127
+
119
128
  function _endpointUrl(url) {
120
129
  if (typeof url !== "string" || url.length === 0) {
121
130
  throw new TypeError("webhookSubscriptions: endpoint_url must be a non-empty string");
@@ -125,12 +134,32 @@ function _endpointUrl(url) {
125
134
  }
126
135
  // safeUrl.parse defaults to ALLOW_HTTP_TLS (https only). The framework
127
136
  // refuses http:// + non-http schemes + user:pass@ userinfo + bracketed
128
- // raw-IP forms at this gate — no separate validation needed here.
137
+ // raw-IP forms at this gate.
138
+ var parsed;
129
139
  try {
130
- b.safeUrl.parse(url);
140
+ parsed = b.safeUrl.parse(url);
131
141
  } catch (e) {
132
142
  throw new TypeError("webhookSubscriptions: endpoint_url — " + (e && e.message || "rejected"));
133
143
  }
144
+ // SSRF: refuse internal / loopback / link-local / cloud-metadata
145
+ // destinations so a registered subscription can't probe the host's own
146
+ // network or the instance-credential metadata service. A literal-IP host
147
+ // is classified via b.ssrfGuard.classify (no DNS); a hostname is matched
148
+ // against the by-name metadata/loopback denylist plus *.internal.
149
+ // DNS-rebinding (a public name resolving to a private IP) is out of scope
150
+ // here by design — resolving the name would add a DNS dependency the
151
+ // registration path avoids; the resolving guard belongs on the delivery
152
+ // dial.
153
+ // Strip a trailing FQDN dot (and IPv6 brackets) BEFORE every check: a
154
+ // trailing dot is semantically the same host but otherwise evades both the
155
+ // by-name denylist ("localhost." / "metadata.google.internal.") and the
156
+ // literal-IP classifier ("169.254.169.254." won't parse as an IP).
157
+ var host = (parsed.hostname || "").replace(/^\[|\]$/g, "").toLowerCase().replace(/\.+$/, "");
158
+ if (b.ssrfGuard.classify(host) ||
159
+ SUBSCRIPTION_BLOCKED_HOSTS[host] ||
160
+ host === "internal" || /\.internal$/.test(host)) {
161
+ throw new TypeError("webhookSubscriptions: endpoint_url host is not allowed (internal/loopback/metadata address)");
162
+ }
134
163
  return url;
135
164
  }
136
165
 
package/lib/webhooks.js CHANGED
@@ -85,17 +85,56 @@ var RATE_WINDOW_MS = C.TIME.minutes(1);
85
85
 
86
86
  function _now() { return Date.now(); }
87
87
 
88
+ // Hostnames that name an internal/metadata destination by name rather
89
+ // than by IP literal. The cloud-metadata services answer on a hostname
90
+ // as well as the 169.254.169.254 link-local IP; localhost resolves to
91
+ // loopback. A literal-IP host is classified directly via b.ssrfGuard
92
+ // (no DNS); these names cover the by-name reach of the same targets.
93
+ var WEBHOOK_BLOCKED_HOSTS = Object.freeze({
94
+ "localhost": 1,
95
+ "metadata.google.internal": 1,
96
+ });
97
+
88
98
  function _validateUrl(url) {
89
99
  if (typeof url !== "string" || url.length === 0) {
90
100
  throw new TypeError("webhooks: url must be a non-empty string");
91
101
  }
92
102
  // Refuse http:// outright for outbound — the framework's safe-url
93
103
  // parser carries the ALLOW_HTTP_TLS preset that admits only https.
104
+ var parsed;
94
105
  try {
95
- b.safeUrl.parse(url, { allowedProtocols: ["https:"] });
106
+ parsed = b.safeUrl.parse(url, { allowedProtocols: ["https:"] });
96
107
  } catch (e) {
97
108
  throw new TypeError("webhooks: url must be https:// — " + (e && e.message || "rejected"));
98
109
  }
110
+ // SSRF: refuse any internal / loopback / link-local / cloud-metadata
111
+ // destination so a registered receiver can't be turned into a probe of
112
+ // the host's own network or the instance-credential metadata service.
113
+ // A literal-IP host is classified directly via b.ssrfGuard.classify
114
+ // (loopback / private / link-local / reserved / cloud-metadata all
115
+ // refused, IPv4 + IPv6 + IPv4-mapped); a hostname is matched against
116
+ // the by-name metadata/loopback denylist plus the *.internal suffix.
117
+ // DNS-rebinding (a public name that resolves to a private IP) is OUT OF
118
+ // SCOPE here by design — resolving the name would add a DNS dependency
119
+ // the outbound path otherwise avoids, and the delivery send path is the
120
+ // place a resolving guard belongs. This gate stops the literal-IP and
121
+ // known-name SSRF reachable directly from operator-supplied input.
122
+ // Strip a trailing FQDN dot (and IPv6 brackets) BEFORE every check: a
123
+ // trailing dot is semantically the same host but otherwise evades both the
124
+ // by-name denylist ("localhost." / "metadata.google.internal.") and the
125
+ // literal-IP classifier ("169.254.169.254." won't parse as an IP).
126
+ var host = (parsed.hostname || "").replace(/^\[|\]$/g, "").toLowerCase().replace(/\.+$/, "");
127
+ var isBlocked = false;
128
+ if (b.ssrfGuard.classify(host)) {
129
+ isBlocked = true; // literal IP in a refused range
130
+ } else if (WEBHOOK_BLOCKED_HOSTS[host]) {
131
+ isBlocked = true; // localhost / metadata.google.internal
132
+ } else if (host === "internal" || /\.internal$/.test(host)) {
133
+ isBlocked = true; // any *.internal cloud-private name
134
+ }
135
+ if (isBlocked) {
136
+ throw new TypeError("webhooks: url host is not allowed (internal/loopback/metadata address)");
137
+ }
99
138
  }
100
139
 
101
140
  function _validateEvents(events) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.3.9",
3
+ "version": "0.3.11",
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": {