@blamejs/blamejs-shop 0.3.41 → 0.3.43

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.43 (2026-05-31) — **Export orders for a date range as CSV or NDJSON, with scheduled exports.** There was no way to pull a bulk export of orders over a date range. A new Exports screen in the admin console previews how many orders and how much revenue a date range covers, then downloads them as a CSV or NDJSON file. The export can also be scheduled and managed as a queued job. Customer email addresses are exported only as a hash, never in the clear, and any field that could be interpreted as a spreadsheet formula is neutralized so an exported file can't run code when opened. This release also updates the vendored blamejs runtime to v0.14.16. **Added:** *Order export* — An Exports screen in the admin console exports orders over a chosen date range. It first shows a preview — the order count, revenue, and average order value for the range — then downloads the orders as a CSV or NDJSON file with a standard set of columns. Exports can also be queued as scheduled jobs and cancelled before they run. Customer email is exported as a hash rather than in the clear, and any cell that begins with a character a spreadsheet would treat as a formula is escaped, so an exported file can't execute anything when opened in Excel or Sheets. **Changed:** *Vendored blamejs runtime updated to v0.14.16* — The bundled blamejs runtime is updated to v0.14.16, which validates connection ports at configuration time — a malformed port (a non-integer or out-of-range value) is now rejected with a clear error at startup instead of silently falling back to a default. No operator action is required.
12
+
13
+ - v0.3.42 (2026-05-31) — **The blog now pages through every post, and the byline shows the shop name.** The blog index showed only the 12 most recent posts with no way to reach the rest, so older posts were a dead end for anyone browsing — even though they stayed in the sitemap. The index now has previous/next pagination, so every published post is reachable. The post byline and the article's structured data now show the shop name instead of an internal author identifier, and a missing blog post or product page no longer returns a body on a HEAD request. **Fixed:** *Blog index pagination* — The blog index listed only the 12 most recent posts and offered no way to reach older ones from /blog. It now has previous/next pagination — the same control used on collection and search pages — so every published post is reachable, with the next link appearing only when there is genuinely another page. · *Blog byline shows the shop name, not an internal id* — The blog post byline and the article's structured data displayed the raw internal author identifier. They now show the shop name, so the public byline and the author Google reads from the structured data are meaningful rather than an internal id. · *Missing pages don't return a body on HEAD* — A missing blog post or product page returned a full page body even for a HEAD request. Those 404 responses now omit the body on HEAD, matching the other pages and the HTTP spec.
14
+
11
15
  - v0.3.41 (2026-05-31) — **The public product API no longer leaks internal error details, and bad order links return Not Found.** A server-side error on the public product API could include the raw internal error message — potentially database or query details — in its 500 response to anonymous callers. Those responses now return a generic message, with the real error recorded server-side only, matching how the admin side already behaves. Separately, opening an order or payment page with a malformed link now returns a clean Not Found instead of a server error. This release also updates the vendored blamejs runtime to v0.14.15. **Changed:** *Vendored blamejs runtime updated to v0.14.15* — The bundled blamejs runtime is updated to v0.14.15, which adds a recognized-purpose vocabulary to its consent ledger (with lawful-basis enforcement for regulated purposes) and a new privacy namespace for periodic third-party vendor-review attestations. These are additive, opt-in capabilities; existing behavior is unchanged and no operator action is required. **Fixed:** *Public product API hides internal error details* — When the public product API (the catalog endpoints) hit a server-side error, its 500 response could carry the raw internal error message — including database or constraint text — to any caller. It now returns a generic "something went wrong" message and records the real error server-side only, the same way the admin console already scrubs its errors. Validation errors still return their helpful 400 message unchanged. · *Malformed order and payment links return Not Found* — Opening an order confirmation or payment page with a malformed (non-identifier) link returned a server error instead of a Not Found. Both pages now return a clean 404 for a malformed or unknown link, matching the order reorder and cancel pages.
12
16
 
13
17
  - v0.3.40 (2026-05-31) — **Return shipping labels — operators issue one, customers download and track it.** After a return was approved there was no way to give the customer a shipping label or for them to follow the parcel back. Operators can now issue a return label on an approved return from the admin console — entering the carrier, tracking number, and the label link generated in their carrier account — and post tracking updates as the parcel moves. The customer sees the label on their return: they can open or print it, see the carrier and tracking number, and follow the scan history, with the return marked received once it arrives back. A customer can only see and download the label for their own return. **Added:** *Return shipping labels and tracking* — Opening a return in the account area now shows its detail, and once a label has been issued the customer can open or print the return label, see the carrier and tracking number, and follow the parcel's scan history — all scoped so a customer only ever sees their own return. On the operator side, an approved return gains an Issue return label form (carrier, service level, weight, the carrier label link, tracking number, and cost) and controls to mark the parcel shipped, in transit, delivered, or held with an exception; marking it delivered records the return as received. The label link is required to be a secure URL, and the customer download is served without exposing that link in the page.
