@blamejs/blamejs-shop 0.3.42 → 0.3.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.3.42",
2
+ "version": "0.3.44",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-1SIn6oAf1DjECbRfKENZasdKHJiywGdXR58wn0hsFGcdVHzUmvfgMkEz5ANIAZJ3",
@@ -455,6 +455,41 @@ function create(opts) {
455
455
  return _topRatings(input, "topNegativeRatings", "negative");
456
456
  }
457
457
 
458
+ // ---- listFlagged -----------------------------------------------------
459
+
460
+ // The moderation queue: every rating whose comment an operator has
461
+ // flagged, most-recent-first, backed by the (comment_flagged,
462
+ // occurred_at) index. Unlike topNegativeRatings (score-ordered, capped
463
+ // at MAX_TOP_LIMIT), this surfaces ALL flagged comments regardless of
464
+ // their score — so a flagged comment on an otherwise high rating is
465
+ // never hidden behind the score cap. The from/to window is optional and
466
+ // applied only when supplied; the row count is capped by `limit`.
467
+ async function listFlagged(input) {
468
+ input = (input && typeof input === "object") ? input : {};
469
+ var limit = _limit(input.limit, DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT, "limit");
470
+
471
+ var sql = "SELECT * FROM order_ratings WHERE comment_flagged = 1";
472
+ var params = [];
473
+ if (input.from != null || input.to != null) {
474
+ var from = _epoch(input.from, "from");
475
+ var to = _epoch(input.to, "to");
476
+ if (from > to) {
477
+ throw new TypeError("orderRatings.listFlagged: from must be <= to");
478
+ }
479
+ params.push(from);
480
+ sql += " AND occurred_at >= ?" + params.length;
481
+ params.push(to);
482
+ sql += " AND occurred_at <= ?" + params.length;
483
+ }
484
+ params.push(limit);
485
+ sql += " ORDER BY occurred_at DESC, id DESC LIMIT ?" + params.length;
486
+
487
+ var r = await query(sql, params);
488
+ var out = [];
489
+ for (var i = 0; i < r.rows.length; i += 1) out.push(_decode(r.rows[i]));
490
+ return out;
491
+ }
492
+
458
493
  return {
459
494
  RATING_AXES: RATING_AXES.slice(),
460
495
  MIN_RATING: MIN_RATING,
@@ -471,6 +506,7 @@ function create(opts) {
471
506
  responseToCustomer: responseToCustomer,
472
507
  topPositiveRatings: topPositiveRatings,
473
508
  topNegativeRatings: topNegativeRatings,
509
+ listFlagged: listFlagged,
474
510
  };
475
511
  }
476
512
 
package/lib/storefront.js CHANGED
@@ -6128,6 +6128,7 @@ var ORDER_PAGE =
6128
6128
  " </table>\n" +
6129
6129
  " </div>\n" +
6130
6130
  " RAW_ORDER_ACTIONS" +
6131
+ " RAW_ORDER_RATING" +
6131
6132
  " </div>\n" +
6132
6133
  " <aside class=\"order-page__totals\">\n" +
6133
6134
  " RAW_ORDER_TIMELINE" +
@@ -6335,6 +6336,134 @@ function _orderActionsBlock(o) {
6335
6336
  return "<div class=\"order-page__actions\">" + btns.join("") + "</div>";
6336
6337
  }
6337
6338
 
