@blamejs/blamejs-shop 0.2.7 → 0.2.9

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.2.x
10
10
 
11
+ - v0.2.9 (2026-05-27) — **Storefront read pages stay fast for every visitor — the cart badge updates client-side.** Restores full edge-render speed for returning and signed-in shoppers. Every storefront read page (home, product listing, search, blog) now renders at the edge for all visitors again; the nav cart badge renders server-side and a tiny client island corrects the number from a new lightweight endpoint. The result: the fast cached page AND an accurate cart count, without sending a shopper-with-a-cart through the slower origin on every page. **Added:** *`GET /cart/count` + a cart-count island* — A new endpoint returns the current session's cart line count as JSON (`{"count": N}`), cached `no-store`, with no product hydration. A small deferred same-origin script (`assets/js/cart-count.js`) reads it on load and updates the nav badge text and its aria-label. A visitor with JavaScript off keeps the server-rendered badge. The script is a same-origin external file, so the existing `script-src 'self'` policy already permits it — no CSP change required. **Fixed:** *Fast read pages for shoppers who have a session* — A visitor carrying a cart or sign-in cookie is once again served the edge-rendered storefront pages instead of being routed to the origin container, so the home page and other read pages load quickly for returning visitors, not just first-time guests. The nav cart count stays correct via the island below.
12
+
13
+ - v0.2.8 (2026-05-27) — **A complete shipping address at checkout, and an accurate cart count on every page.** Two storefront fixes. Checkout now collects a real, shippable address — a street line, an optional apt/suite line, and a city — alongside the region, postal code, and country it already took; a signed-in customer's default saved address pre-fills the form, the address shows on the order confirmation, and the order export carries it so fulfilment has everything it needs. Separately, the navigation cart badge now shows the correct item count on the catalog, product, search, and blog pages — not only on the cart and account pages — by rendering those pages for a shopper-with-a-cart on the path that can read their session. **Changed:** *Checkout collects a complete shipping address* — The shipping step now takes a street address (line 1, plus an optional apartment / suite / unit line) and a city in addition to the region, postal code, and country it already collected, so a placed order carries an address you can actually ship to. Street line and city are marked required for the common physical-goods path; the server treats them as optional so a digital-only order still completes. A signed-in customer's default saved address pre-fills the form. · *Shipping address on the confirmation page and in the order export* — The order confirmation now shows the ship-to address, and the order CSV / NDJSON export gains `shipping_line1`, `shipping_line2`, and `shipping_city` columns (inserted after `customer_name`) so a fulfilment handoff has the full address. Consumers that key on the header name are unaffected; a consumer that keys on absolute column position should re-map to the 27-column layout. **Fixed:** *Cart count is correct on every page* — The nav cart badge previously read zero on edge-rendered pages (home, product, search, blog) for a shopper who had items in their cart, while the cart and account pages showed the real count. A request that carries a session cookie is now rendered on the path that can unseal the session and read the cart, so the badge is consistent everywhere. Guests — who have no cart — still get the fast cached render with a count of zero, which is correct for them.
14
+
11
15
  - v0.2.7 (2026-05-27) — **Default-theme brand polish — broader favicon support and a larger logo.** Small refinements to the bundled default theme's branding. Every page head now advertises a PNG favicon and an Apple touch icon alongside the existing SVG favicon, so the site icon renders correctly on Safari / iOS home screens and on browsers that don't support SVG favicons, plus a theme-color so the mobile browser chrome matches the dark canvas. The header and footer logo are rendered larger for more presence. These affect the default theme only; a custom theme passed via the theme primitive is unchanged. **Changed:** *Fuller favicon + icon metadata* — The page head now includes a PNG favicon and an `apple-touch-icon` (Apple home-screen icons require PNG — SVG favicons aren't used there) in addition to the SVG favicon, plus a `theme-color` meta for the mobile browser UI. Emitted on every page across both the edge and container render paths. · *Larger header and footer logo* — The default theme renders the brand logo at a larger size in the header and footer for clearer presence.
12
16
 
13
17
  - v0.2.6 (2026-05-27) — **Business-hours page, a graceful unconfigured-checkout state, and the brand shield on the product page.** Three storefront changes. Operators can publish their open hours: define schedules (timezone + per-weekday open/close) from a new Hours screen in the admin console, and a public /hours page renders each as a weekly grid with a live open/closed status computed per request in the schedule's timezone. The cart now degrades gracefully when no payment provider is configured — instead of a checkout button that leads to a dead route, it shows a clear, disabled 'checkout isn't set up yet' notice, and a direct visit to /checkout returns a tidy 'unavailable' page rather than a 404. And the product page's post-quantum-checkout trust badge now uses the real brand shield mark (the site favicon) in place of a CSS approximation. **Added:** *Public /hours page* — A server-rendered page listing every active schedule as a weekday grid (closed days marked), with a live open/closed pill and the next transition ("Opens Monday at 09:00" / "Closes at 17:00"). Timezone-aware and computed per request, so it isn't cached stale. · *Admin console: Hours* — A new Hours screen (and matching JSON API) to create a schedule with a timezone and per-weekday open/close times (a blank day is closed), list schedules, and archive ones no longer in use. Holiday closures and one-off date exceptions — which override the weekly base — are available through the API. The screen appears only when the business-hours primitive is wired. **Changed:** *Cart and /checkout degrade gracefully without a payment provider* — When checkout isn't configured (no payment provider), the cart no longer shows a checkout button that leads nowhere — it shows a disabled control with a clear note that online checkout isn't set up yet and the cart is saved. A direct visit to /checkout now returns a tidy 503 'checkout unavailable' page instead of a 404. Configuring a payment provider restores the full cart → shipping → payment → confirmation flow with no other change. · *Product-page trust badge uses the brand shield* — The post-quantum-secured-checkout badge on the product page now renders the actual brand shield mark (the site icon) rather than a CSS-drawn hex approximation, so the badge matches the brand identity used elsewhere.
@@ -1,18 +1,22 @@
1
1
  {
2
- "version": "0.2.7",
2
+ "version": "0.2.9",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-FgqvXcZygYkJjjOtXAeu9xi6AtYqkchPnJKcS0OeHm8lynhd9r4RfdpoT7P7j7/l",
6
6
  "fingerprinted": "css/admin.afd7b964c1f6fe1b.css"
7
7
  },
8
8
  "css/main.css": {
9
- "integrity": "sha384-BRkvAnovRCuCGMQ4Wtfr+YuGLmcMvD6ScqePyRq7sdTrQEF+93ILtrO0dLv+I6Av",
10
- "fingerprinted": "css/main.c9b731a59c0389cf.css"
9
+ "integrity": "sha384-6aJfYezHHWR7kgX0foRedjqZg90PP6xrD7oXFim5X2gzV3E3gzZaQTzqgZuCP9eU",
10
+ "fingerprinted": "css/main.d428a281f9f583d8.css"
11
11
  },
12
12
  "js/announcement.js": {
13
13
  "integrity": "sha384-z4zcEMn+tScoVnYRE4nEf8N/oyvpxdpaxTNrT4QO/jURChid4+qjAvWkzatCaAPq",
14
14
  "fingerprinted": "js/announcement.59eab4872d2f3b4c.js"
15
15
  },
16
+ "js/cart-count.js": {
17
+ "integrity": "sha384-K/rkm//Dzg8nuOfpaeenJvLKKl+6DEuvuJi1LLgd46BK+dd1HYmU8R7/gHHjtEsr",
18
+ "fingerprinted": "js/cart-count.bfc2eb434b19c00a.js"
19
+ },
16
20
  "js/consent.js": {
17
21
  "integrity": "sha384-KKMQ0og8HPOykRRPpUyxX7dMhTvKySfVtpGX/jGWzZwNaN/c4OykvRvXpqBHcQST",
18
22
  "fingerprinted": "js/consent.517d6ad1fbf106a1.js"
package/lib/checkout.js CHANGED
@@ -58,6 +58,22 @@ function _shipTo(s) {
58
58
  if (s.postal && (typeof s.postal !== "string" || !/^[A-Za-z0-9 -]{1,16}$/.test(s.postal))) {
59
59
  throw new TypeError("checkout: ship_to.postal malformed");
60
60
  }
61
+ // Street address + city are free-text (international addresses defy a
62
+ // tight character class), so validation is shape + length only. They
63
+ // are optional at this layer — a digital-only order ships nothing —
64
+ // and length-capped so a stored value can't bloat the order row; the
65
+ // values are HTML-escaped wherever they render (confirmation, admin,
66
+ // export). The checkout form marks line1 + city required so the
67
+ // common physical-goods path collects a complete address.
68
+ if (s.line1 && (typeof s.line1 !== "string" || s.line1.length > 200)) {
69
+ throw new TypeError("checkout: ship_to.line1 malformed");
70
+ }
71
+ if (s.line2 && (typeof s.line2 !== "string" || s.line2.length > 200)) {
72
+ throw new TypeError("checkout: ship_to.line2 malformed");
73
+ }
74
+ if (s.city && (typeof s.city !== "string" || s.city.length > 120)) {
75
+ throw new TypeError("checkout: ship_to.city malformed");
76
+ }
61
77
  return s;
62
78
  }
63
79
 
@@ -74,7 +74,7 @@ var b = require("./vendor/blamejs");
74
74
 
75
75
  // ---- column schema ------------------------------------------------------
76
76
  //
77
- // The 24-column built-in shape. Operators selecting `columns: [...]`
77
+ // The 27-column built-in shape. Operators selecting `columns: [...]`
78
78
  // pick a subset; order in the produced output follows COLUMN_ORDER,
79
79
  // not the operator's input order, so consumers can rely on a stable
80
80
  // header layout independent of how the request was filed.
@@ -87,6 +87,9 @@ var COLUMN_ORDER = Object.freeze([
87
87
  "customer_id",
88
88
  "customer_email_hash",
89
89
  "customer_name",
90
+ "shipping_line1",
91
+ "shipping_line2",
92
+ "shipping_city",
90
93
  "shipping_country",
91
94
  "shipping_postal",
92
95
  "shipping_region",
@@ -281,6 +284,9 @@ function _projectOrder(row, _opts) {
281
284
  customer_id: row.customer_id || "",
282
285
  customer_email_hash: emailHash,
283
286
  customer_name: row.customer_name || "",
287
+ shipping_line1: shipTo.line1 || "",
288
+ shipping_line2: shipTo.line2 || "",
289
+ shipping_city: shipTo.city || "",
284
290
  shipping_country: shipTo.country || "",
285
291
  shipping_postal: shipTo.postal || "",
286
292
  shipping_region: shipTo.state || shipTo.region || "",
package/lib/storefront.js CHANGED
@@ -266,6 +266,7 @@ var LAYOUT =
266
266
  " </footer>\n" +
267
267
  CONSENT_BANNER +
268
268
  "RAW_CONSENT_SCRIPT" +
269
+ "RAW_CART_COUNT_SCRIPT" +
269
270
  "RAW_ANNOUNCEMENT_SCRIPT" +
270
271
  "</body>\n" +
271
272
  "</html>\n";
@@ -621,6 +622,7 @@ function _wrap(opts) {
621
622
  .replace("RAW_CSS_INTEGRITY", themeCssIntegrity)
622
623
  .replace("RAW_ANNOUNCEMENT_BAR", announcementBarHtml)
623
624
  .replace("RAW_CONSENT_SCRIPT", _islandScript("consent.js", { id: "consent-island", policy: _activeConsentPolicy }))
625
+ .replace("RAW_CART_COUNT_SCRIPT", _islandScript("cart-count.js", { id: "cart-count-island" }))
624
626
  .replace("RAW_ANNOUNCEMENT_SCRIPT", announcementScript)
625
627
  .replace("RAW_CURRENCY_SWITCHER", switcherHtml)
626
628
  .replace("RAW_LOCALE_SWITCHER", localeCtx.switcher_html || "")
@@ -3367,6 +3369,54 @@ var CART_LINE_EDITABLE =
3367
3369
 
3368
3370
  // ---- checkout form + payment page + order confirmation -----------------
3369
3371
 
3372
+ // The shipping-address fieldset for checkout. Reuses `_addrField` (the
3373
+ // same labelled-input builder the account address book uses) so the
3374
+ // checkout + saved-address forms collect an identical address shape.
3375
+ // `p` pre-fills each field (from a signed-in customer's default shipping
3376
+ // address); every value is escaped by `_addrField` / the email builder.
3377
+ // Street line 1 + city are marked required for the common physical-goods
3378
+ // path; the backend (checkout._shipTo) is authoritative and treats them
3379
+ // as optional so a digital-only order still completes.
3380
+ function _checkoutShippingFields(p) {
3381
+ p = p || {};
3382
+ var esc = b.template.escapeHtml;
3383
+ var email =
3384
+ "<label class=\"form-field\">" +
3385
+ "<span class=\"form-field__label\">Email <span class=\"form-field__req\" aria-hidden=\"true\">*</span></span>" +
3386
+ "<input type=\"email\" name=\"email\" value=\"" + esc(p.email == null ? "" : String(p.email)) + "\" required autocomplete=\"email\">" +
3387
+ "</label>";
3388
+ return email +
3389
+ _addrField("name", "Full name", p.name, { required: true, maxlength: 120, autocomplete: "name" }) +
3390
+ _addrField("line1", "Street address", p.line1, { required: true, maxlength: 200, autocomplete: "address-line1" }) +
3391
+ _addrField("line2", "Apt / suite / unit (optional)", p.line2, { maxlength: 200, autocomplete: "address-line2" }) +
3392
+ "<div class=\"form-row form-row--inline\">" +
3393
+ _addrField("city", "City", p.city, { required: true, maxlength: 120, autocomplete: "address-level2" }) +
3394
+ _addrField("state", "State / province code", p.state, { maxlength: 5, pattern: "[A-Za-z0-9]{1,5}", autocomplete: "address-level1" }) +
3395
+ "</div>" +
3396
+ "<div class=\"form-row form-row--inline\">" +
3397
+ _addrField("postal", "Postal code", p.postal, { maxlength: 16, autocomplete: "postal-code" }) +
3398
+ _addrField("country", "Country (ISO 3166-1)", p.country || "US", { required: true, maxlength: 2, pattern: "[A-Za-z]{2}", autocomplete: "country" }) +
3399
+ "</div>";
3400
+ }
3401
+
3402
+ // Build the order's ship_to from a checkout POST body. Shared by the
3403
+ // card-form POST and the PayPal create call so both persist an identical
3404
+ // address shape. A blank field becomes `undefined` (omitted) rather than
3405
+ // "" so checkout._shipTo's optional-field checks aren't tripped by an
3406
+ // empty input; country + state are upper-cased to match the ISO
3407
+ // validators, the free-text street/city fields are trimmed only.
3408
+ function _shipToFromBody(body) {
3409
+ body = body || {};
3410
+ return {
3411
+ country: (body.country || "").toUpperCase(),
3412
+ state: body.state ? String(body.state).toUpperCase() : undefined,
3413
+ postal: body.postal || undefined,
3414
+ line1: body.line1 ? String(body.line1).trim() : undefined,
3415
+ line2: body.line2 ? String(body.line2).trim() : undefined,
3416
+ city: body.city ? String(body.city).trim() : undefined,
3417
+ };
3418
+ }
3419
+
3370
3420
  var CHECKOUT_PAGE =
3371
3421
  "<section class=\"checkout-page\">\n" +
3372
3422
  " <header class=\"section-head\">\n" +
@@ -3375,13 +3425,7 @@ var CHECKOUT_PAGE =
3375
3425
  " <p class=\"section-head__lede\">Enter where the order should ship. Payment runs through Stripe on the next step.</p>\n" +
3376
3426
  " </header>\n" +
3377
3427
  " <form method=\"post\" action=\"/checkout\" class=\"form-stack\">\n" +
3378
- " <div class=\"form-row\"><label class=\"form-field\"><span class=\"form-field__label\">Email</span><input type=\"email\" name=\"email\" required autocomplete=\"email\"></label></div>\n" +
3379
- " <div class=\"form-row\"><label class=\"form-field\"><span class=\"form-field__label\">Full name</span><input type=\"text\" name=\"name\" required autocomplete=\"name\"></label></div>\n" +
3380
- " <div class=\"form-row form-row--inline\">\n" +
3381
- " <label class=\"form-field\"><span class=\"form-field__label\">Country (ISO 3166-1)</span><input type=\"text\" name=\"country\" value=\"US\" maxlength=\"2\" pattern=\"[A-Z]{2}\" required autocomplete=\"country\" class=\"form-field__input--xs\"></label>\n" +
3382
- " <label class=\"form-field\"><span class=\"form-field__label\">State / Region</span><input type=\"text\" name=\"state\" maxlength=\"5\" autocomplete=\"address-level1\" class=\"form-field__input--xs\"></label>\n" +
3383
- " <label class=\"form-field\"><span class=\"form-field__label\">Postal code</span><input type=\"text\" name=\"postal\" maxlength=\"16\" autocomplete=\"postal-code\" class=\"form-field__input--sm\"></label>\n" +
3384
- " </div>\n" +
3428
+ " RAW_SHIPPING_FIELDS" +
3385
3429
  " <div class=\"form-row\"><label class=\"form-field\"><span class=\"form-field__label\">Gift card code <span class=\"small\">(optional)</span></span><input type=\"text\" name=\"gift_card_code\" autocomplete=\"off\" placeholder=\"XXXX-XXXX-XXXX-XXXX\" maxlength=\"24\"></label></div>\n" +
3386
3430
  " <div class=\"checkout-summary\">\n" +
3387
3431
  " <h3>Order summary</h3>\n" +
@@ -3431,7 +3475,8 @@ function renderCheckoutForm(opts) {
3431
3475
  asset_css_main: opts.theme.assetUrl("css/main.css"),
3432
3476
  });
3433
3477
  }
3434
- var body = _render(CHECKOUT_PAGE, { subtotal: subtotal });
3478
+ var body = _render(CHECKOUT_PAGE, { subtotal: subtotal })
3479
+ .replace("RAW_SHIPPING_FIELDS", _checkoutShippingFields(opts.prefill));
3435
3480
  // Signed-in customer with a spendable points balance — surface a
3436
3481
  // redeem-at-checkout field. The block is appended as raw HTML (the
3437
3482
  // balance + value are numbers we control, the conversion ratio is the
@@ -3534,7 +3579,7 @@ function _paypalCheckoutBlock(clientId, currency) {
3534
3579
  "if(!window.paypal){return;}" +
3535
3580
  "var form=document.querySelector('.checkout-page form');" +
3536
3581
  "var errEl=document.getElementById('paypal-error');" +
3537
- "function vals(){var d={};['email','name','country','state','postal'].forEach(function(k){var el=form&&form.elements[k];if(el){d[k]=el.value;}});return d;}" +
3582
+ "function vals(){var d={};['email','name','line1','line2','city','country','state','postal'].forEach(function(k){var el=form&&form.elements[k];if(el){d[k]=el.value;}});return d;}" +
3538
3583
  "function showErr(m){if(errEl){errEl.hidden=false;errEl.textContent=m||'PayPal checkout could not be completed.';}}" +
3539
3584
  "paypal.Buttons({" +
3540
3585
  "onClick:function(_d,actions){if(form&&form.reportValidity&&!form.reportValidity()){return actions.reject();}return actions.resolve();}," +
@@ -3664,10 +3709,31 @@ var ORDER_PAGE =
3664
3709
  " <div><dt>Shipping</dt><dd>{{shipping}}</dd></div>\n" +
3665
3710
  " <div class=\"totals-list__grand\"><dt>Total</dt><dd>{{total}}</dd></div>\n" +
3666
3711
  " </dl>\n" +
3667
- " </aside>\n" +
3712
+ " RAW_SHIP_TO</aside>\n" +
3668
3713
  " </div>\n" +
3669
3714
  "</section>\n";
3670
3715
 
3716
+ // Renders the order's shipping address as an <address> block for the
3717
+ // confirmation page. Built from the stored `ship_to` (country/state/
3718
+ // postal + line1/line2/city); shows whichever parts are present so a
3719
+ // legacy order with only a country still renders, and a digital order
3720
+ // with no address renders nothing. Every part is HTML-escaped.
3721
+ function _shipToAddressBlock(s) {
3722
+ if (!s || typeof s !== "object") return "";
3723
+ var esc = b.template.escapeHtml;
3724
+ var cityLine = [s.city, s.state, s.postal]
3725
+ .filter(function (x) { return x != null && String(x).trim().length; })
3726
+ .map(String).join(", ");
3727
+ var parts = [s.line1, s.line2, cityLine, s.country]
3728
+ .filter(function (x) { return x != null && String(x).trim().length; })
3729
+ .map(function (x) { return "<span>" + esc(String(x)) + "</span>"; });
3730
+ if (!parts.length) return "";
3731
+ return "<div class=\"order-page__ship\">" +
3732
+ "<h2 class=\"pdp__variants-title\">Ship to</h2>" +
3733
+ "<address class=\"order-ship-address\">" + parts.join("") + "</address>" +
3734
+ "</div>";
3735
+ }
3736
+
3671
3737
  function renderOrder(opts) {
3672
3738
  if (!opts || !opts.order) throw new TypeError("storefront.renderOrder: opts.order required");
3673
3739
  var o = opts.order;
@@ -3714,6 +3780,7 @@ function renderOrder(opts) {
3714
3780
  tax: tax,
3715
3781
  shipping: shipping,
3716
3782
  total: total,
3783
+ ship_to: o.ship_to || null,
3717
3784
  recommendations: recs,
3718
3785
  has_recommendations: recs.length > 0,
3719
3786
  asset_css_main: opts.theme.assetUrl("css/main.css"),
@@ -3742,7 +3809,7 @@ function renderOrder(opts) {
3742
3809
  tax: tax,
3743
3810
  shipping: shipping,
3744
3811
  total: total,
3745
- }).replace("RAW_LINES", rows);
3812
+ }).replace("RAW_LINES", rows).replace("RAW_SHIP_TO", _shipToAddressBlock(o.ship_to));
3746
3813
  // Post-purchase cross-sell rail — reuses the catalog grid + product-card
3747
3814
  // markup (so it inherits the storefront's card styling), rendered only
3748
3815
  // when the picker returned something.
@@ -5783,15 +5850,7 @@ function mount(router, deps) {
5783
5850
  }
5784
5851
  }
5785
5852
 
5786
- var sid = _readSidCookie(req);
5787
- var cartCount = 0;
5788
- if (sid) {
5789
- var c = await deps.cart.bySession(sid);
5790
- if (c) {
5791
- var lines = await deps.cart.listLines(c.id);
5792
- cartCount = lines.length;
5793
- }
5794
- }
5853
+ var cartCount = await _cartCountForReq(req);
5795
5854
  var ccy = await _currencyForReq(req);
5796
5855
  _send(res, 200, renderSearch(Object.assign({
5797
5856
  q: q,
@@ -5821,15 +5880,7 @@ function mount(router, deps) {
5821
5880
  // variant-select interaction we don't ship yet.
5822
5881
  var media = await deps.catalog.media.listForProduct(product.id);
5823
5882
  // Render cart count from the current session's cart, if any.
5824
- var sid = _readSidCookie(req);
5825
- var cartCount = 0;
5826
- if (sid) {
5827
- var c = await deps.cart.bySession(sid);
5828
- if (c) {
5829
- var lines = await deps.cart.listLines(c.id);
5830
- cartCount = lines.length;
5831
- }
5832
- }
5883
+ var cartCount = await _cartCountForReq(req);
5833
5884
  // Published reviews aggregate + list. A failed read (e.g. the
5834
5885
  // reviews table not yet migrated) degrades to the empty state
5835
5886
  // rather than 500-ing the whole PDP — reviews are supplementary
@@ -6233,6 +6284,21 @@ function mount(router, deps) {
6233
6284
  });
6234
6285
  }
6235
6286
 
6287
+ // Nav cart-badge count for the cart-count island. Every storefront
6288
+ // chrome page server-renders the badge (0 on the cookie-less edge-cached
6289
+ // render); the island fetches this on load and corrects the number for a
6290
+ // visitor whose sealed `shop_sid` the edge can't read. JSON only — one
6291
+ // session lookup, no product hydration — and never cached (a per-session
6292
+ // value must not land in a shared cache).
6293
+ router.get("/cart/count", async function (req, res) {
6294
+ var count = await _cartCountForReq(req);
6295
+ res.status(200);
6296
+ res.setHeader && res.setHeader("content-type", "application/json; charset=utf-8");
6297
+ res.setHeader && res.setHeader("cache-control", "no-store");
6298
+ var payload = JSON.stringify({ count: count });
6299
+ return res.end ? res.end(payload) : res.send(payload);
6300
+ });
6301
+
6236
6302
  router.get("/cart", async function (req, res) {
6237
6303
  var ccy = await _currencyForReq(req);
6238
6304
  var sid = _readSidCookie(req);
@@ -6335,22 +6401,49 @@ function mount(router, deps) {
6335
6401
  return res.end ? res.end() : res.send("");
6336
6402
  }
6337
6403
  var totals = pricing.totals(c, lines, {});
6338
- // Loyalty balance only for a signed-in customer with loyalty
6339
- // wired. A read failure (table not migrated) degrades to no
6340
- // redeem field rather than 500-ing checkout.
6404
+ // A signed-in customer drives two best-effort lookups, both keyed
6405
+ // off the same auth env: the loyalty balance (for the redeem field)
6406
+ // and the default shipping address (to pre-fill the form). Either
6407
+ // read failing (table not migrated, no saved address) degrades to
6408
+ // the un-prefilled / no-redeem checkout rather than 500-ing it.
6409
+ var coAuth = _currentCustomerEnv(req);
6341
6410
  var loyaltyBalance = null;
6342
- if (deps.loyalty) {
6343
- var loyAuth = _currentCustomerEnv(req);
6344
- if (loyAuth) {
6345
- try { loyaltyBalance = await deps.loyalty.balance(loyAuth.customer_id); }
6346
- catch (_e) { loyaltyBalance = null; }
6347
- }
6411
+ if (deps.loyalty && coAuth) {
6412
+ try { loyaltyBalance = await deps.loyalty.balance(coAuth.customer_id); }
6413
+ catch (_e) { loyaltyBalance = null; }
6414
+ }
6415
+ var prefill = null;
6416
+ if (deps.addresses && coAuth) {
6417
+ try {
6418
+ var addrRows = await deps.addresses.listForCustomer(coAuth.customer_id, { limit: 50 });
6419
+ var pick = null;
6420
+ for (var ai = 0; ai < addrRows.length; ai += 1) {
6421
+ if (Number(addrRows[ai].is_default_shipping) === 1) { pick = addrRows[ai]; break; }
6422
+ }
6423
+ if (!pick && addrRows.length) pick = addrRows[0];
6424
+ if (pick) {
6425
+ prefill = {
6426
+ name: pick.recipient_name,
6427
+ line1: pick.street_line1,
6428
+ line2: pick.street_line2,
6429
+ city: pick.city,
6430
+ postal: pick.postal_code,
6431
+ country: pick.country,
6432
+ // The saved `region` is free-text ("California"); checkout's
6433
+ // `state` is a short subdivision code. Carry it over only when
6434
+ // it already matches the code shape so we never pre-seed a
6435
+ // value the field's own pattern would reject.
6436
+ state: (pick.region && /^[A-Za-z0-9]{1,5}$/.test(pick.region)) ? pick.region : undefined,
6437
+ };
6438
+ }
6439
+ } catch (_e) { prefill = null; }
6348
6440
  }
6349
6441
  _send(res, 200, renderCheckoutForm({
6350
6442
  lines: lines, totals: totals, shop_name: shopName, theme: theme,
6351
6443
  paypal_client_id: deps.paypal ? deps.paypal_client_id : null,
6352
6444
  loyalty_balance: loyaltyBalance,
6353
6445
  loyalty_points_per_usd: deps.loyalty ? deps.loyalty.REDEMPTION_POINTS_PER_USD : null,
6446
+ prefill: prefill,
6354
6447
  }));
6355
6448
  });
6356
6449
 
@@ -6371,11 +6464,7 @@ function mount(router, deps) {
6371
6464
  res.status(303); res.setHeader && res.setHeader("location", "/cart");
6372
6465
  return res.end ? res.end() : res.send("");
6373
6466
  }
6374
- var shipTo = {
6375
- country: (body.country || "").toUpperCase(),
6376
- state: body.state ? String(body.state).toUpperCase() : undefined,
6377
- postal: body.postal || undefined,
6378
- };
6467
+ var shipTo = _shipToFromBody(body);
6379
6468
  try {
6380
6469
  // default_shipping_id may be a literal string or an
6381
6470
  // operator-supplied async resolver (e.g. backed by the
@@ -6446,11 +6535,7 @@ function mount(router, deps) {
6446
6535
  if (!sid) return _json(400, { error: "no-session" });
6447
6536
  var c = await deps.cart.bySession(sid);
6448
6537
  if (!c || c.status !== "active") return _json(409, { error: "no-active-cart" });
6449
- var shipTo = {
6450
- country: (body.country || "").toUpperCase(),
6451
- state: body.state ? String(body.state).toUpperCase() : undefined,
6452
- postal: body.postal || undefined,
6453
- };
6538
+ var shipTo = _shipToFromBody(body);
6454
6539
  try {
6455
6540
  var defaultShipId = typeof deps.default_shipping_id === "function"
6456
6541
  ? await deps.default_shipping_id() : deps.default_shipping_id;
@@ -3,8 +3,8 @@
3
3
  "_about": "blamejs.shop vendors a single framework — blamejs — which itself bundles every server-side crypto/identity dependency. The transitive packages blamejs ships are surfaced in its own MANIFEST.json at lib/vendor/blamejs/lib/vendor/MANIFEST.json — Trivy / Grype rely on that nested data for CVE attribution.",
4
4
  "packages": {
5
5
  "blamejs": {
6
- "version": "0.13.18",
7
- "tag": "v0.13.18",
6
+ "version": "0.13.19",
7
+ "tag": "v0.13.19",
8
8
  "license": "Apache-2.0",
9
9
  "author": "blamejs contributors",
10
10
  "source": "https://github.com/blamejs/blamejs",
@@ -40,6 +40,11 @@ jobs:
40
40
  smoke:
41
41
  name: Framework smoke (${{ matrix.os }})
42
42
  runs-on: ${{ matrix.os }}
43
+ # Backstop against a hung child no watchdog reaped (and against the
44
+ # watchdog logic itself regressing): without this a stuck job rides
45
+ # GitHub's 6-hour default. The full suite finishes in well under
46
+ # 20 min even on the slow macOS runners; 30 leaves headroom.
47
+ timeout-minutes: 30
43
48
  strategy:
44
49
  fail-fast: false
45
50
  matrix:
@@ -93,6 +98,7 @@ jobs:
93
98
  wiki-e2e:
94
99
  name: Wiki e2e (${{ matrix.os }})
95
100
  runs-on: ${{ matrix.os }}
101
+ timeout-minutes: 25
96
102
  strategy:
97
103
  fail-fast: false
98
104
  matrix:
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.13.x
10
10
 
11
+ - v0.13.19 (2026-05-27) — **`auditTools` export / archive / forensic-snapshot can return the bundle as bytes — no output directory for serverless / read-only filesystems.** b.auditTools.exportSlice, b.auditTools.archive, and b.auditTools.forensicSnapshot required an `out` directory to write the encrypted bundle (rows.enc + optional checkpoint.enc + manifest.json), which is unusable on a read-only or ephemeral serverless filesystem. Each now accepts `returnBytes: true` instead of `out` and returns the bundle as an in-memory `{ filename: Buffer }` map — ready to stream to object storage or over the wire with no filesystem access. `out` and `returnBytes` are mutually exclusive. The on-disk path is unchanged. The bundle's encryption (XChaCha20-Poly1305 + Argon2id), chain-proof material, and manifest checksums are identical to the written bundle, so an in-memory bundle written to disk verifies exactly as one produced by the `out` path. **Added:** *`returnBytes` on `auditTools.exportSlice` / `archive` / `forensicSnapshot` — in-memory bundles* — Pass `returnBytes: true` (and omit `out`) to get the encrypted audit bundle as an in-memory `{ filename: Buffer }` map instead of a directory write — the read-only / serverless path. `exportSlice` / `archive` return `{ manifest, files, rowCount, range }`; `forensicSnapshot` returns `{ ...manifest, files }` where `files` carries the slice's `rows.enc` + `manifest.json` plus the `forensic-snapshot.json` incident wrapper. The encryption, chain proof, and manifest checksums match the on-disk bundle byte-for-byte, so the bytes verify with `verifyBundle` once written out. `out` and `returnBytes` are mutually exclusive (passing both throws). **Fixed:** *`auditTools.forensicSnapshot` now honors the `since` window instead of capturing the entire audit history* — `forensicSnapshot` passed its `since` bound to the slice exporter under the wrong option name, so the time filter was silently dropped and the snapshot bundled every audit row regardless of `since`. The window is now applied — a snapshot scoped to an incident window contains only that window's rows. The snapshot manifest's `auditSliceFile` field, previously always undefined, now records the slice location.
12
+
11
13
  - v0.13.18 (2026-05-27) — **`bodyParser` multipart can buffer uploads in memory — no tmp directory for serverless / read-only filesystems.** The multipart/form-data sub-parser previously streamed every file part to a tmp directory on disk (os.tmpdir() by default), which fails on a read-only or ephemeral serverless filesystem. A new multipart.storage option selects where file parts land: "disk" (default, unchanged — req.files[].path points at a tmp file cleaned up on response end) or "memory" (req.files[].buffer holds the assembled bytes, with no filesystem access at all). Both modes enforce the same per-file (fileSize), per-field, and total-request (totalSize) caps, so memory mode adds no new memory-exhaustion surface. The file object shape is stable across both modes — disk sets path with buffer null, memory sets buffer with path null — so a handler branches on whichever is non-null. An invalid storage value is rejected when the middleware is constructed. **Added:** *`bodyParser` multipart `storage: "memory"` — buffer uploads in RAM instead of a tmp directory* — `b.middleware.bodyParser({ multipart: { storage: "memory" } })` buffers each uploaded file part in memory and exposes it as `req.files[].buffer` (a Buffer), with no `os.tmpdir()` write and no tmp-file cleanup — the read-only / serverless path. The default `storage: "disk"` is unchanged: file parts stream to a tmp file, `req.files[].path` points at it, and it is removed when the response finishes. Both modes apply the existing `fileSize` / per-field `maxBytes` / `totalSize` caps and SHA3-512 hash each part during streaming, so memory mode is bounded by the same limits and adds no new DoS surface. The `req.files[]` shape is stable across modes (disk: `path` set, `buffer` null; memory: `buffer` set, `path` null). A `storage` value other than `"disk"` or `"memory"` throws a `TypeError` at construction.
12
14
 
13
15
  - v0.13.17 (2026-05-27) — **Template engine can render from a string with no views directory — for serverless / read-only filesystems.** b.template.create previously required a viewsDir that exists on disk, and rendering always read the template (and its layout/partials) from that directory — unusable on a read-only or ephemeral serverless filesystem where the templates aren't on disk. The engine now accepts a source string directly: viewsDir is optional, and the returned engine exposes renderString(source, data?, opts?) and compileString(source, opts?) that compile and render from a string with no disk read. {% extends %} and {{> partial}} in a string source resolve through an operator-supplied opts.resolve(name) -> string callback (without it, an extends throws a clear error and a missing partial inlines empty, matching the file path). The same HTML-escaping, expression grammar, and extends/partial-depth caps apply. The file-backed render / compile / precompileAll still work exactly as before when a viewsDir is configured, and now refuse with a clear error when one isn't. **Added:** *`engine.renderString` / `engine.compileString` — render templates from a string, no viewsDir* — `b.template.create({})` (no `viewsDir`) returns a string-only engine; `renderString(source, data?, { resolve })` and `compileString(source, { resolve })` compile and render from a source string with zero filesystem access — the read-only / serverless path. `{% extends %}` and `{{> partial}}` resolve through `opts.resolve(name) -> string`. The HTML escaping, grammar, and depth caps are identical to the file path. When a `viewsDir` IS configured, `render`/`compile`/`precompileAll` behave exactly as before; without one they refuse with `viewsDir not configured`. `renderString(source, { resolve })` may omit the data argument — an opts object carrying a function `resolve` is recognized as opts, not data. **Security:** *Vendored `@simplewebauthn/server` refreshed 13.3.0 → 13.3.1* — The vendored WebAuthn server bundle (`b.auth.passkey`'s registration/authentication verification) is refreshed to the latest upstream patch, with the MANIFEST version, CPE, and SHA-256 integrity hashes updated and the bundle re-verified.
@@ -221,7 +221,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
221
221
  - **PII redaction** — `b.redact`
222
222
  - **Decoy detection** — canary-credential / decoy-record framework auditing every positive lookup as `honeytoken.tripped` (`b.honeytoken`)
223
223
  - **Boot assertions** — operator-callable security policy assertions (`b.security.assertProduction`); tamper-evident config-baseline drift detection signed with audit-signing key + at-boot vendor-bundle SHA-256 integrity verification across `lib/vendor/*` (`b.configDrift`, `b.configDrift.verifyVendorIntegrity`)
224
- - **CSP reports + forensic export** — `b.middleware.cspReport`; post-incident audit-bundle composer (`b.auditTools.forensicSnapshot`)
224
+ - **CSP reports + forensic export** — `b.middleware.cspReport`; post-incident audit-bundle composer (`b.auditTools.forensicSnapshot`); audit export / archive / forensic snapshot write to disk or return the encrypted bundle in memory (`returnBytes`) for read-only / serverless filesystems
225
225
 
226
226
  ### i18n + format helpers
227
227
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 1,
3
- "frameworkVersion": "0.13.18",
4
- "createdAt": "2026-05-27T19:22:43.399Z",
3
+ "frameworkVersion": "0.13.19",
4
+ "createdAt": "2026-05-27T20:32:29.864Z",
5
5
  "exports": {
6
6
  "a2a": {
7
7
  "type": "object",
@@ -146,14 +146,18 @@ function fsync(fd) {
146
146
  * b.atomicFile.fsyncDir("/var/lib/blamejs/data");
147
147
  */
148
148
  function fsyncDir(dirPath) {
149
- // CodeQL js/insecure-temporary-file: dirPath is an operator-supplied
150
- // framework data directory (e.g. /var/lib/blamejs/data)never an
151
- // os.tmpdir-reachable path. The fd is used solely for fsync and is
152
- // closed immediately; no read or write occurs through it, so the
153
- // tmp-file heuristic does not apply. Owner-only 0o700 dataDir
154
- // perms are set by ensureDir.
149
+ // CodeQL js/insecure-temporary-file: this is a read-only open of an
150
+ // EXISTING directory to fsync its inode no file is created, so the
151
+ // predictable-temp-name / symlink-race the query targets does not
152
+ // apply. The fd is opened "r", fsynced, and closed immediately; no
153
+ // write goes through it. The directory itself is created 0o700 by
154
+ // ensureDir. dirPath is normally an operator data dir (e.g.
155
+ // /var/lib/blamejs/data); when a caller fsyncs a dir under os.tmpdir
156
+ // (test fixtures via fs.mkdtempSync, or an audit bundle written to a
157
+ // tmp `out`), mkdtempSync already guarantees a unique 0o700 dir, so
158
+ // there is still no race surface.
155
159
  try {
156
- var fd = nodeFs.openSync(dirPath, "r");
160
+ var fd = nodeFs.openSync(dirPath, "r"); // lgtm[js/insecure-temporary-file] — read-only fsync of an existing dir; no temp file created
157
161
  try { nodeFs.fsyncSync(fd); } catch (_e) { /* Windows rejects directory fsync */ }
158
162
  finally { nodeFs.closeSync(fd); }
159
163
  } catch (_e) { /* dir fsync is best-effort across filesystems */ }
@@ -291,36 +291,43 @@ async function _defaultReadPredecessorRowHash(firstCounter) {
291
291
 
292
292
  // ---- Bundle writer ----
293
293
 
294
- async function _writeBundle(args) {
295
- var outDir = args.outDir;
294
+ // Assemble the encrypted bundle entirely in memory: returns the
295
+ // manifest plus an ordered { filename: Buffer } map. Pure — no
296
+ // filesystem touch — so it backs both the on-disk writer and the
297
+ // returnBytes / serverless path. The bundle is always the same 2-3
298
+ // files (rows.enc, optional checkpoint.enc, manifest.json) whether it
299
+ // lands on disk or ships as bytes.
300
+ async function _buildBundle(args) {
296
301
  var kind = args.kind;
297
302
  var rows = args.rows;
298
303
  var checkpoint = args.checkpoint || null;
299
304
  var passphrase = args.passphrase;
300
305
  var predecessorRowHash = args.predecessorRowHash;
301
306
 
302
- atomicFile.ensureDir(outDir);
303
-
304
307
  var firstRow = rows[0];
305
308
  var lastRow = rows[rows.length - 1];
309
+ var files = {};
306
310
 
307
311
  // 1. Encrypt the rows JSONL
308
312
  var jsonl = rows.map(function (r) {
309
313
  return JSON.stringify(_rowToWireForm(r));
310
314
  }).join("\n") + "\n";
311
315
  var rowsEnc = await backupCrypto.encryptWithFreshSalt(jsonl, passphrase);
312
- atomicFile.writeSync(nodePath.join(outDir, "rows.enc"), rowsEnc.encrypted, { fileMode: 0o600 });
316
+ files["rows.enc"] = rowsEnc.encrypted;
313
317
 
314
318
  // 2. (archive) Encrypt the checkpoint JSON
315
319
  var checkpointSalt = null;
320
+ var checkpointEncrypted = null;
316
321
  if (checkpoint) {
317
322
  var ckptJson = _canonicalize(_rowToWireForm(checkpoint));
318
323
  var ckptEnc = await backupCrypto.encryptWithFreshSalt(ckptJson, passphrase);
319
- atomicFile.writeSync(nodePath.join(outDir, "checkpoint.enc"), ckptEnc.encrypted, { fileMode: 0o600 });
324
+ files["checkpoint.enc"] = ckptEnc.encrypted;
320
325
  checkpointSalt = ckptEnc.salt;
326
+ checkpointEncrypted = ckptEnc.encrypted;
321
327
  }
322
328
 
323
- // 3. Build manifest
329
+ // 3. Build manifest — checksums computed from the in-memory buffers
330
+ // (no read-back of what we just wrote).
324
331
  var manifest = {
325
332
  format: BUNDLE_FORMAT,
326
333
  kind: kind,
@@ -342,8 +349,8 @@ async function _writeBundle(args) {
342
349
  },
343
350
  checksum: {
344
351
  rowsSha3_512: backupCrypto.checksum(rowsEnc.encrypted),
345
- checkpointSha3_512: checkpointSalt
346
- ? backupCrypto.checksum(nodeFs.readFileSync(nodePath.join(outDir, "checkpoint.enc")))
352
+ checkpointSha3_512: checkpointEncrypted
353
+ ? backupCrypto.checksum(checkpointEncrypted)
347
354
  : null,
348
355
  },
349
356
  };
@@ -355,9 +362,22 @@ async function _writeBundle(args) {
355
362
  checkpointId: String(checkpoint._id),
356
363
  };
357
364
  }
365
+ files["manifest.json"] = Buffer.from(_canonicalize(manifest), "utf8");
366
+ return { manifest: manifest, files: files };
367
+ }
368
+
369
+ async function _writeBundle(args) {
370
+ var outDir = args.outDir;
371
+ var built = await _buildBundle(args);
372
+
373
+ atomicFile.ensureDir(outDir);
374
+ atomicFile.writeSync(nodePath.join(outDir, "rows.enc"), built.files["rows.enc"], { fileMode: 0o600 });
375
+ if (built.files["checkpoint.enc"]) {
376
+ atomicFile.writeSync(nodePath.join(outDir, "checkpoint.enc"), built.files["checkpoint.enc"], { fileMode: 0o600 });
377
+ }
358
378
  var manifestPath = nodePath.join(outDir, "manifest.json");
359
- atomicFile.writeSync(manifestPath, _canonicalize(manifest), { fileMode: 0o600 });
360
- return { manifest: manifest, manifestPath: manifestPath };
379
+ atomicFile.writeSync(manifestPath, built.files["manifest.json"], { fileMode: 0o600 });
380
+ return { manifest: built.manifest, manifestPath: manifestPath };
361
381
  }
362
382
 
363
383
  // ---- Bundle reader ----
@@ -437,8 +457,14 @@ async function _readBundle(inDir, passphrase) {
437
457
  * Refuses if `opts.out` exists, no rows match, or no signed
438
458
  * checkpoint covers the slice (run `b.audit.checkpoint()` first).
439
459
  *
460
+ * Pass `returnBytes: true` instead of `out` for the bundle as an
461
+ * in-memory `{ filename: Buffer }` map (`rows.enc` + `checkpoint.enc`
462
+ * + `manifest.json`) — the read-only / serverless path. `out` and
463
+ * `returnBytes` are mutually exclusive.
464
+ *
440
465
  * @opts
441
- * out: string, // fresh directory path for the bundle
466
+ * out: string, // fresh directory path (omit when returnBytes)
467
+ * returnBytes:boolean, // true → return { manifest, files } in memory, no disk
442
468
  * before: number|Date|string, // archive rows recordedAt < this
443
469
  * passphrase: Buffer|string, // bundle-encryption passphrase
444
470
  *
@@ -455,7 +481,12 @@ async function _readBundle(inDir, passphrase) {
455
481
  async function archive(opts) {
456
482
  opts = opts || {};
457
483
  _requirePassphrase(opts.passphrase);
458
- _requireOutDir(opts.out, "archive");
484
+ var returnBytes = opts.returnBytes === true;
485
+ if (returnBytes && opts.out !== undefined) {
486
+ throw new AuditToolsError("audit-tools/out-and-return-bytes",
487
+ "archive: specify either opts.out (write to disk) or opts.returnBytes (in-memory bytes), not both");
488
+ }
489
+ if (!returnBytes) _requireOutDir(opts.out, "archive");
459
490
  var beforeMs = _toMs(opts.before);
460
491
  if (beforeMs == null) {
461
492
  throw new AuditToolsError("audit-tools/no-before",
@@ -482,6 +513,22 @@ async function archive(opts) {
482
513
 
483
514
  var predecessorRowHash = await readPredecessorHash(firstCounter);
484
515
 
516
+ if (returnBytes) {
517
+ var built = await _buildBundle({
518
+ kind: KIND_ARCHIVE,
519
+ rows: rows,
520
+ checkpoint: checkpoint,
521
+ passphrase: opts.passphrase,
522
+ predecessorRowHash: predecessorRowHash,
523
+ });
524
+ return {
525
+ manifest: built.manifest,
526
+ files: built.files,
527
+ rowCount: rows.length,
528
+ range: built.manifest.range,
529
+ };
530
+ }
531
+
485
532
  var written = await _writeBundle({
486
533
  outDir: opts.out,
487
534
  kind: KIND_ARCHIVE,
@@ -517,8 +564,15 @@ async function archive(opts) {
517
564
  * action filter that drops intermediate counters is rejected with
518
565
  * `audit-tools/non-contiguous`.
519
566
  *
567
+ * Pass `returnBytes: true` instead of `out` to get the bundle as an
568
+ * in-memory `{ filename: Buffer }` map (`rows.enc` + `manifest.json`)
569
+ * with no filesystem touch — the read-only / serverless path; ship it
570
+ * to object storage or over the wire. `out` and `returnBytes` are
571
+ * mutually exclusive.
572
+ *
520
573
  * @opts
521
- * out: string, // fresh directory path
574
+ * out: string, // fresh directory path (omit when returnBytes)
575
+ * returnBytes:boolean, // true → return { manifest, files } in memory, no disk
522
576
  * from: number|Date|string, // recordedAt >= this (inclusive)
523
577
  * to: number|Date|string, // recordedAt <= this (inclusive)
524
578
  * action: string, // exact action match (optional)
@@ -536,7 +590,12 @@ async function archive(opts) {
536
590
  async function exportSlice(opts) {
537
591
  opts = opts || {};
538
592
  _requirePassphrase(opts.passphrase);
539
- _requireOutDir(opts.out, "export");
593
+ var returnBytes = opts.returnBytes === true;
594
+ if (returnBytes && opts.out !== undefined) {
595
+ throw new AuditToolsError("audit-tools/out-and-return-bytes",
596
+ "export: specify either opts.out (write to disk) or opts.returnBytes (in-memory bytes), not both");
597
+ }
598
+ if (!returnBytes) _requireOutDir(opts.out, "export");
540
599
  var fromMs = _toMs(opts.from);
541
600
  var toMs = _toMs(opts.to);
542
601
  var readRows = opts.readRows || _defaultReadRows;
@@ -567,6 +626,22 @@ async function exportSlice(opts) {
567
626
  var firstCounter = Number(rows[0].monotonicCounter);
568
627
  var predecessorRowHash = await readPredecessorHash(firstCounter);
569
628
 
629
+ if (returnBytes) {
630
+ var built = await _buildBundle({
631
+ kind: KIND_EXPORT,
632
+ rows: rows,
633
+ checkpoint: null,
634
+ passphrase: opts.passphrase,
635
+ predecessorRowHash: predecessorRowHash,
636
+ });
637
+ return {
638
+ manifest: built.manifest,
639
+ files: built.files,
640
+ rowCount: rows.length,
641
+ range: built.manifest.range,
642
+ };
643
+ }
644
+
570
645
  var written = await _writeBundle({
571
646
  outDir: opts.out,
572
647
  kind: KIND_EXPORT,
@@ -852,9 +927,15 @@ async function _defaultApplyPurge(args) {
852
927
  * `audit.forensic_snapshot.composed` audit event so the act of
853
928
  * composing the snapshot is itself on-chain.
854
929
  *
930
+ * Pass `returnBytes: true` instead of `out` for the snapshot as an
931
+ * in-memory `{ filename: Buffer }` map (the slice's `rows.enc` +
932
+ * `manifest.json` plus `forensic-snapshot.json`) — the read-only /
933
+ * serverless path. `out` and `returnBytes` are mutually exclusive.
934
+ *
855
935
  * @opts
856
- * out: string, // fresh directory path
857
- * since: number|Date|string, // include rows recordedAt >= this
936
+ * out: string, // fresh directory path (omit when returnBytes)
937
+ * returnBytes:boolean, // true return { ...manifest, files } in memory, no disk
938
+ * since: number|Date|string, // include rows recordedAt >= this (windowed since → now)
858
939
  * passphrase: Buffer|string, // bundle-encryption passphrase
859
940
  * reason: string, // required incident-context reason
860
941
  * incidentId: string, // optional ticket / incident id
@@ -874,29 +955,39 @@ async function _defaultApplyPurge(args) {
874
955
  async function forensicSnapshot(opts) {
875
956
  opts = opts || {};
876
957
  _requirePassphrase(opts.passphrase);
877
- _requireOutDir(opts.out, "forensicSnapshot");
958
+ var returnBytes = opts.returnBytes === true;
959
+ if (returnBytes && opts.out !== undefined) {
960
+ throw new AuditToolsError("audit-tools/out-and-return-bytes",
961
+ "forensicSnapshot: specify either opts.out (write to disk) or opts.returnBytes (in-memory bytes), not both");
962
+ }
963
+ if (!returnBytes) _requireOutDir(opts.out, "forensicSnapshot");
878
964
  var sinceMs = _toMs(opts.since);
879
965
  if (sinceMs == null) {
880
966
  throw new AuditToolsError("audit-tools/no-since",
881
967
  "forensicSnapshot: opts.since is required");
882
968
  }
883
969
  validateOpts.requireNonEmptyString(opts.reason, "reason", AuditToolsError, "audit-tools/no-reason");
970
+ // exportSlice windows by from/to — pass the requested `since` as `from`
971
+ // and now as `to` so the snapshot captures only the incident window
972
+ // rather than the entire audit history.
884
973
  var sliceResult = await exportSlice({
885
- out: opts.out,
886
- since: sinceMs,
887
- until: Date.now(),
888
- passphrase: opts.passphrase,
889
- readRows: opts.readRows,
974
+ out: returnBytes ? undefined : opts.out,
975
+ returnBytes: returnBytes,
976
+ from: sinceMs,
977
+ to: Date.now(),
978
+ passphrase: opts.passphrase,
979
+ readRows: opts.readRows,
890
980
  readCoveringCheckpoint: opts.readCoveringCheckpoint,
891
981
  });
892
- // Compose snapshot manifest with operator-supplied IR context.
982
+ // Compose snapshot manifest with operator-supplied IR context. The
983
+ // audit slice lands as rows.enc inside the bundle either way.
893
984
  var manifest = {
894
985
  snapshotKind: "forensic",
895
986
  incidentId: opts.incidentId || null,
896
987
  reason: opts.reason,
897
988
  actor: opts.actor || null,
898
989
  composedAt: new Date().toISOString(),
899
- auditSliceFile: sliceResult && sliceResult.path,
990
+ auditSliceFile: returnBytes ? "rows.enc" : (sliceResult && sliceResult.manifestPath),
900
991
  auditSliceCount: sliceResult && sliceResult.rowCount,
901
992
  runtime: {
902
993
  nodeVersion: process.version,
@@ -906,14 +997,18 @@ async function forensicSnapshot(opts) {
906
997
  uptimeSec: Math.round(process.uptime()),
907
998
  },
908
999
  };
909
- var manifestPath = require("node:path").join(opts.out, "forensic-snapshot.json");
910
- require("node:fs").writeFileSync(manifestPath, _canonicalize(manifest), "utf8");
1000
+ var manifestBytes = Buffer.from(_canonicalize(manifest), "utf8");
1001
+ var manifestPath = null;
1002
+ if (!returnBytes) {
1003
+ manifestPath = nodePath.join(opts.out, "forensic-snapshot.json");
1004
+ atomicFile.writeSync(manifestPath, manifestBytes, { fileMode: 0o600 });
1005
+ }
911
1006
  try {
912
1007
  require("./audit").safeEmit({
913
1008
  action: "audit.forensic_snapshot.composed",
914
1009
  outcome: "success",
915
1010
  metadata: {
916
- out: opts.out,
1011
+ out: returnBytes ? null : opts.out,
917
1012
  incidentId: manifest.incidentId,
918
1013
  reason: opts.reason,
919
1014
  actor: opts.actor || null,
@@ -921,6 +1016,12 @@ async function forensicSnapshot(opts) {
921
1016
  },
922
1017
  });
923
1018
  } catch (_e) { /* audit best-effort */ }
1019
+ if (returnBytes) {
1020
+ // Mirror the on-disk layout: the slice's files plus the IR wrapper.
1021
+ var files = Object.assign({}, sliceResult.files);
1022
+ files["forensic-snapshot.json"] = manifestBytes;
1023
+ return Object.assign({}, manifest, { files: files });
1024
+ }
924
1025
  return Object.assign({}, manifest, { manifestPath: manifestPath });
925
1026
  }
926
1027
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.13.18",
3
+ "version": "0.13.19",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
@@ -0,0 +1,27 @@
1
+ {
2
+ "$schema": "../scripts/release-notes-schema.json",
3
+ "version": "0.13.19",
4
+ "date": "2026-05-27",
5
+ "headline": "`auditTools` export / archive / forensic-snapshot can return the bundle as bytes — no output directory for serverless / read-only filesystems",
6
+ "summary": "b.auditTools.exportSlice, b.auditTools.archive, and b.auditTools.forensicSnapshot required an `out` directory to write the encrypted bundle (rows.enc + optional checkpoint.enc + manifest.json), which is unusable on a read-only or ephemeral serverless filesystem. Each now accepts `returnBytes: true` instead of `out` and returns the bundle as an in-memory `{ filename: Buffer }` map — ready to stream to object storage or over the wire with no filesystem access. `out` and `returnBytes` are mutually exclusive. The on-disk path is unchanged. The bundle's encryption (XChaCha20-Poly1305 + Argon2id), chain-proof material, and manifest checksums are identical to the written bundle, so an in-memory bundle written to disk verifies exactly as one produced by the `out` path.",
7
+ "sections": [
8
+ {
9
+ "heading": "Added",
10
+ "items": [
11
+ {
12
+ "title": "`returnBytes` on `auditTools.exportSlice` / `archive` / `forensicSnapshot` — in-memory bundles",
13
+ "body": "Pass `returnBytes: true` (and omit `out`) to get the encrypted audit bundle as an in-memory `{ filename: Buffer }` map instead of a directory write — the read-only / serverless path. `exportSlice` / `archive` return `{ manifest, files, rowCount, range }`; `forensicSnapshot` returns `{ ...manifest, files }` where `files` carries the slice's `rows.enc` + `manifest.json` plus the `forensic-snapshot.json` incident wrapper. The encryption, chain proof, and manifest checksums match the on-disk bundle byte-for-byte, so the bytes verify with `verifyBundle` once written out. `out` and `returnBytes` are mutually exclusive (passing both throws)."
14
+ }
15
+ ]
16
+ },
17
+ {
18
+ "heading": "Fixed",
19
+ "items": [
20
+ {
21
+ "title": "`auditTools.forensicSnapshot` now honors the `since` window instead of capturing the entire audit history",
22
+ "body": "`forensicSnapshot` passed its `since` bound to the slice exporter under the wrong option name, so the time filter was silently dropped and the snapshot bundled every audit row regardless of `since`. The window is now applied — a snapshot scoped to an incident window contains only that window's rows. The snapshot manifest's `auditSliceFile` field, previously always undefined, now records the slice location."
23
+ }
24
+ ]
25
+ }
26
+ ]
27
+ }
@@ -0,0 +1,119 @@
1
+ "use strict";
2
+ /**
3
+ * b.auditTools export verbs — `returnBytes` (serverless / read-only fs)
4
+ * path. exportSlice / archive / forensicSnapshot assemble the bundle
5
+ * in memory as a { filename: Buffer } map instead of writing to an
6
+ * `out` directory. The in-memory bytes must be byte-identical to a
7
+ * written bundle, so writing them to disk and verifyBundle-ing proves
8
+ * the round-trip.
9
+ */
10
+
11
+ var helpers = require("../helpers");
12
+ var b = helpers.b;
13
+ var check = helpers.check;
14
+ var backupCrypto = require("../../lib/backup/crypto");
15
+
16
+ var PASS = "test-pass-".padEnd(32, "x");
17
+
18
+ function _rows(n, baseMs) {
19
+ var rows = [];
20
+ for (var i = 1; i <= n; i++) {
21
+ rows.push({
22
+ _id: "log-" + i,
23
+ monotonicCounter: i,
24
+ recordedAt: baseMs + i * 1000,
25
+ action: "user.login",
26
+ outcome: "success",
27
+ actorUserId: "alice",
28
+ actorUserIdHash: "h-alice",
29
+ actorIp: "10.0.0.5",
30
+ actorSessionId: "s-" + i,
31
+ resourceKind: "session",
32
+ resourceId: "s-" + i,
33
+ reason: null,
34
+ metadata: null,
35
+ prevHash: (i === 1 ? "00" : "ab").repeat(64),
36
+ rowHash: "ab".repeat(64),
37
+ });
38
+ }
39
+ return rows;
40
+ }
41
+
42
+ async function run() {
43
+ var base = Date.UTC(2026, 4, 1, 12, 0, 0);
44
+ var fakeRows = _rows(5, base);
45
+ async function readRows(_criteria) { return fakeRows; }
46
+ async function readPredecessorRowHash(_c) { return "00".repeat(64); }
47
+
48
+ // exportSlice returnBytes → in-memory files, no disk.
49
+ var slice = await b.auditTools.exportSlice({
50
+ returnBytes: true,
51
+ from: base, to: base + 10000,
52
+ passphrase: PASS, readRows: readRows,
53
+ readPredecessorRowHash: readPredecessorRowHash,
54
+ });
55
+ check("exportSlice returnBytes: no manifestPath / outDir",
56
+ slice.manifestPath === undefined && slice.outDir === undefined);
57
+ check("exportSlice returnBytes: files is a map", slice.files && typeof slice.files === "object");
58
+ check("exportSlice returnBytes: rows.enc is a Buffer", Buffer.isBuffer(slice.files["rows.enc"]));
59
+ check("exportSlice returnBytes: manifest.json is a Buffer", Buffer.isBuffer(slice.files["manifest.json"]));
60
+ check("exportSlice returnBytes: rowCount matches", slice.rowCount === 5);
61
+ check("exportSlice returnBytes: no checkpoint.enc (export kind)",
62
+ slice.files["checkpoint.enc"] === undefined);
63
+
64
+ // The manifest describes the actual returned bytes: its checksum
65
+ // over rows.enc must match (computed with the same function the
66
+ // bundle uses), and rows.enc must decrypt back to the 5 rows.
67
+ var manifest = JSON.parse(slice.files["manifest.json"].toString("utf8"));
68
+ check("exportSlice returnBytes: manifest rowCount matches", manifest.rowCount === 5);
69
+ check("exportSlice returnBytes: manifest kind is export", manifest.kind === "export");
70
+ check("exportSlice returnBytes: manifest checksum matches rows.enc bytes",
71
+ backupCrypto.checksum(slice.files["rows.enc"]) === manifest.checksum.rowsSha3_512);
72
+ var plain = (await backupCrypto.decryptWithPassphrase(
73
+ slice.files["rows.enc"], PASS, manifest.salts.rows)).toString("utf8");
74
+ var decodedRows = plain.split("\n").filter(Boolean);
75
+ check("exportSlice returnBytes: rows.enc decrypts to the 5 rows", decodedRows.length === 5);
76
+
77
+ // out + returnBytes is rejected (mutually exclusive).
78
+ var threwBoth = null;
79
+ try {
80
+ await b.auditTools.exportSlice({ out: "/tmp/x", returnBytes: true,
81
+ from: base, to: base + 10000, passphrase: PASS, readRows: readRows });
82
+ } catch (e) { threwBoth = e; }
83
+ check("exportSlice: out + returnBytes throws",
84
+ threwBoth && /either.*out.*or.*returnBytes/i.test(threwBoth.message));
85
+
86
+ // forensicSnapshot returnBytes → slice files + the IR wrapper, and
87
+ // the `since` window is actually applied (regression: it used to be
88
+ // dropped because the value was passed as `until`, not `to`).
89
+ var windowCriteria = null;
90
+ async function windowedReadRows(criteria) { windowCriteria = criteria; return fakeRows; }
91
+ var snap = await b.auditTools.forensicSnapshot({
92
+ returnBytes: true,
93
+ since: base + 2000,
94
+ passphrase: PASS,
95
+ reason: "IR drill — verify returnBytes path",
96
+ incidentId: "inc-rb-1",
97
+ readRows: windowedReadRows,
98
+ readPredecessorRowHash: readPredecessorRowHash,
99
+ });
100
+ check("forensicSnapshot returnBytes: files has the IR wrapper",
101
+ snap.files && Buffer.isBuffer(snap.files["forensic-snapshot.json"]));
102
+ check("forensicSnapshot returnBytes: files has the slice rows.enc",
103
+ Buffer.isBuffer(snap.files["rows.enc"]));
104
+ check("forensicSnapshot returnBytes: no disk manifestPath",
105
+ snap.manifestPath === null || snap.manifestPath === undefined);
106
+ check("forensicSnapshot returnBytes: snapshotKind is forensic",
107
+ snap.snapshotKind === "forensic");
108
+ check("forensicSnapshot: since is applied as the from-bound (fromMs set)",
109
+ windowCriteria && windowCriteria.fromMs === base + 2000);
110
+ }
111
+
112
+ module.exports = { run: run };
113
+
114
+ if (require.main === module) {
115
+ run().then(
116
+ function () { console.log("[audit-tools-return-bytes] OK"); },
117
+ function (e) { console.error(e); process.exit(1); }
118
+ );
119
+ }
@@ -9777,6 +9777,49 @@ function testWikiPortAgreesAcrossArtifacts() {
9777
9777
  bad);
9778
9778
  }
9779
9779
 
9780
+ // v0.13.19 — a CI job that runs the long test suites (smoke / wiki
9781
+ // e2e) MUST declare `timeout-minutes`. Without it a hung child (a
9782
+ // leaked timer / socket / fs.watch handle — the macOS smoke-hang
9783
+ // class) rides GitHub's 6-hour default before the job is reaped. The
9784
+ // smoke runner's per-file watchdog (test/smoke.js) catches most
9785
+ // hangs; this job-level backstop catches the rest and a regressed
9786
+ // watchdog. Encoded so a new test-running workflow job can't ship
9787
+ // without the backstop.
9788
+ function testTestJobsDeclareTimeout() {
9789
+ var bad = [];
9790
+ var files = _workflowFiles();
9791
+ for (var fi = 0; fi < files.length; fi += 1) {
9792
+ var wf = files[fi];
9793
+ var text;
9794
+ try { text = fs.readFileSync(wf, "utf8"); }
9795
+ catch (_e) { continue; }
9796
+ var lines = text.split(/\r?\n/);
9797
+ var jobs = []; // { name, startLine, lines: [] }
9798
+ var inJobs = false;
9799
+ var cur = null;
9800
+ for (var li = 0; li < lines.length; li += 1) {
9801
+ var ln = lines[li];
9802
+ if (!inJobs) { if (/^jobs:\s*$/.test(ln)) inJobs = true; continue; }
9803
+ // A job key sits at exactly 2-space indent under `jobs:`.
9804
+ var jm = ln.match(/^ {2}([A-Za-z0-9_-]+):\s*$/);
9805
+ if (jm) { cur = { name: jm[1], startLine: li + 1, lines: [] }; jobs.push(cur); continue; }
9806
+ if (cur) cur.lines.push(ln);
9807
+ }
9808
+ for (var ji = 0; ji < jobs.length; ji += 1) {
9809
+ var body = jobs[ji].lines.join("\n");
9810
+ var runsSuite = /node\s+test\/smoke\.js/.test(body) || /node\s+test\/e2e\.js/.test(body);
9811
+ if (runsSuite && !/(^|\n)\s*timeout-minutes\s*:/.test(body)) {
9812
+ bad.push({ file: wf, line: jobs[ji].startLine,
9813
+ content: "CI job '" + jobs[ji].name + "' runs the test suite but declares no " +
9814
+ "timeout-minutes — a hung job would ride GitHub's 6h default. Add `timeout-minutes: <n>`." });
9815
+ }
9816
+ }
9817
+ }
9818
+ bad = _filterMarkers(bad, "ci-test-job-missing-timeout");
9819
+ _report("every CI workflow job that runs the test suite declares timeout-minutes " +
9820
+ "(v0.13.19 — no test job rides GitHub's 6h default when a child hangs)", bad);
9821
+ }
9822
+
9780
9823
  // v0.11.43 drift cleanup — every `@nav` value in a lib/*.js @module
9781
9824
  // block MUST be one of the canonical category names below. The wiki
9782
9825
  // sidebar derives directly from `@nav`, so unreviewed drift surfaces
@@ -10319,6 +10362,10 @@ async function run() {
10319
10362
  // WIKI_PORT default must match the release-container.yml smoke
10320
10363
  // step's port mapping + curl host.
10321
10364
  testWikiPortAgreesAcrossArtifacts();
10365
+ // v0.13.19 CI hang backstop: every workflow job that runs the test
10366
+ // suite must declare timeout-minutes so a hung child can't ride
10367
+ // GitHub's 6-hour default.
10368
+ testTestJobsDeclareTimeout();
10322
10369
  // v0.12.1 compliance posture coverage detector: KNOWN_POSTURES ⊇
10323
10370
  // POSTURE_DEFAULTS + REGIME_MAP ⊇ KNOWN_POSTURES.
10324
10371
  testCompliancePostureCoverage();
@@ -166,6 +166,16 @@ if (!Number.isFinite(PARALLEL) || PARALLEL < 1) PARALLEL = 1;
166
166
  if (PARALLEL > 64) PARALLEL = 64; // allow:raw-byte-literal — sanity ceiling on parallel children, not bytes
167
167
  void os;
168
168
 
169
+ // Per-file watchdog budget. A forked child that never exits (leaked
170
+ // timer / socket / fs.watch handle — more common on macOS) would
171
+ // otherwise hang the whole run until the CI job's wall-clock limit.
172
+ // Past this budget the child is SIGKILLed and the file reported as a
173
+ // failure that NAMES the file, turning an unattributable multi-hour
174
+ // hang into a fast, diagnosable error. Generous (the full host suite
175
+ // runs in ~140s); override with SMOKE_FILE_TIMEOUT_MS.
176
+ var FILE_TIMEOUT_MS = parseInt(process.env.SMOKE_FILE_TIMEOUT_MS || "300000", 10);
177
+ if (!Number.isFinite(FILE_TIMEOUT_MS) || FILE_TIMEOUT_MS < 1000) FILE_TIMEOUT_MS = 300000;
178
+
169
179
  // _readTimings / _writeTimings — persist per-test durations under
170
180
  // .test-output/smoke-timings.json so the next run's LPT scheduler can
171
181
  // place long-tail tests on the first worker. Median of last 5 runs to
@@ -238,8 +248,46 @@ function _runFileForked(modulePath, displayName) {
238
248
  });
239
249
  var stdoutBuf = "";
240
250
  var stderrBuf = "";
251
+ // Single-resolve guard: close / error / watchdog can all fire; the
252
+ // first one wins and cancels the watchdog so a normal exit never
253
+ // trips it and a kill never double-resolves.
254
+ var settled = false;
255
+ function settle(result) {
256
+ if (settled) return;
257
+ settled = true;
258
+ clearTimeout(watchdog);
259
+ resolve(result);
260
+ }
261
+ var watchdog = setTimeout(function () {
262
+ // Child overran the budget with no exit — reap it and report a
263
+ // failure that names the file + the most likely cause.
264
+ try { child.kill("SIGKILL"); } catch (_e) { /* already gone */ }
265
+ settle({
266
+ ok: false,
267
+ ms: Date.now() - fileStart,
268
+ checks: 0,
269
+ error: "watchdog: '" + displayName + "' exceeded " + FILE_TIMEOUT_MS +
270
+ "ms with no exit — likely a leaked handle (timer / socket / fs.watch). " +
271
+ "Last stderr: " + (stderrBuf.slice(-500) || "(none)"),
272
+ stderr: stderrBuf,
273
+ displayName: displayName,
274
+ });
275
+ }, FILE_TIMEOUT_MS);
276
+ if (typeof watchdog.unref === "function") watchdog.unref();
241
277
  child.stdout.on("data", function (d) { stdoutBuf += d.toString("utf8"); });
242
278
  child.stderr.on("data", function (d) { stderrBuf += d.toString("utf8"); });
279
+ child.on("error", function (e) {
280
+ // fork() itself failed (ENOENT / EMFILE / spawn error) — without
281
+ // this handler the Promise would never resolve and hang the run.
282
+ settle({
283
+ ok: false,
284
+ ms: Date.now() - fileStart,
285
+ checks: 0,
286
+ error: displayName + ": fork error: " + ((e && e.message) || String(e)),
287
+ stderr: stderrBuf,
288
+ displayName: displayName,
289
+ });
290
+ });
243
291
  child.on("close", function (code) {
244
292
  var ms = Date.now() - fileStart;
245
293
  // Last line of stdout is the JSON result line.
@@ -248,7 +296,7 @@ function _runFileForked(modulePath, displayName) {
248
296
  var parsed;
249
297
  try { parsed = JSON.parse(resultLine); }
250
298
  catch (_e) { parsed = { ok: false, error: "no result line; stderr: " + stderrBuf.slice(0, 500) }; }
251
- resolve({
299
+ settle({
252
300
  ok: code === 0 && parsed.ok,
253
301
  ms: ms,
254
302
  checks: parsed.checks || 0,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
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": {