package/lib/admin.js CHANGED
@@ -503,6 +503,7 @@ function mount(router, deps) {
503
503
  var customerSegments = deps.customerSegments || null; // per-customer segment-membership panel disabled when absent
504
504
  var orderTracking = deps.orderTracking || null; // shipment/tracking panel disabled when absent
505
505
  var salesReports = deps.salesReports || null; // /admin/reports degrades to an unconfigured notice when absent
506
+ var orderExport = deps.orderExport || null; // /admin/exports (date-range CSV/NDJSON dump + scheduled-export queue) disabled when absent
506
507
  var printReceipts = deps.printReceipts || null; // order receipt document disabled when absent
507
508
  var packingSlips = deps.packingSlips || null; // order packing-slip document disabled when absent
508
509
  var pickLists = deps.pickLists || null; // warehouse pick-list console disabled when absent
@@ -519,7 +520,7 @@ function mount(router, deps) {
519
520
  // `reports` is always present in the nav (read-only sales summary needs no
520
521
  // extra dep); its route mounts unconditionally and renders an unconfigured
521
522
  // notice when the salesReports primitive isn't wired.
522
- var navAvailable = { returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, preorder: !!deps.preorder, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, customerSegments: !!customerSegments, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, blog: !!deps.blog, knowledgeBase: !!deps.knowledgeBase, customerSurveys: !!deps.customerSurveys, storefrontPages: !!deps.storefrontPages, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, autoDiscount: !!deps.autoDiscount, discountAllocation: !!deps.discountAllocation, quantityDiscounts: !!deps.quantityDiscounts, loyalty: !!deps.loyalty, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels, supportTickets: !!supportTickets, orderExchanges: !!orderExchanges };
523
+ var navAvailable = { returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, preorder: !!deps.preorder, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, customerSegments: !!customerSegments, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, blog: !!deps.blog, knowledgeBase: !!deps.knowledgeBase, customerSurveys: !!deps.customerSurveys, storefrontPages: !!deps.storefrontPages, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, autoDiscount: !!deps.autoDiscount, discountAllocation: !!deps.discountAllocation, quantityDiscounts: !!deps.quantityDiscounts, loyalty: !!deps.loyalty, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels, supportTickets: !!supportTickets, orderExchanges: !!orderExchanges, orderExport: !!orderExport };
523
524
 
524
525
  try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
525
526
 
@@ -6429,6 +6430,286 @@ function mount(router, deps) {
6429
6430
  });
6430
6431
  }
6431
6432
 