6339
+ // A post-purchase rating is offered on the same window a return is: a paid
6340
+ // (or further-along) order, never a still-pending (unpaid) one nor the
6341
+ // terminal off-ramps (cancelled / refunded). The order FSM has no
6342
+ // `fulfilled` status — the post-payment states are paid / fulfilling /
6343
+ // shipped / delivered — so the rating window mirrors _orderEligibleForReturn
6344
+ // rather than gating on a status the primitive doesn't expose.
6345
+ function _orderEligibleForRating(status) {
6346
+ return status === "paid" || status === "fulfilling" ||
6347
+ status === "shipped" || status === "delivered";
6348
+ }
6349
+
6350
+ // The three rating axes the customer scores, in display order. Labels are
6351
+ // static framework copy; the axis key matches the submitRating field name.
6352
+ var ORDER_RATING_AXES = [
6353
+ { key: "shipping", label: "Shipping", field: "shipping_rating" },
6354
+ { key: "packaging", label: "Packaging", field: "packaging_rating" },
6355
+ { key: "recommend", label: "Recommend us", field: "recommend_rating" },
6356
+ ];
6357
+
6358
+ // 1–5 selector for one rating axis. Rendered as native radio buttons so the
6359
+ // form works with no JavaScript and a screen reader announces each option.
6360
+ // `field` is the submitRating field name; nothing here is operator/customer
6361
+ // input, but the labels are escaped at the sink for consistency.
6362
+ function _ratingAxisField(axis) {
6363
+ var esc = b.template.escapeHtml;
6364
+ var opts = "";
6365
+ for (var v = 1; v <= 5; v += 1) {
6366
+ opts +=
6367
+ "<label class=\"order-rating__option\">" +
6368
+ "<input type=\"radio\" name=\"" + esc(axis.field) + "\" value=\"" + v + "\" required>" +
6369
+ "<span>" + v + "</span>" +
6370
+ "</label>";
6371
+ }
6372
+ return "<fieldset class=\"order-rating__axis\">" +
6373
+ "<legend>" + esc(axis.label) + "</legend>" +
6374
+ "<div class=\"order-rating__scale\">" + opts + "</div>" +
6375
+ "</fieldset>";
6376
+ }
6377
+
6378
+ // The rating form (eligible, not-yet-rated order). Posts to
6379
+ // /orders/:id/rate; the container CSRF injection tokens it automatically
6380
+ // (the action is not an EDGE_POST_PATHS prefix). A correction notice from a
6381
+ // rejected submit (bad value / over-length comment / duplicate) renders
6382
+ // above the form.
6383
+ function _orderRatingForm(o, notice) {
6384
+ var esc = b.template.escapeHtml;
6385
+ var noticeHtml = notice
6386
+ ? "<p class=\"form-notice form-notice--err\" role=\"alert\">" + esc(String(notice)) + "</p>"
6387
+ : "";
6388
+ var axes = ORDER_RATING_AXES.map(_ratingAxisField).join("");
6389
+ return "<section class=\"order-rating order-rating--form\">" +
6390
+ "<h2 class=\"pdp__variants-title\">Rate this order</h2>" +
6391
+ "<p class=\"order-rating__lede\">How did the delivery go? Your feedback helps us improve.</p>" +
6392
+ noticeHtml +
6393
+ "<form class=\"form-stack\" method=\"post\" action=\"/orders/" + esc(String(o.id)) + "/rate\">" +
6394
+ axes +
6395
+ "<label class=\"form-field\"><span>Comment <small>(optional)</small></span>" +
6396
+ "<textarea name=\"comment\" rows=\"3\" maxlength=\"2000\" " +
6397
+ "placeholder=\"Tell us about your delivery experience\"></textarea></label>" +
6398
+ "<div class=\"order-page__actions\">" +
6399
+ "<button type=\"submit\" class=\"btn-primary\">Submit rating</button>" +
6400
+ "</div>" +
6401
+ "</form>" +
6402
+ "</section>";
6403
+ }
6404
+
6405
+ // The submitted rating (the three scores) plus, when present, the
6406
+ // customer's own comment and the operator's public reply. ESCAPE-BY-DEFAULT:
6407
+ // the comment and the reply are spliced from the primitive's pre-escaped
6408
+ // `comment_html` / `response_html` fields (escaped via b.template.escapeHtml
6409
+ // at the primitive's render layer), NEVER the raw `comment` / `response_text`
6410
+ // — so a `<script>`/`onerror` payload a customer typed is inert here. A
6411
+ // flagged comment is suppressed (operators moderate via the admin queue).
6412
+ function _orderRatingDisplay(rating) {
6413
+ var esc = b.template.escapeHtml;
6414
+ var scoreRows = ORDER_RATING_AXES.map(function (axis) {
6415
+ return "<div><dt>" + esc(axis.label) + "</dt>" +
6416
+ "<dd>" + esc(String(rating[axis.field])) + " / 5</dd></div>";
6417
+ }).join("");
6418
+ // comment_html is already escaped by the primitive — splice it, do not
6419
+ // re-escape (double-escaping would render visible entities) and never
6420
+ // reach for rating.comment (the raw, un-escaped string).
6421
+ var commentHtml = (rating.comment_html && !rating.comment_flagged)
6422
+ ? "<div class=\"order-rating__comment\"><h3>Your comment</h3>" +
6423
+ "<p>" + rating.comment_html + "</p></div>"
6424
+ : "";
6425
+ // response_html is the operator's public reply, also pre-escaped — splice
6426
+ // it, never rating.response_text.
6427
+ var responseHtml = rating.response_html
6428
+ ? "<div class=\"order-rating__response\"><h3>Our reply</h3>" +
6429
+ "<p>" + rating.response_html + "</p></div>"
6430
+ : "";
6431
+ return "<section class=\"order-rating order-rating--done\">" +
6432
+ "<h2 class=\"pdp__variants-title\">Your rating</h2>" +
6433
+ "<dl class=\"order-rating__scores\">" + scoreRows + "</dl>" +
6434
+ commentHtml +
6435
+ responseHtml +
6436
+ "</section>";
6437
+ }
6438
+
6439
+ // Resolve the rating panel for the order page: the submitted rating (read-
6440
+ // only display) when one exists, the submission form when the order is
6441
+ // eligible and unrated, and nothing otherwise (a pending / cancelled /
6442
+ // refunded order, or a renderer reached without the ratings primitive
6443
+ // wired). `opts.rating` is the getRating row (or null); `opts.rating_notice`
6444
+ // is a correction message echoed back onto a rejected submit.
6445
+ function _orderRatingBlock(opts) {
6446
+ var o = opts.order;
6447
+ if (opts.rating) return _orderRatingDisplay(opts.rating);
6448
+ if (opts.rating_eligible && _orderEligibleForRating(o.status)) {
6449
+ return _orderRatingForm(o, opts.rating_notice || null);
6450
+ }
6451
+ return "";
6452
+ }
6453
+
6454
+ // Map a ?rate_err= code (set by a rejected rating POST on its PRG redirect)
6455
+ // to a clean, operator-safe correction message rendered above the rating
6456
+ // form. Defensive request reader — an unknown / absent code yields no
6457
+ // notice, never a raw error or a leak. The duplicate / value / length codes
6458
+ // mirror the primitive's refusal classes without echoing its raw message.
6459
+ function _ratingNoticeFor(code) {
6460
+ if (code === "dupe") return "You've already rated this order.";
6461
+ if (code === "value") return "Please give each rating a score from 1 to 5.";
6462
+ if (code === "comment") return "Your comment couldn't be saved — please shorten it and try again.";
6463
+ if (code === "input") return "Please check your rating and try again.";
6464
+ return null;
6465
+ }
6466
+
6338
6467
  function renderOrder(opts) {
6339
6468
  if (!opts || !opts.order) throw new TypeError("storefront.renderOrder: opts.order required");
6340
6469
  var o = opts.order;
@@ -6376,6 +6505,10 @@ function renderOrder(opts) {
6376
6505
  var timelineHtml = _orderTimelineBlock(o.status);
6377
6506
  var trackingHtml = _orderTrackingBlock(shipments);
6378
6507
  var actionsHtml = _orderActionsBlock(o);
6508
+ // Post-purchase rating panel — container-only (session-gated). The route
6509
+ // passes the resolved getRating row (rating) + the eligibility/notice
6510
+ // flags; the edge render never does, so the panel is empty at the edge.
6511
+ var ratingHtml = _orderRatingBlock(opts);
6379
6512
  if (opts.theme) {
6380
6513
  return opts.theme.render("order", {
6381
6514
  title: "Order " + o.id,
@@ -6393,6 +6526,7 @@ function renderOrder(opts) {
6393
6526
  timeline_html: timelineHtml,
6394
6527
  tracking_html: trackingHtml,
6395
6528
  actions_html: actionsHtml,
6529
+ rating_html: ratingHtml,
6396
6530
  can_return: _orderEligibleForReturn(o.status),
6397
6531
  can_reorder: _orderEligibleForReorder(o.status),
6398
6532
  can_cancel: _orderEligibleForCancel(o.status),
@@ -6444,6 +6578,11 @@ function renderOrder(opts) {
6444
6578
  .replace("RAW_ORDER_TRACKING", trackingHtml)
6445
6579
  .replace("RAW_ORDER_ACTIONS", actionsHtml)
6446
6580
  .replace("RAW_SHIP_TO", _shipToAddressBlock(o.ship_to));
6581
+ // The rating panel carries customer/operator free text (already escaped
6582
+ // into comment_html / response_html, but a `$` in that text would still
6583
+ // trip String.replace's dollar substitution) — splice it via the
6584
+ // replacer-function helper, never a replacement-string .replace.
6585
+ body = _spliceRaw(body, "RAW_ORDER_RATING", ratingHtml);
6447
6586
  // Post-purchase cross-sell rail — reuses the catalog grid + product-card
6448
6587
  // markup (so it inherits the storefront's card styling), rendered only
6449
6588
  // when the picker returned something.
@@ -10738,12 +10877,35 @@ function mount(router, deps) {
10738
10877
  }
10739
10878
  } catch (_e) { shipments = []; }
10740
10879
  }
10880
+ // Post-purchase fulfillment rating. Surfaced only on a signed-in
10881
+ // customer's OWN order (the IDOR gate above already proved
10882
+ // o.customer_id === orderAuth.customer_id when set) — a guest order
10883
+ // carries no owner, so the rating form/display is suppressed there.
10884
+ // Best-effort: an absent ratings primitive (or its table unmigrated)
10885
+ // degrades to "no rating panel" rather than 500-ing the order page.
10886
+ var ratingRow = null;
10887
+ var ratingEligible = false;
10888
+ if (deps.orderRatings && o.customer_id && orderAuth && o.customer_id === orderAuth.customer_id) {
10889
+ // Offer the rating surface only AFTER a successful read: an absent /
10890
+ // unmigrated ratings table degrades to "no rating panel" rather than
10891
+ // rendering a form whose POST would then 500 against the same missing
10892
+ // table. Eligibility also tracks the order-status window so the form
10893
+ // shows only when a submit would actually be accepted (the POST gates
10894
+ // on the same _orderEligibleForRating check).
10895
+ try {
10896
+ ratingRow = await deps.orderRatings.getRating({ order_id: o.id });
10897
+ ratingEligible = _orderEligibleForRating(o.status);
10898
+ } catch (_e) { ratingRow = null; ratingEligible = false; }
10899
+ }
10741
10900
  var ordUrl = req.url ? new URL(req.url, "http://localhost") : null;
10742
10901
  _send(res, 200, renderOrder({
10743
10902
  order: o,
10744
10903
  product_lookup: productLookup,
10745
10904
  recommendations: recommendations,
10746
10905
  shipments: shipments,
10906
+ rating: ratingRow,
10907
+ rating_eligible: ratingEligible,
10908
+ rating_notice: ordUrl ? _ratingNoticeFor(ordUrl.searchParams.get("rate_err")) : null,
10747
10909
  reordered: ordUrl ? ordUrl.searchParams.get("reordered") === "1" : false,
10748
10910
  cancelled: ordUrl ? ordUrl.searchParams.get("cancelled") === "1" : false,
10749
10911
  shop_name: shopName,
@@ -14661,6 +14823,97 @@ function mount(router, deps) {
14661
14823
  res.setHeader && res.setHeader("location", "/orders/" + encodeURIComponent(o.id) + "?cancelled=1");
14662
14824
  return res.end ? res.end() : res.send("");
14663
14825
  });
14826
+
14827
+ // POST /orders/:id/rate — a customer rates one of their OWN orders
14828
+ // (shipping / packaging / recommend, plus an optional comment). The
14829
+ // rating's customer_id is pinned to the SESSION customer — never a
14830
+ // form/query field — and the order_id is verified to belong to that
14831
+ // session before submitRating runs. Two refusals guard against IDOR: an
14832
+ // unauthenticated POST goes to login (never near an order), and a
14833
+ // foreign / guest-owned / unknown / malformed order is a clean 404
14834
+ // (never rated, never leaked). Bad input (rating out of [1,5], over-
14835
+ // length comment) and a duplicate submission map to a clean correction
14836
+ // redirect, never a 500 or a raw-error leak. Mounts only when the
14837
+ // ratings primitive is wired.
14838
+ if (deps.orderRatings) {
14839
+ router.post("/orders/:order_id/rate", async function (req, res) {
14840
+ var orderId = req.params && req.params.order_id;
14841
+ // Rating is a logged-in-customer action — resolve the session first
14842
+ // so an unauthenticated POST goes to login, never near the order.
14843
+ var rateAuth = _currentCustomerEnv(req);
14844
+ if (!rateAuth) {
14845
+ res.status(303); res.setHeader && res.setHeader("location", "/account/login");
14846
+ return res.end ? res.end() : res.send("");
14847
+ }
14848
+ var o;
14849
+ try { o = orderId ? await deps.order.get(orderId) : null; }
14850
+ catch (e) { if (e instanceof TypeError) { o = null; } else throw e; }
14851
+ // Ownership gate against IDOR: a missing order, a malformed id, an
14852
+ // order owned by another customer, OR a guest order with no owner
14853
+ // all 404 — the rating never touches an order the caller doesn't own.
14854
+ if (!o || !o.customer_id || o.customer_id !== rateAuth.customer_id) {
14855
+ return _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
14856
+ }
14857
+ // Eligibility gate mirrors the rating window (paid → delivered). A
14858
+ // pending / cancelled / refunded order bounces back unchanged.
14859
+ if (!_orderEligibleForRating(o.status)) {
14860
+ res.status(303);
14861
+ res.setHeader && res.setHeader("location", "/orders/" + encodeURIComponent(o.id));
14862
+ return res.end ? res.end() : res.send("");
14863
+ }
14864
+ var body = req.body || {};
14865
+ // Defensive request-shape readers: the rating axes arrive as form
14866
+ // strings — coerce to integers so the primitive's strict [1,5]
14867
+ // integer validator sees a number (a blank / garbage field becomes
14868
+ // NaN, which the primitive refuses as a clean TypeError → "value").
14869
+ function _scoreField(raw) {
14870
+ // Whole-string integer only. parseInt would silently accept a
14871
+ // crafted "5abc" (→5) or "1.9" (→1) and persist a value the
14872
+ // shopper never chose; require the entire field to be digits so
14873
+ // anything else becomes NaN, which the primitive's strict [1,5]
14874
+ // validator refuses as a clean "value" correction.
14875
+ var s = String(raw == null ? "" : raw).trim();
14876
+ if (!/^-?\d+$/.test(s)) return NaN;
14877
+ var n = Number(s);
14878
+ return Number.isInteger(n) ? n : NaN;
14879
+ }
14880
+ function _rateRedirect(code) {
14881
+ res.status(303);
14882
+ res.setHeader && res.setHeader("location",
14883
+ "/orders/" + encodeURIComponent(o.id) + (code ? "?rate_err=" + code : ""));
14884
+ return res.end ? res.end() : res.send("");
14885
+ }
14886
+ var comment = typeof body.comment === "string" && body.comment.length ? body.comment : undefined;
14887
+ try {
14888
+ await deps.orderRatings.submitRating({
14889
+ order_id: o.id,
14890
+ customer_id: rateAuth.customer_id, // session-pinned, never from the form
14891
+ shipping_rating: _scoreField(body.shipping_rating),
14892
+ packaging_rating: _scoreField(body.packaging_rating),
14893
+ recommend_rating: _scoreField(body.recommend_rating),
14894
+ comment: comment,
14895
+ });
14896
+ } catch (e) {
14897
+ // Coded errors first (a plain Error with .code), then the
14898
+ // primitive's TypeErrors — so a duplicate surfaces its own notice
14899
+ // rather than the generic bad-input one. Any of these is a clean
14900
+ // correction redirect, never a 500.
14901
+ if (e && e.code === "ORDER_RATING_ALREADY_EXISTS") return _rateRedirect("dupe");
14902
+ if (e instanceof TypeError) {
14903
+ // The comment validator's message names "comment"; everything
14904
+ // else is a rating-value problem. Either way, no raw leak.
14905
+ var isComment = /comment/.test(String(e.message || ""));
14906
+ return _rateRedirect(isComment ? "comment" : "value");
14907
+ }
14908
+ throw e;
14909
+ }
14910
+ // PRG back to the order page — the submitted rating now renders in
14911
+ // place of the form (a refresh doesn't re-submit).
14912
+ res.status(303);
14913
+ res.setHeader && res.setHeader("location", "/orders/" + encodeURIComponent(o.id));
14914
+ return res.end ? res.end() : res.send("");
14915
+ });
14916
+ }
14664
14917
  }
14665
14918
 
14666
14919
  // POST /cart/lines — add a line. Reads variant_id + qty from the
@@ -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.14.15",
7
- "tag": "v0.14.15",
6
+ "version": "0.14.16",
7
+ "tag": "v0.14.16",
8
8
  "license": "Apache-2.0",
9
9
  "author": "blamejs contributors",
10
10
  "source": "https://github.com/blamejs/blamejs",
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.14.x
10
10
 
11
+ - v0.14.16 (2026-05-31) — **Connection entry-point ports are validated at config time.** Six connection entry points previously read opts.port with a bare `|| <default>` fallback, silently coercing a string, negative, NaN, or out-of-range port instead of catching the operator's typo. A new b.validateOpts.optionalPort enforces the RFC 6335 §6 wire-valid range and is wired into b.mail.smtpTransport, b.ntpCheck.querySingle, b.networkDns.useDnsOverTls, b.networkNts (KE handshake / query / facade), b.redisClient.create, and createApp().listen — each now throws at construction with a clear message naming the bad value. The app.listen / createApp bind site opts into allowZero so port 0 (the legitimate ephemeral-bind sentinel) still works; the five outbound-connect sites require [1,65535]. **Added:** *`b.validateOpts.optionalPort`* — A config-time port validator: `optionalPort(value, label, errorClass, code, opts?)` returns an omitted (`undefined` / `null`) port unchanged, and otherwise requires an integer in the RFC 6335 §6 wire-valid range [1,65535] — rejecting a string, negative, NaN, Infinity, fractional, or out-of-range value. Pass `{ allowZero: true }` for a listen-bind site where port 0 is the OS ephemeral-bind sentinel. The thrown message reports the offending shape (so `Infinity` / `"443"` stay visible), and routes a caller-supplied typed framework error (or a plain Error when none is given), matching the existing `optionalPositiveFinite` family. **Changed:** *Connection entry points reject a malformed port at construction* — `b.mail.smtpTransport`, `b.ntpCheck.querySingle`, `b.networkDns.useDnsOverTls`, `b.networkNts.performKeHandshake` / `query` / `querySingle`, `b.redisClient.create`, and `createApp().listen` (plus the `createApp` constructor's default port) now validate `opts.port` and throw synchronously on a non-integer / out-of-range value rather than coercing it through `||` to a default. This is a behavior change for a caller that was passing a non-canonical port (e.g. the string `"587"` or a NaN) and relying on the silent fallback — pass an integer in [1,65535] instead (or `0` for an ephemeral `createApp().listen` bind). `b.ntpCheck` gains a typed `NtpCheckError` for this (it had no error class before). **Detectors:** *Connection entry points must compose the port validator* — A new check flags a lib connection entry point that reads `opts.port` / `opts.kePort` / `opts.ntpPort` with a `|| <default>` fallback without composing `b.validateOpts.optionalPort` (or the equivalent `numericBounds.isPositiveFiniteInt` + 65535 cap), so an unvalidated port read can't slip back in. **Migration:** *Pass an integer port to connection primitives* — If you were passing a non-integer or out-of-range `opts.port` to a mail / NTP / NTS / DNS-over-TLS / Redis transport or to `createApp().listen` and relying on the silent `|| default` fallback, that now throws at construction. Pass an integer in [1,65535]; for an ephemeral `createApp().listen` bind, pass `0` (still accepted).
12
+
11
13
  - v0.14.14 (2026-05-31) — **Recognized consent purposes with lawful-basis gating, and a new b.privacy namespace for annual EdTech vendor-review attestations.** Closes the student-data gap where an educational-only consent purpose and an annual third-party vendor-review report were described but never implemented. b.consent gains a recognized-purpose vocabulary: a purpose value matching a recognized key carries lawful-basis constraints that grant() enforces, and the named educational-only purpose (FERPA's school-official exception and California's SOPIPA) refuses a legitimate_interests lawful basis. The new b.privacy namespace ships vendorReview(), a builder for the dated, clause-by-clause annual EdTech third-party / processor review FERPA and SOPIPA expect a school or district to keep — it computes whether every required clause (no targeted advertising, no commercial profiling, no sale of student data, deletion on request, school-official designation, and so on) is attested, names the gaps, and stamps a 365-day re-review clock. Free-form consent purposes keep working unchanged, so the vocabulary is opt-in and additive. **Added:** *`b.consent` recognized-purpose vocabulary + lawful-basis gating* — `b.consent.recognizedPurpose(name)` looks up a recognized purpose and `b.consent.listPurposes()` enumerates them. When a `grant({ purpose })` value matches a recognized key, `grant()` enforces that purpose's lawful-basis constraints; the `educational-only` purpose forbids a `legitimate_interests` basis (FERPA 34 CFR 99.31(a)(1) school-official exception; California SOPIPA Cal. B&P 22584; FTC school-authorized COPPA consent 16 CFR 312.5(c)(10)) and marks the data commercial-use-prohibited. The commercial-use prohibition is an operator trust-boundary obligation — `isGranted()` does not re-derive it. Any purpose value NOT in the vocabulary stays free-form and unconstrained, so existing callers are unaffected; the hash-chain column set is unchanged, so `b.consent.verify()` over existing rows is unaffected. · *`b.privacy.vendorReview` — annual EdTech vendor-review attestation* — A new `b.privacy` namespace whose `vendorReview(opts)` builds the dated third-party / processor review a FERPA school-official arrangement and California SOPIPA expect for every vendor that touches student data. The operator supplies a boolean attestation per clause (educational-purpose-only, no-targeted-advertising, no-commercial-profiling, no-sale-of-student-data, security-safeguards, deletion-on-request, sub-processor-currency, breach-notification, school-official-designation, directory-information-handling); `vendorReview` validates the shape, computes whether every required clause is attested (`attested`) and which are not (`gaps`), and stamps `reviewedAt` plus a 365-day `nextReviewDueAt` re-review clock. `b.privacy.listVendorReviewClauses()` returns the clause set with citations. Operator-feeds-metadata: the frozen report is not framework-persisted — compose it into your retention / audit / export sink. A best-effort `privacy.vendor_review.recorded` audit event fires when an audit sink is wired. **Detectors:** *A gated consent purpose must go through `b.consent`* — A new check flags any lib code that mints a consent row with a hardcoded `educational-only` purpose literal without composing the recognized-purpose vocabulary — which would record the value while never enforcing its FERPA / SOPIPA lawful-basis constraint.
12
14
 
13
15
  - v0.14.13 (2026-05-31) — **Close advertised-but-missing surface: SRS1 chained forwarding, DCQL array-wildcard claim paths, and in-memory safe-archive extraction.** Three primitives advertised a capability in their documentation or card but refused or omitted it at runtime; this release implements each. b.mail.srs gains srs1Rewrite for the SRS1 double-forward (and multi-hop) case — previously the @intro described SRS1 and create() threw, pointing at a function that was never exported. b.safeArchive gains extractToMemory, the in-memory counterpart to extract for read-only / serverless filesystems — previously the card advertised in-memory extraction but the orchestrator required a destination directory. b.auth.oid4vp.matchDcql now honours a null claims-path segment as the array wildcard the OpenID4VP DCQL spec defines, rather than refusing it as unsupported while the card advertised DCQL. A stale version-pinned wording in a safe-archive error message is corrected. Every change is additive or message-only — no existing caller changes behaviour. **Added:** *`b.mail.srs` SRS1 chained forwarding — `srs1Rewrite`* — `b.mail.srs.create(...)` now returns `srs1Rewrite` alongside `rewrite` / `reverse`. `srs1Rewrite(srsAddress)` chains an already-SRS0 (or SRS1) envelope-from for a further forwarding hop: it keeps the original SRS0 body verbatim, prepends the SRS0 originator's domain, and binds the pair with this forwarder's own HMAC-SHA-256 tag — no new timestamp, no repeated original local-part — emitting `SRS1=tag=originator==<SRS0-body>@thisForwarder`. `reverse()` now detects an SRS1 address, verifies this hop's tag and forwarder-domain binding, and unwraps exactly one hop back to the originator's SRS0 so a multi-hop bounce routes straight to the forwarder that can recover the original sender. Typed failure modes: `srs/not-srs0` (input not SRS-encoded), `srs/malformed` (missing the `==` separator), `srs/bad-tag` (tampered), `srs/too-long` (chain exceeds the RFC 5321 256-octet path limit). Implements the Sender Rewriting Scheme SRS1 wire format; the second-hop SPF rationale is RFC 7208 §2.4. · *`b.safeArchive.extractToMemory` — in-memory safe extraction* — An async generator counterpart to `b.safeArchive.extract` for read-only / serverless filesystems: it resolves the source, sniffs the format, auto-unwraps recipient (`BAWRP`) / passphrase (`BAWPP`) envelopes, and dispatches to the zip / tar / tar.gz reader's in-memory `extractEntries()`, yielding `{ name, bytes, size }` per regular-file entry without ever writing to disk. It takes no `destination`. Every defense the disk path runs applies unchanged: the zip-bomb caps (entry-count / per-entry / total / expansion-ratio), the `b.guardArchive` metadata cascade (Zip-Slip / path-traversal / symlink-escape / encrypted-entry refusal, CVE-2025-3445 class), and the entry-type policy. The disk-only realpath-agreement check (CVE-2025-4517 PATH_MAX TOCTOU defense) is intentionally absent — there is no extraction root — so the archive-level name refusals carry containment. Trusted-stream sources are refused upfront (the adversarial-safe central-directory walk needs random access). gzip magic per RFC 1952 §2.3.1. **Fixed:** *OID4VP DCQL `null` claim-path segment now resolves the array wildcard* — `b.auth.oid4vp.matchDcql` previously threw `auth-oid4vp/null-path-segment-not-supported` for a `null` claims-path segment while the namespace card advertised DCQL — under-disclosing a legitimate presentation (CWE-863). Per OpenID4VP 1.0 §7.1.1 a `null` segment selects all elements of the array at that depth; the matcher now recurses over array elements with existence semantics (with DCQL value-matching applied to any selected leaf), composed to arbitrary depth. A `null` segment on a non-array node — like an integer index into a non-array, or a string key into an array — is a clean non-match, not a thrown error, because the matcher walks holder credential data rather than operator config. String and integer claim paths are byte-identical to before; only queries that previously threw now succeed or fail cleanly. · *safe-archive trusted-stream refusal message no longer cites a stale version* — The thrown `safe-archive/trusted-stream-unsupported` message and its comment claimed trusted-stream extraction was "deferred to v0.12.8 / when the v0.12.8 sequential extract path lands." That path shipped long ago — `b.archive.read.zip.fromTrustedStream` and the tar sequential mode exist — so the message now points at them as present capabilities and drops the version-pinned wording. The error code is unchanged. **Detectors:** *A primitive may not advertise a capability and then throw an unimplemented stub* — A new check flags a bare `not yet supported` / `operator demand TBD` / `not supported in v1` refusal in a lib throw string (comments excluded). A defer is only complete with a written re-open condition; the SRS1 and DCQL stubs that this release implements both carried this bare-defer shape, and the detector keeps it from re-entering. · *DCQL `null` path segments must recurse, never refuse* — A new check flags the `null path segment not supported` refusal shape in `lib/auth/oid4vp.js`, so the spec-mandated array wildcard cannot be re-stubbed. · *`extractToMemory` must stay disk-free* — A new check flags any `writeFileSync` / `renameSync` / `mkdirSync` / `createWriteStream` inside the `extractToMemory` generator body, so the read-only / serverless contract cannot regress into a disk write.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 1,
3
- "frameworkVersion": "0.14.14",
4
- "createdAt": "2026-05-31T19:48:38.125Z",
3
+ "frameworkVersion": "0.14.16",
4
+ "createdAt": "2026-06-01T01:11:20.010Z",
5
5
  "exports": {
6
6
  "a2a": {
7
7
  "type": "object",
@@ -43286,6 +43286,10 @@
43286
43286
  "type": "primitive",
43287
43287
  "valueType": "number"
43288
43288
  },
43289
+ "NtpCheckError": {
43290
+ "type": "function",
43291
+ "arity": 4
43292
+ },
43289
43293
  "bootCheck": {
43290
43294
  "type": "function",
43291
43295
  "arity": 1
@@ -93,6 +93,7 @@ var nodeFs = require("node:fs");
93
93
  var nodePath = require("node:path");
94
94
  var appShutdown = require("./app-shutdown");
95
95
  var audit = require("./audit");
96
+ var validateOpts = require("./validate-opts");
96
97
  var C = require("./constants");
97
98
  var cluster = require("./cluster");
98
99
  var db = require("./db");
@@ -139,6 +140,9 @@ async function createApp(opts) {
139
140
  if (!opts.dataDir || typeof opts.dataDir !== "string") {
140
141
  throw new Error("createApp: opts.dataDir is required");
141
142
  }
143
+ // Constructor-time default port (used by listen() when listenOpts.port is
144
+ // omitted); allowZero for the ephemeral-bind sentinel.
145
+ validateOpts.optionalPort(opts.port, "createApp: opts.port", undefined, undefined, { allowZero: true });
142
146
  var dataDir = nodePath.resolve(opts.dataDir);
143
147
  if (!nodeFs.existsSync(dataDir)) {
144
148
  nodeFs.mkdirSync(dataDir, { recursive: true });
@@ -279,6 +283,10 @@ async function createApp(opts) {
279
283
 
280
284
  function listen(listenOpts) {
281
285
  listenOpts = listenOpts || {};
286
+ // Port 0 is the legitimate ephemeral-bind sentinel for a listen socket
287
+ // (RFC 6335 §6 / POSIX bind), so allowZero — but a non-integer / NaN /
288
+ // out-of-range port is an operator typo that must fail at boot.
289
+ validateOpts.optionalPort(listenOpts.port, "createApp.listen: listenOpts.port", undefined, undefined, { allowZero: true });
282
290
  var port = (listenOpts.port !== undefined) ? listenOpts.port
283
291
  : (opts.port !== undefined) ? opts.port
284
292
  : 0;
@@ -767,6 +767,7 @@ function smtpTransport(opts) {
767
767
  "dkimSigner must be an object with a .sign(rfc822) method " +
768
768
  "(see b.mail.dkim.create)", true);
769
769
  }
770
+ validateOpts.optionalPort(opts.port, "smtp transport: opts.port", MailError, "mail/smtp-misconfigured");
770
771
  var port = opts.port || 587;
771
772
  var useImplicitTLS = port === 465 || opts.implicitTls === true;
772
773
  var rejectUnauthorized = opts.rejectUnauthorized !== false;
@@ -253,6 +253,7 @@ function useDnsOverTls(opts) {
253
253
  opts = opts || {};
254
254
  validateOpts(opts, ["host", "port", "servername", "ca"], "dns.useDnsOverTls");
255
255
  validateOpts.requireNonEmptyString(opts.host, "dns.useDnsOverTls: host", DnsError, "dns/bad-dot-host");
256
+ validateOpts.optionalPort(opts.port, "dns.useDnsOverTls: opts.port", DnsError, "dns/bad-dot-port");
256
257
  if (opts.ca !== undefined && opts.ca !== null &&
257
258
  !Buffer.isBuffer(opts.ca) && typeof opts.ca !== "string" && !Array.isArray(opts.ca)) {
258
259
  throw new DnsError("dns/bad-dot-ca",
@@ -241,6 +241,7 @@ function performKeHandshake(opts) {
241
241
  opts = opts || {};
242
242
  validateOpts(opts, ["host", "port", "servername", "aead", "ca", "timeoutMs"], "nts.performKeHandshake");
243
243
  validateOpts.requireNonEmptyString(opts.host, "nts.performKeHandshake: host", NtsError, "nts/bad-host");
244
+ validateOpts.optionalPort(opts.port, "nts.performKeHandshake: opts.port", NtsError, "nts/bad-ke-port");
244
245
  var timeoutMs = opts.timeoutMs || C.TIME.seconds(10);
245
246
  return new Promise(function (resolve, reject) {
246
247
  var settled = false;
@@ -408,6 +409,7 @@ function _walkExtensions(msg, startOff) {
408
409
  function querySingle(opts) {
409
410
  opts = opts || {};
410
411
  validateOpts(opts, ["host", "port", "aeadId", "c2sKey", "s2cKey", "cookies", "timeoutMs"], "nts.querySingle");
412
+ validateOpts.optionalPort(opts.port, "nts.querySingle: opts.port", NtsError, "nts/bad-ntp-port");
411
413
  if (!Buffer.isBuffer(opts.c2sKey) || opts.c2sKey.length === 0) {
412
414
  throw new NtsError("nts/no-c2s-key", "nts.querySingle: c2sKey required (Buffer)");
413
415
  }
@@ -542,6 +544,8 @@ function querySingle(opts) {
542
544
  async function query(opts) {
543
545
  opts = opts || {};
544
546
  validateOpts(opts, ["host", "kePort", "ntpPort", "aead", "ca", "timeoutMs", "servername"], "nts.query");
547
+ validateOpts.optionalPort(opts.kePort, "nts.query: opts.kePort", NtsError, "nts/bad-ke-port");
548
+ validateOpts.optionalPort(opts.ntpPort, "nts.query: opts.ntpPort", NtsError, "nts/bad-ntp-port");
545
549
  var ke = await performKeHandshake({
546
550
  host: opts.host,
547
551
  port: opts.kePort,
@@ -48,10 +48,16 @@ var dgram = require("node:dgram");
48
48
  var C = require("./constants");
49
49
  var lazyRequire = require("./lazy-require");
50
50
  var safeAsync = require("./safe-async");
51
+ var validateOpts = require("./validate-opts");
52
+ var { defineClass } = require("./framework-error");
51
53
 
52
54
  var audit = lazyRequire(function () { return require("./audit"); });
53
55
  var observability = lazyRequire(function () { return require("./observability"); });
54
56
 
57
+ // Config-time misuse (a bad opts.port) throws a typed, permanent error so an
58
+ // operator catches the typo at boot rather than as a Promise rejection.
59
+ var NtpCheckError = defineClass("NtpCheckError", { alwaysPermanent: true });
60
+
55
61
  // NTP epoch: 1900-01-01. Unix epoch: 1970-01-01. Offset: 70 years incl. 17
56
62
  // leap days = 2,208,988,800 seconds.
57
63
  var NTP_TO_UNIX_OFFSET_SECONDS = 2208988800;
@@ -166,6 +172,7 @@ function _resetThresholdsForTest() {
166
172
  */
167
173
  function querySingle(server, opts) {
168
174
  opts = opts || {};
175
+ validateOpts.optionalPort(opts.port, "ntpCheck.querySingle: opts.port", NtpCheckError, "ntp/bad-port");
169
176
  var port = opts.port || DEFAULT_PORT;
170
177
  var timeoutMs = opts.timeoutMs || DEFAULT_TIMEOUT_MS;
171
178
 
@@ -445,6 +452,7 @@ function monitor(opts) {
445
452
 
446
453
  module.exports = {
447
454
  querySingle: querySingle,
455
+ NtpCheckError: NtpCheckError,
448
456
  checkDrift: checkDrift,
449
457
  bootCheck: bootCheck,
450
458
  monitor: monitor,
@@ -155,9 +155,17 @@ function _frameToValue(frame) {
155
155
  function create(opts) {
156
156
  opts = opts || {};
157
157
  validateOpts.requireNonEmptyString(opts.url, "redis.create: opts.url", RedisError, "BAD_OPTS");
158
+ // Validate an operator-supplied opts.port up front for a clear typo
159
+ // message (e.g. the string "6379" or a negative value).
160
+ validateOpts.optionalPort(opts.port, "redis.create: opts.port", RedisError, "BAD_OPTS");
158
161
  var parsed = _parseRedisUrl(opts.url);
159
162
  var host = opts.host || parsed.host;
160
163
  var port = opts.port || parsed.port;
164
+ // Re-validate the RESOLVED port. A url-supplied port (redis://h:0,
165
+ // redis://h:99999) is not range-checked by _parseRedisUrl, so without
166
+ // this an outbound connect could inherit a zero / out-of-range port that
167
+ // the opts.port guard above never sees.
168
+ validateOpts.optionalPort(port, "redis.create: resolved port (opts.port or url)", RedisError, "BAD_OPTS");
161
169
  var useTls = opts.tls !== undefined ? !!opts.tls : parsed.tls;
162
170
  var password = opts.password !== undefined ? opts.password : parsed.password;
163
171
  var username = opts.username !== undefined ? opts.username : parsed.username;
@@ -27,6 +27,8 @@
27
27
  * a typed error wrap the call.
28
28
  */
29
29
 
30
+ var numericBounds = require("./numeric-bounds");
31
+
30
32
  function _format(primitive, unknownKey, allowedKeys) {
31
33
  return primitive + ": unknown option '" + unknownKey + "'. " +
32
34
  "Allowed keys: " + allowedKeys.slice().sort().join(", ") + ".";
@@ -150,6 +152,26 @@ function optionalFunction(value, label, errorClass, code) {
150
152
  return value;
151
153
  }
152
154
 
155
+ // optionalPort — a TCP/UDP port number must be an integer in the wire-valid
156
+ // range (RFC 6335 §6). Outbound-connect sites require [1,65535]; pass
157
+ // { allowZero: true } for a listen-bind site where port 0 is the legitimate
158
+ // ephemeral-bind sentinel the OS replaces with a kernel-assigned port. Uses
159
+ // numericBounds.shape() in the message so Infinity / NaN / "443" stay visible.
160
+ function optionalPort(value, label, errorClass, code, opts) {
161
+ if (value === undefined || value === null) return value;
162
+ opts = opts || {};
163
+ var ok = opts.allowZero
164
+ ? (numericBounds.isNonNegativeFiniteInt(value) && value <= 65535)
165
+ : (numericBounds.isPositiveFiniteInt(value) && value <= 65535);
166
+ if (!ok) {
167
+ _throw(errorClass, code, (label || "opt") + " must be " +
168
+ (opts.allowZero ? "0 (ephemeral) or " : "") +
169
+ "an integer in [" + (opts.allowZero ? 0 : 1) + ",65535], got " + numericBounds.shape(value),
170
+ "validate-opts/bad-port");
171
+ }
172
+ return value;
173
+ }
174
+
153
175
  // applyDefaults — resolve every key in DEFAULTS against opts. For each
154
176
  // key, the operator's value (if not undefined) wins; otherwise the
155
177
  // default is used. Returns a new plain object — NOT a frozen one, so
@@ -391,6 +413,7 @@ module.exports.optionalBoolean = optionalBoolean;
391
413
  module.exports.optionalPositiveInt = optionalPositiveInt;
392
414
  module.exports.optionalFiniteNonNegative = optionalFiniteNonNegative;
393
415
  module.exports.optionalPositiveFinite = optionalPositiveFinite;
416
+ module.exports.optionalPort = optionalPort;
394
417
  module.exports.optionalFunction = optionalFunction;
395
418
  module.exports.optionalNonEmptyString = optionalNonEmptyString;
396
419
  module.exports.optionalNonEmptyStringArray = optionalNonEmptyStringArray;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.14.14",
3
+ "version": "0.14.16",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",