6433
+ // ---- order export ---------------------------------------------------
6434
+ //
6435
+ // Bulk, date-range dump of the orders table to CSV / NDJSON, plus the
6436
+ // queue of operator-filed scheduled-export jobs a background worker
6437
+ // drains. Distinct from the per-order receipt CSV columns and the
6438
+ // /admin/reports day-series ?format=csv (an aggregate, not the rows):
6439
+ // this is the full row-level order set for offline analysis,
6440
+ // accounting reconciliation, or a 3PL handoff.
6441
+ //
6442
+ // Surfaces (mounted only when the orderExport primitive is wired):
6443
+ // GET /admin/exports — screen: range form +
6444
+ // summary preview (count + total) + the scheduled-export list +
6445
+ // a schedule-a-new-export form (bearer JSON returns the summary +
6446
+ // the export rows).
6447
+ // GET /admin/exports/download?from=&to=&format=
6448
+ // — file response: streams
6449
+ // csvForRange / ndjsonForRange with the right content-type +
6450
+ // content-disposition. The primitive owns the CSV-injection
6451
+ // defense (a leading = / + / - / @ cell is prefixed with `'`),
6452
+ // so the route composes its output and never hand-rolls
6453
+ // serialization from raw order fields.
6454
+ // POST /admin/exports/schedule — file a scheduled job.
6455
+ // POST /admin/exports/:id/cancel — cancel a queued job.
6456
+ //
6457
+ // Range: from/to are calendar-date (YYYY-MM-DD) inputs from the browser,
6458
+ // or raw epoch-ms for the JSON API. The window is half-open [from, to):
6459
+ // the inclusive end day advances to the next UTC midnight so the chosen
6460
+ // day is fully covered. An empty or inverted range is a config/entry-tier
6461
+ // 400 (the operator corrects the typo), surfaced via _safeNotice.
6462
+ if (orderExport) {
6463
+ var EXPORT_FORMATS = orderExport.FORMATS || ["csv", "ndjson"];
6464
+
6465
+ // Resolve the export window from the query string into epoch-ms
6466
+ // [from, to). Reuses the report screen's date helpers: the epoch-ms
6467
+ // `from`/`to` pair wins for machine clients; the calendar-date
6468
+ // `from-date`/`to-date` pair is what the browser date inputs submit
6469
+ // (the `to-date` end day is inclusive, so step to the next UTC midnight).
6470
+ // Both bounds are required — there is no implicit "last 30 days" default
6471
+ // for a bulk dump, so a missing bound is a clean 400 rather than a
6472
+ // surprise full-table export. Throws TypeError on a malformed/empty/
6473
+ // inverted range; the orderExport primitive re-validates (non-negative
6474
+ // integers, to >= from).
6475
+ function _exportWindow(url) {
6476
+ var from = _parseEpochMs(url && url.searchParams.get("from"), "from");
6477
+ var to = _parseEpochMs(url && url.searchParams.get("to"), "to");
6478
+ if (from == null) from = _parseDateParam(url && url.searchParams.get("from-date"), "from");
6479
+ if (to == null) {
6480
+ var toDate = _parseDateParam(url && url.searchParams.get("to-date"), "to");
6481
+ if (toDate != null) to = toDate + b.constants.TIME.days(1);
6482
+ }
6483
+ if (from == null || to == null) {
6484
+ throw new TypeError("admin: a from and a to date are both required for an export");
6485
+ }
6486
+ if (to <= from) {
6487
+ throw new TypeError("admin: the to date must be after the from date");
6488
+ }
6489
+ return { from: from, to: to };
6490
+ }
6491
+
6492
+ function _exportFormat(url) {
6493
+ var f = (url && url.searchParams.get("format")) || "csv";
6494
+ if (EXPORT_FORMATS.indexOf(f) === -1) {
6495
+ throw new TypeError("admin: format must be one of " + EXPORT_FORMATS.join(", "));
6496
+ }
6497
+ return f;
6498
+ }
6499
+
6500
+ // Stream the chosen format's async-iterable into the response body.
6501
+ // csvForRange / ndjsonForRange yield the header (CSV) then one chunk
6502
+ // per database batch; the route writes each batch to the socket as it
6503
+ // is produced, so the process holds at most one batch in memory no
6504
+ // matter how many orders the range covers (the resumable streamCursor
6505
+ // / scheduled-export queue remains the path for an out-of-band dump).
6506
+ // The primitive RFC-4180-quotes every CSV cell and neutralizes the
6507
+ // spreadsheet-formula injection vector, so the route never touches the
6508
+ // cell bytes.
6509
+ async function _streamExport(res, win, format) {
6510
+ var iter = format === "ndjson"
6511
+ ? orderExport.ndjsonForRange({ from: win.from, to: win.to })
6512
+ : orderExport.csvForRange({ from: win.from, to: win.to });
6513
+ var contentType = format === "ndjson"
6514
+ ? "application/x-ndjson; charset=utf-8"
6515
+ : "text/csv; charset=utf-8";
6516
+ var ext = format === "ndjson" ? "ndjson" : "csv";
6517
+ var fromDay = _dateInputValue(win.from);
6518
+ var toDay = _dateInputValue(win.to - b.constants.TIME.days(1));
6519
+ var filename = "orders-" + (fromDay || "start") + "-to-" + (toDay || "end") + "." + ext;
6520
+ res.status(200);
6521
+ if (res.setHeader) {
6522
+ res.setHeader("content-type", contentType);
6523
+ // The filename is built from validated epoch-ms date strings
6524
+ // (digits + hyphens only), so it carries no quote/CRLF that could
6525
+ // break out of the header — but keep it ASCII-safe regardless.
6526
+ res.setHeader("content-disposition", "attachment; filename=\"" + filename.replace(/[^A-Za-z0-9._-]/g, "") + "\"");
6527
+ res.setHeader("x-content-type-options", "nosniff");
6528
+ }
6529
+ // Write the header first, then one chunk per batch as the primitive
6530
+ // yields it — bounded memory regardless of the order count. A
6531
+ // response without an incremental write() (a JSON test stub) falls
6532
+ // back to buffering the date-bounded window.
6533
+ if (typeof res.write === "function" && typeof res.end === "function") {
6534
+ for await (var chunk of iter) {
6535
+ if (chunk) res.write(chunk);
6536
+ }
6537
+ res.end();
6538
+ } else {
6539
+ var body = "";
6540
+ for await (var c2 of iter) body += c2;
6541
+ if (res.end) res.end(body); else res.send(body);
6542
+ }
6543
+ }
6544
+
6545
+ router.get("/admin/exports", _pageOrApi(true,
6546
+ R(async function (req, res) {
6547
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
6548
+ // A download link carries ?download=1 (+ from/to/format); the bearer
6549
+ // API serves it as a file too so tooling can pull the dump directly.
6550
+ if (url && url.searchParams.get("download")) {
6551
+ var winD, fmtD;
6552
+ try { winD = _exportWindow(url); fmtD = _exportFormat(url); }
6553
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
6554
+ return _streamExport(res, winD, fmtD);
6555
+ }
6556
+ // Summary preview for a chosen range (omitted when no range given).
6557
+ var summary = null, win = null;
6558
+ if ((url && url.searchParams.get("from")) || (url && url.searchParams.get("from-date"))) {
6559
+ try { win = _exportWindow(url); }
6560
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
6561
+ summary = await orderExport.summaryForRange({ from: win.from, to: win.to });
6562
+ }
6563
+ var listing = await orderExport.listExports({ limit: 50 });
6564
+ _json(res, 200, { summary: summary, range: win, exports: listing.rows || [] });
6565
+ }),
6566
+ async function (req, res) {
6567
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
6568
+ // Download from the browser surface too (a link, not a fetch).
6569
+ if (url && url.searchParams.get("download")) {
6570
+ var win0, fmt0;
6571
+ try { win0 = _exportWindow(url); fmt0 = _exportFormat(url); }
6572
+ catch (e) {
6573
+ var nd = _safeNotice(e, "export.download");
6574
+ var rowsD = (await orderExport.listExports({ limit: 50 })).rows || [];
6575
+ return _sendHtml(res, nd.status, renderAdminExports({
6576
+ shop_name: deps.shop_name, nav_available: navAvailable, exports: rowsD,
6577
+ formats: EXPORT_FORMATS, notice: nd.message.replace(/^admin:\s*/, ""),
6578
+ }));
6579
+ }
6580
+ return _streamExport(res, win0, fmt0);
6581
+ }
6582
+ // Optional summary preview for a chosen range. A malformed/empty/
6583
+ // inverted range re-renders the screen with a correction notice
6584
+ // (config/entry tier) rather than 500-ing.
6585
+ var summary = null, win = null, format = "csv", notice = null;
6586
+ var hasRange = (url && url.searchParams.get("from-date")) || (url && url.searchParams.get("from"));
6587
+ if (hasRange) {
6588
+ try {
6589
+ win = _exportWindow(url);
6590
+ format = _exportFormat(url);
6591
+ summary = await orderExport.summaryForRange({ from: win.from, to: win.to });
6592
+ } catch (e) {
6593
+ if (!(e instanceof TypeError)) throw e;
6594
+ notice = _safeNotice(e, "export.summary").message.replace(/^admin:\s*/, "");
6595
+ win = null; summary = null;
6596
+ }
6597
+ }
6598
+ var rows = (await orderExport.listExports({ limit: 50 })).rows || [];
6599
+ _sendHtml(res, 200, renderAdminExports({
6600
+ shop_name: deps.shop_name, nav_available: navAvailable, exports: rows,
6601
+ formats: EXPORT_FORMATS, summary: summary, range: win, format: format,
6602
+ scheduled: url && url.searchParams.get("scheduled"),
6603
+ cancelled: url && url.searchParams.get("cancelled"),
6604
+ notice: notice,
6605
+ }));
6606
+ },
6607
+ ));
6608
+
6609
+ // The dedicated download route — a plain link target (and a bearer GET
6610
+ // for tooling). Same file response on both surfaces.
6611
+ router.get("/admin/exports/download", _pageOrApi(true,
6612
+ R(async function (req, res) {
6613
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
6614
+ var win, format;
6615
+ try { win = _exportWindow(url); format = _exportFormat(url); }
6616
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
6617
+ return _streamExport(res, win, format);
6618
+ }),
6619
+ async function (req, res) {
6620
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
6621
+ var win, format;
6622
+ try { win = _exportWindow(url); format = _exportFormat(url); }
6623
+ catch (e) {
6624
+ if (!(e instanceof TypeError)) throw e;
6625
+ var n = _safeNotice(e, "export.download");
6626
+ var rows = (await orderExport.listExports({ limit: 50 })).rows || [];
6627
+ return _sendHtml(res, n.status, renderAdminExports({
6628
+ shop_name: deps.shop_name, nav_available: navAvailable, exports: rows,
6629
+ formats: EXPORT_FORMATS, notice: n.message.replace(/^admin:\s*/, ""),
6630
+ }));
6631
+ }
6632
+ return _streamExport(res, win, format);
6633
+ },
6634
+ ));
6635
+
6636
+ // File a scheduled export job. Range + format are validated here (the
6637
+ // same _exportWindow / _exportFormat shape the download uses); the
6638
+ // primitive re-validates and persists a `queued` row a worker drains.
6639
+ router.post("/admin/exports/schedule", _pageOrApi(false,
6640
+ W("export.schedule", async function (req, res) {
6641
+ var body = req.body || {};
6642
+ var job = await orderExport.scheduleExport({
6643
+ format: body.format,
6644
+ from: _strictNonNegIntField(body.from, "from"),
6645
+ to: _strictNonNegIntField(body.to, "to"),
6646
+ });
6647
+ _json(res, 201, job);
6648
+ return { id: job.id };
6649
+ }),
6650
+ async function (req, res) {
6651
+ var body = req.body || {};
6652
+ // The browser form submits calendar-date inputs; coerce to the
6653
+ // epoch-ms [from, to) window the primitive expects.
6654
+ try {
6655
+ var win = _exportWindow(new URL("http://localhost/?" +
6656
+ "from-date=" + encodeURIComponent(body["from-date"] || "") +
6657
+ "&to-date=" + encodeURIComponent(body["to-date"] || "")));
6658
+ var format = (body.format && EXPORT_FORMATS.indexOf(body.format) !== -1) ? body.format : null;
6659
+ if (!format) throw new TypeError("admin: format must be one of " + EXPORT_FORMATS.join(", "));
6660
+ await orderExport.scheduleExport({ format: format, from: win.from, to: win.to });
6661
+ } catch (e) {
6662
+ var n = _safeNotice(e, "export.schedule");
6663
+ var rows = (await orderExport.listExports({ limit: 50 })).rows || [];
6664
+ return _sendHtml(res, n.status, renderAdminExports({
6665
+ shop_name: deps.shop_name, nav_available: navAvailable, exports: rows,
6666
+ formats: EXPORT_FORMATS, notice: n.message.replace(/^admin:\s*/, ""),
6667
+ }));
6668
+ }
6669
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".export.schedule", outcome: "success" });
6670
+ _redirect(res, "/admin/exports?scheduled=1");
6671
+ },
6672
+ ));
6673
+
6674
+ // Cancel a queued scheduled export. A malformed id is a 400; an unknown
6675
+ // id (or a job past `queued`, which the FSM refuses) is surfaced as a
6676
+ // correction notice on the browser path / a 4xx on the bearer path.
6677
+ router.post("/admin/exports/:id/cancel", _pageOrApi(false,
6678
+ W("export.cancel", async function (req, res) {
6679
+ var row;
6680
+ try { row = await orderExport.cancelExport(req.params.id); }
6681
+ catch (e) {
6682
+ // The FSM refuses a cancel once the job is running/terminal — a
6683
+ // coded conflict (409), checked first. It is a plain Error with a
6684
+ // .code, never a TypeError; the message is operator-safe (it names
6685
+ // the refused transition, never storage internals).
6686
+ if (e && e.code) return _problem(res, 409, "conflict", "That export can no longer be cancelled.");
6687
+ // A syntactically valid id that names no job is a not-found
6688
+ // TypeError — distinct from a malformed id — so map it to 404 (a
6689
+ // malformed id stays a 400) and clients can tell the two apart.
6690
+ if (e instanceof TypeError && / not found$/.test(String(e.message || ""))) {
6691
+ return _problem(res, 404, "export-not-found");
6692
+ }
6693
+ if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
6694
+ throw e;
6695
+ }
6696
+ // cancelExport throws on a missing row, so a falsy return is purely
6697
+ // defensive — keep the 404 in case the primitive ever returns null.
6698
+ if (!row) return _problem(res, 404, "export-not-found");
6699
+ _json(res, 200, row);
6700
+ return { id: row.id };
6701
+ }),
6702
+ async function (req, res) {
6703
+ var ok = false;
6704
+ try { ok = !!(await orderExport.cancelExport(req.params.id)); }
6705
+ catch (_e) { ok = false; }
6706
+ if (!ok) return _redirect(res, "/admin/exports?err=1");
6707
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".export.cancel", outcome: "success", metadata: { id: req.params.id } });
6708
+ _redirect(res, "/admin/exports?cancelled=1");
6709
+ },
6710
+ ));
6711
+ }
6712
+
6432
6713
  // ---- analytics ------------------------------------------------------
6433
6714
 
6434
6715
  var analytics = deps.analytics || null;
@@ -9277,6 +9558,7 @@ var ADMIN_NAV_ITEMS = [
9277
9558
  { key: "inventory", href: "/admin/inventory", label: "Inventory" },
9278
9559
  { key: "orders", href: "/admin/orders", label: "Orders" },
9279
9560
  { key: "reports", href: "/admin/reports", label: "Reports" },
9561
+ { key: "exports", href: "/admin/exports", label: "Exports", requires: "orderExport" },
9280
9562
  { key: "customers", href: "/admin/customers", label: "Customers", requires: "customers" },
9281
9563
  { key: "segments", href: "/admin/segments", label: "Segments", requires: "customerSegments" },
9282
9564
  { key: "returns", href: "/admin/returns", label: "Returns", requires: "returns" },
@@ -9702,6 +9984,114 @@ function renderAdminReports(opts) {
9702
9984
  return _renderAdminShell(opts.shop_name, "Reports", body, "reports", opts.nav_available);
9703
9985
  }
9704
9986
 
9987
+ // Order-export screen. A date-range + format picker drives a summary
9988
+ // preview (order count + total for the chosen window) and the two download
9989
+ // links (CSV / NDJSON); below it the scheduled-export queue lists every
9990
+ // operator-filed job with a per-job cancel, and a schedule-a-new-export
9991
+ // form files one. `opts.summary` is null until the operator picks a range;
9992
+ // `opts.exports` is the scheduled-export rows.
9993
+ var EXPORT_STATUS_PILL = {
9994
+ queued: "pending",
9995
+ running: "fulfilling",
9996
+ complete: "paid",
9997
+ failed: "refunded",
9998
+ cancelled: "cancelled",
9999
+ };
10000
+ function renderAdminExports(opts) {
10001
+ opts = opts || {};
10002
+ var exportsRows = opts.exports || [];
10003
+ var formats = opts.formats || ["csv", "ndjson"];
10004
+ var summary = opts.summary || null;
10005
+ var range = opts.range || null;
10006
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
10007
+ var scheduled = opts.scheduled ? "<div class=\"banner banner--ok\">Export scheduled.</div>" : "";
10008
+ var cancelled = opts.cancelled ? "<div class=\"banner banner--ok\">Export cancelled.</div>" : "";
10009
+
10010
+ // The range form prefills with the chosen window (or blank). It GETs back
10011
+ // to /admin/exports so the window lives in the URL (bookmarkable) and the
10012
+ // summary preview re-renders; the two download links carry the same window
10013
+ // + the format as a ?download=1 query.
10014
+ var fromVal = range ? _dateInputValue(range.from) : "";
10015
+ var toVal = range ? _dateInputValue(range.to - b.constants.TIME.days(1)) : "";
10016
+
10017
+ var rangeForm =
10018
+ "<form method=\"get\" action=\"/admin/exports\" class=\"order-filters\">" +
10019
+ "<label class=\"form-field\"><span>From</span><input type=\"date\" name=\"from-date\" value=\"" + _htmlEscape(fromVal) + "\" required></label>" +
10020
+ "<label class=\"form-field\"><span>To</span><input type=\"date\" name=\"to-date\" value=\"" + _htmlEscape(toVal) + "\" required></label>" +
10021
+ "<button class=\"btn\" type=\"submit\">Preview range</button>" +
10022
+ "</form>";
10023
+
10024
+ // Summary preview + download links (only once a valid range is chosen).
10025
+ var previewBlock = "";
10026
+ if (summary && range) {
10027
+ // Pick a headline currency from the by_currency map (the busiest
10028
+ // bucket); fall back to USD when the window is empty.
10029
+ var curBuckets = Object.keys(summary.by_currency || {});
10030
+ var headlineCur = curBuckets.length ? curBuckets[0] : "USD";
10031
+ var qWin = "from=" + encodeURIComponent(String(range.from)) + "&to=" + encodeURIComponent(String(range.to));
10032
+ var dlLinks = formats.map(function (f) {
10033
+ return "<a class=\"btn btn--ghost\" href=\"/admin/exports/download?" + qWin + "&format=" + encodeURIComponent(f) + "\">Download " + _htmlEscape(f.toUpperCase()) + "</a>";
10034
+ }).join("");
10035
+ previewBlock =
10036
+ "<section><h2>Range preview</h2>" +
10037
+ "<p class=\"meta\">Window: " + _htmlEscape(_fmtDate(range.from)) + " → " + _htmlEscape(_fmtDate(range.to - b.constants.TIME.days(1))) + " (inclusive)</p>" +
10038
+ "<div class=\"stat-grid\">" +
10039
+ _statCard("Orders in range", String(summary.order_count)) +
10040
+ _statCard("Revenue", pricing.format(summary.revenue_minor, headlineCur)) +
10041
+ _statCard("Avg order value", pricing.format(summary.average_order_minor, headlineCur)) +
10042
+ "</div>" +
10043
+ "<div class=\"actions-row mt\">" + dlLinks + "</div>" +
10044
+ "</section>";
10045
+ }
10046
+
10047
+ // Scheduled-export queue table.
10048
+ var jobRows = exportsRows.map(function (j) {
10049
+ var pill = EXPORT_STATUS_PILL[j.status] || "pending";
10050
+ var from = j.from_ts != null ? _fmtDate(j.from_ts) : "—";
10051
+ var to = j.to_ts != null ? _fmtDate(j.to_ts) : "—";
10052
+ var rowCount = (j.row_count != null) ? String(j.row_count) : "—";
10053
+ var enc = _htmlEscape(encodeURIComponent(j.id));
10054
+ var cancelCell = j.status === "queued"
10055
+ ? "<form method=\"post\" action=\"/admin/exports/" + enc + "/cancel\" class=\"form-inline\">" +
10056
+ "<button class=\"btn btn--danger\" type=\"submit\">Cancel</button></form>"
10057
+ : "<span class=\"meta\">—</span>";
10058
+ return "<tr>" +
10059
+ "<td><code class=\"order-id\">" + _htmlEscape(String(j.format || "")) + "</code></td>" +
10060
+ "<td>" + _htmlEscape(from) + " → " + _htmlEscape(to) + "</td>" +
10061
+ "<td><span class=\"status-pill " + pill + "\">" + _htmlEscape(String(j.status || "")) + "</span></td>" +
10062
+ "<td class=\"num\">" + _htmlEscape(rowCount) + "</td>" +
10063
+ "<td>" + cancelCell + "</td>" +
10064
+ "</tr>";
10065
+ }).join("");
10066
+ var jobTable = exportsRows.length
10067
+ ? "<div class=\"panel\"><table><thead><tr><th scope=\"col\">Format</th><th scope=\"col\">Window</th><th scope=\"col\">Status</th><th scope=\"col\" class=\"num\">Rows</th><th scope=\"col\">Actions</th></tr></thead><tbody>" + jobRows + "</tbody></table></div>"
10068
+ : "<p class=\"empty\">No scheduled exports yet.</p>";
10069
+
10070
+ var scheduleFmtOptions = formats.map(function (f) {
10071
+ return "<option value=\"" + _htmlEscape(f) + "\">" + _htmlEscape(f.toUpperCase()) + "</option>";
10072
+ }).join("");
10073
+ var scheduleForm =
10074
+ "<div class=\"panel mt mw-42\">" +
10075
+ "<h3 class=\"subhead\">Schedule an export</h3>" +
10076
+ "<p class=\"meta\">Queue a date-range dump for a background worker to produce. The job appears below as “queued” and can be cancelled until the worker claims it.</p>" +
10077
+ "<form method=\"post\" action=\"/admin/exports/schedule\">" +
10078
+ "<label class=\"form-field\"><span>From</span><input type=\"date\" name=\"from-date\" required></label>" +
10079
+ "<label class=\"form-field\"><span>To</span><input type=\"date\" name=\"to-date\" required></label>" +
10080
+ "<label class=\"form-field\"><span>Format</span><select name=\"format\">" + scheduleFmtOptions + "</select></label>" +
10081
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Schedule export</button></div>" +
10082
+ "</form>" +
10083
+ "</div>";
10084
+
10085
+ var body =
10086
+ "<section><h2>Exports</h2>" + notice + scheduled + cancelled +
10087
+ "<p class=\"meta\">Dump the full order set for a date range to CSV or NDJSON — for accounting, analysis, or a fulfilment-partner handoff. Customer email is pseudonymised in the output.</p>" +
10088
+ rangeForm +
10089
+ "</section>" +
10090
+ previewBlock +
10091
+ "<section><h2>Scheduled exports</h2>" + jobTable + scheduleForm + "</section>";
10092
+ return _renderAdminShell(opts.shop_name, "Exports", body, "exports", opts.nav_available);
10093
+ }
10094
+
9705
10095
  // A single shipping-label row for the standalone cross-order tables. Shows
9706
10096
  // carrier + service, tracking number, cost (via pricing.format from the
9707
10097
  // label's own minor units + currency), the broker it came from, its status
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.3.41",
2
+ "version": "0.3.43",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-1SIn6oAf1DjECbRfKENZasdKHJiywGdXR58wn0hsFGcdVHzUmvfgMkEz5ANIAZJ3",
@@ -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",
@@ -0,0 +1,45 @@
1
+ {
2
+ "$schema": "../scripts/release-notes-schema.json",
3
+ "version": "0.14.16",
4
+ "date": "2026-05-31",
5
+ "headline": "Connection entry-point ports are validated at config time",
6
+ "summary": "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].",
7
+ "sections": [
8
+ {
9
+ "heading": "Added",
10
+ "items": [
11
+ {
12
+ "title": "`b.validateOpts.optionalPort`",
13
+ "body": "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."
14
+ }
15
+ ]
16
+ },
17
+ {
18
+ "heading": "Changed",
19
+ "items": [
20
+ {
21
+ "title": "Connection entry points reject a malformed port at construction",
22
+ "body": "`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)."
23
+ }
24
+ ]
25
+ },
26
+ {
27
+ "heading": "Detectors",
28
+ "items": [
29
+ {
30
+ "title": "Connection entry points must compose the port validator",
31
+ "body": "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."
32
+ }
33
+ ]
34
+ },
35
+ {
36
+ "heading": "Migration",
37
+ "items": [
38
+ {
39
+ "title": "Pass an integer port to connection primitives",
40
+ "body": "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)."
41
+ }
42
+ ]
43
+ }
44
+ ]
45
+ }
@@ -2743,6 +2743,33 @@ async function testNoDuplicateCodeBlocks() {
2743
2743
  ],
2744
2744
  reason: "v0.12.42 — validateOpts-then-guard prelude shared by three builder-style functions: cert.create mints a certificate, mail-send-deliver.deliver sends a message, vc.present builds + signs a Verifiable Presentation. The common shingle is the `validateOpts(allowedKeys) + required-field / non-empty-array guards + typed-error throw` idiom; the bodies diverge entirely (X.509 minting / SMTP delivery / VC-JOSE-COSE presentation envelope). Same validate-then-guard family as the v0.12.29 / v0.12.33 / v0.12.40 clusters.",
2745
2745
  },
2746
+ {
2747
+ mode: "family-subset",
2748
+ files: [
2749
+ "lib/api-key.js:_validateIssueOpts",
2750
+ "lib/http-client-cache.js:create",
2751
+ "lib/network-dns.js:useDnsOverTls",
2752
+ ],
2753
+ reason: "v0.14.15 — validateOpts config-validation prelude (validateOpts(allowedKeys) + requireNonEmptyString + the new optionalPort port-range guard + typed-error throw). api-key._validateIssueOpts validates an API-key issue spec; http-client-cache.create validates a cache config; network-dns.useDnsOverTls validates a DoT endpoint. Wiring validateOpts.optionalPort into the DoT site tipped its preamble past the 50-token shingle threshold against two unrelated config-time primitives. Bodies diverge entirely; the shared primitive IS validateOpts (already extracted) — coupling these specs would couple three unrelated standards on a syntactic accident. Verified coincidental (absent on clean main; the 3 functions are unrelated). Same validate-then-guard family as the v0.12.29 / v0.12.42 clusters.",
2754
+ },
2755
+ {
2756
+ mode: "family-subset",
2757
+ files: [
2758
+ "lib/agent-idempotency.js:_safeAudit",
2759
+ "lib/agent-orchestrator.js:_safeAudit",
2760
+ "lib/redis-client.js:_frameToValue",
2761
+ ],
2762
+ reason: "v0.14.15 — coincidental boilerplate shingle surfaced when the validateOpts.optionalPort wiring was added to redis-client.create: the file's token stream shifted enough that a 50-token window in redis-client.js clusters with the identical _safeAudit audit-emit wrappers in agent-idempotency / agent-orchestrator (a try/catch around audit().safeEmit). _safeAudit drop-silently emits an audit event; redis-client._frameToValue parses a RESP wire frame — entirely unrelated behaviour sharing only a generic guard/try-catch idiom. Verified absent on clean main; no shared logic to extract.",
2763
+ },
2764
+ {
2765
+ mode: "family-subset",
2766
+ files: [
2767
+ "lib/compliance-sanctions-fetcher.js:create",
2768
+ "lib/network-nts.js:performKeHandshake",
2769
+ "lib/web-push-vapid.js:buildVapidAuthHeader",
2770
+ ],
2771
+ reason: "v0.14.15 — validateOpts config-validation prelude again: network-nts.performKeHandshake gained the optionalPort port-range guard, tipping its preamble past the shingle threshold against compliance-sanctions-fetcher.create (sanctions-list fetch config) and web-push-vapid.buildVapidAuthHeader (VAPID JWT auth header build). Three unrelated specs (NTS-KE handshake / sanctions fetch / Web-Push VAPID) sharing only the validateOpts-then-guard idiom; the shared primitive is validateOpts. Verified coincidental (absent on clean main). Same family as the v0.12.29 / v0.12.42 clusters.",
2772
+ },
2746
2773
  {
2747
2774
  mode: "family-subset",
2748
2775
  files: [
@@ -6523,6 +6550,8 @@ var KNOWN_ANTIPATTERNS = [
6523
6550
 
6524
6551
  { id: "consent-purposes-null-proto", primitive: "the recognized-purpose map (PURPOSES) must be a null-prototype object so an operator-supplied free-form purpose colliding with an Object.prototype member (toString / constructor / __proto__) resolves to undefined, not the prototype value", scanScope: "lib", regex: /var PURPOSES\s*=\s*Object\.freeze\(/, requires: /Object\.create\(null\)/, allowlist: [], reason: "v0.14.14 Codex P2 on PR #295 (CWE-1321) — recognizedPurpose(name) + grant() index PURPOSES[purpose] with an operator-controlled value; a plain-prototype map returns Object.prototype.toString (truthy) for purpose \"toString\", breaking the null-for-free-form contract and entering grant()'s recognized branch for a value listPurposes() never exposes. PURPOSES is now Object.freeze(Object.assign(Object.create(null), {...})) so every unrecognized key resolves to undefined. Detector requires the null-prototype declaration so the lookup can't silently revert to a plain object." },
6525
6552
 
6553
+ { id: "connect-entry-point-port-must-compose-optionalPort", primitive: "a connection entry point reading opts.port / opts.kePort / opts.ntpPort with a `|| <default>` fallback must first validate it via validateOpts.optionalPort (or, where a permanent typed error is needed, the equivalent numericBounds.isPositiveFiniteInt(opts.port) + 65535 cap) — an unvalidated opts.port || N silently accepts a string / negative / NaN / out-of-range port", scanScope: "lib", regex: /\bopts\.(?:port|kePort|ntpPort)\s*\|\|/, requires: /validateOpts\.optionalPort\(|isPositiveFiniteInt\(opts\.port\)/, allowlist: [], reason: "v0.14.15 — the connection entry points (mail.smtpTransport, ntpCheck.querySingle, dns.useDnsOverTls, nts.performKeHandshake / querySingle / query, redis.create) read opts.port || <default>, silently coercing a string / negative / NaN / >65535 port; rule §5 says config-time entry points THROW so the operator catches the typo at boot. Each now composes validateOpts.optionalPort (RFC 6335 §6 [1,65535]; allowZero for the app.listen ephemeral bind) — or, where a MailError-permanent typed error is needed, the same numericBounds.isPositiveFiniteInt + 65535 rule inline. The requires-companion clears a file once it validates; a new entry point reading opts.port || N without composing the validator trips the gate." },
6554
+
6526
6555
  {
6527
6556
  // ROTATION-EPOCH ACCEPT (v0.14.x): a vault-key rotation (b.vault.rotate)
6528
6557
  // re-keys the local dataDir, which changes the SHA3-512 fingerprint of
@@ -0,0 +1,70 @@
1
+ "use strict";
2
+ // b.validateOpts.optionalPort — RFC 6335 §6 port-range validator — plus the
3
+ // representative connection entry-point wiring (ntpCheck.querySingle; the other
4
+ // five sites compose the identical guard).
5
+
6
+ var helpers = require("../helpers");
7
+ var b = helpers.b;
8
+ var check = helpers.check;
9
+ var redisClient = require("../../lib/redis-client");
10
+
11
+ function _thrCode(fn) { try { fn(); return null; } catch (e) { return e.code || e.message; } }
12
+
13
+ function run() {
14
+ var vo = b.validateOpts;
15
+ check("optionalPort is a function", typeof vo.optionalPort === "function");
16
+
17
+ // pass-through + valid ports
18
+ check("optionalPort(undefined) → undefined", vo.optionalPort(undefined) === undefined);
19
+ check("optionalPort(null) → null", vo.optionalPort(null) === null);
20
+ check("optionalPort(443) → 443", vo.optionalPort(443, "p") === 443);
21
+ check("optionalPort(1) accepted", vo.optionalPort(1, "p") === 1);
22
+ check("optionalPort(65535) accepted", vo.optionalPort(65535, "p") === 65535);
23
+
24
+ // 0 rejected by default, accepted with allowZero (ephemeral listen-bind)
25
+ check("optionalPort(0) throws by default", _thrCode(function () { vo.optionalPort(0, "p"); }) !== null);
26
+ check("optionalPort(0, allowZero) → 0", vo.optionalPort(0, "p", undefined, undefined, { allowZero: true }) === 0);
27
+
28
+ // out-of-range / non-integer / wrong-type all throw
29
+ [65536, -1, 0.5, "443", NaN, Infinity, "x", true, {}].forEach(function (bad, i) {
30
+ check("optionalPort rejects bad value #" + i + " (" + String(bad) + ")",
31
+ _thrCode(function () { vo.optionalPort(bad, "p"); }) !== null);
32
+ });
33
+ // 65536 is out of range even with allowZero
34
+ check("optionalPort(65536, allowZero) still throws",
35
+ _thrCode(function () { vo.optionalPort(65536, "p", undefined, undefined, { allowZero: true }); }) !== null);
36
+
37
+ // message carries numericBounds.shape() so Infinity / "443" stay visible
38
+ var msg = _thrCode(function () { vo.optionalPort(Infinity, "p"); });
39
+ check("optionalPort message shows the bad shape", typeof msg === "string" && /Infinity/.test(msg));
40
+
41
+ // typed-error class + code via a defineClass-built framework error
42
+ check("optionalPort routes a typed error class + code",
43
+ _thrCode(function () { vo.optionalPort(-1, "p", b.ntpCheck.NtpCheckError, "ntp/bad-port"); }) === "ntp/bad-port");
44
+
45
+ // plain Error when no errorClass is supplied
46
+ var plain = null;
47
+ try { vo.optionalPort(-1, "p"); } catch (e) { plain = e; }
48
+ check("optionalPort throws a plain Error with no errorClass", (plain instanceof Error) && !plain.code);
49
+
50
+ // representative entry-point wiring: ntpCheck.querySingle rejects a bad port
51
+ // synchronously (before the Promise) with a typed NtpCheckError.
52
+ check("b.ntpCheck.NtpCheckError is a constructor", typeof b.ntpCheck.NtpCheckError === "function");
53
+ check("ntpCheck.querySingle({port:-1}) throws ntp/bad-port",
54
+ _thrCode(function () { b.ntpCheck.querySingle("pool.ntp.org", { port: -1 }); }) === "ntp/bad-port");
55
+ check("ntpCheck.querySingle({port:70000}) throws ntp/bad-port",
56
+ _thrCode(function () { b.ntpCheck.querySingle("pool.ntp.org", { port: 70000 }); }) === "ntp/bad-port");
57
+
58
+ // A url-supplied connection port must be range-checked too — the opts.port
59
+ // guard alone misses a port resolved from the url, so redis://h:0 (parsed to
60
+ // port 0) would otherwise reach an outbound connect. create() is inert until
61
+ // .connect(), so a valid url throws nothing.
62
+ check("redis url-port 0 rejected at create (resolved-port guard)",
63
+ _thrCode(function () { redisClient.create({ url: "redis://localhost:0" }); }) !== null);
64
+ check("redis url-port out-of-range rejected at create",
65
+ _thrCode(function () { redisClient.create({ url: "redis://localhost:99999" }); }) !== null);
66
+ check("redis valid url-port accepted (no throw at create)",
67
+ _thrCode(function () { redisClient.create({ url: "redis://localhost:6379" }); }) === null);
68
+ }
69
+
70
+ module.exports = { run: run };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.3.41",
3
+ "version": "0.3.43",
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": {