@blamejs/blamejs-shop 0.2.7 → 0.2.8
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 +2 -0
- package/lib/asset-manifest.json +3 -3
- package/lib/checkout.js +16 -0
- package/lib/order-export.js +7 -1
- package/lib/storefront.js +114 -30
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/.github/workflows/ci.yml +6 -0
- package/lib/vendor/blamejs/CHANGELOG.md +2 -0
- package/lib/vendor/blamejs/README.md +1 -1
- package/lib/vendor/blamejs/api-snapshot.json +2 -2
- package/lib/vendor/blamejs/lib/atomic-file.js +11 -7
- package/lib/vendor/blamejs/lib/audit-tools.js +129 -28
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.13.19.json +27 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/audit-tools-return-bytes.test.js +119 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +47 -0
- package/lib/vendor/blamejs/test/smoke.js +49 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.2.x
|
|
10
10
|
|
|
11
|
+
- 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.
|
|
12
|
+
|
|
11
13
|
- 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
14
|
|
|
13
15
|
- 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.
|
package/lib/asset-manifest.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.2.
|
|
2
|
+
"version": "0.2.8",
|
|
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-
|
|
10
|
-
"fingerprinted": "css/main.
|
|
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",
|
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
|
|
package/lib/order-export.js
CHANGED
|
@@ -74,7 +74,7 @@ var b = require("./vendor/blamejs");
|
|
|
74
74
|
|
|
75
75
|
// ---- column schema ------------------------------------------------------
|
|
76
76
|
//
|
|
77
|
-
// The
|
|
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
|
@@ -3367,6 +3367,54 @@ var CART_LINE_EDITABLE =
|
|
|
3367
3367
|
|
|
3368
3368
|
// ---- checkout form + payment page + order confirmation -----------------
|
|
3369
3369
|
|
|
3370
|
+
// The shipping-address fieldset for checkout. Reuses `_addrField` (the
|
|
3371
|
+
// same labelled-input builder the account address book uses) so the
|
|
3372
|
+
// checkout + saved-address forms collect an identical address shape.
|
|
3373
|
+
// `p` pre-fills each field (from a signed-in customer's default shipping
|
|
3374
|
+
// address); every value is escaped by `_addrField` / the email builder.
|
|
3375
|
+
// Street line 1 + city are marked required for the common physical-goods
|
|
3376
|
+
// path; the backend (checkout._shipTo) is authoritative and treats them
|
|
3377
|
+
// as optional so a digital-only order still completes.
|
|
3378
|
+
function _checkoutShippingFields(p) {
|
|
3379
|
+
p = p || {};
|
|
3380
|
+
var esc = b.template.escapeHtml;
|
|
3381
|
+
var email =
|
|
3382
|
+
"<label class=\"form-field\">" +
|
|
3383
|
+
"<span class=\"form-field__label\">Email <span class=\"form-field__req\" aria-hidden=\"true\">*</span></span>" +
|
|
3384
|
+
"<input type=\"email\" name=\"email\" value=\"" + esc(p.email == null ? "" : String(p.email)) + "\" required autocomplete=\"email\">" +
|
|
3385
|
+
"</label>";
|
|
3386
|
+
return email +
|
|
3387
|
+
_addrField("name", "Full name", p.name, { required: true, maxlength: 120, autocomplete: "name" }) +
|
|
3388
|
+
_addrField("line1", "Street address", p.line1, { required: true, maxlength: 200, autocomplete: "address-line1" }) +
|
|
3389
|
+
_addrField("line2", "Apt / suite / unit (optional)", p.line2, { maxlength: 200, autocomplete: "address-line2" }) +
|
|
3390
|
+
"<div class=\"form-row form-row--inline\">" +
|
|
3391
|
+
_addrField("city", "City", p.city, { required: true, maxlength: 120, autocomplete: "address-level2" }) +
|
|
3392
|
+
_addrField("state", "State / province code", p.state, { maxlength: 5, pattern: "[A-Za-z0-9]{1,5}", autocomplete: "address-level1" }) +
|
|
3393
|
+
"</div>" +
|
|
3394
|
+
"<div class=\"form-row form-row--inline\">" +
|
|
3395
|
+
_addrField("postal", "Postal code", p.postal, { maxlength: 16, autocomplete: "postal-code" }) +
|
|
3396
|
+
_addrField("country", "Country (ISO 3166-1)", p.country || "US", { required: true, maxlength: 2, pattern: "[A-Za-z]{2}", autocomplete: "country" }) +
|
|
3397
|
+
"</div>";
|
|
3398
|
+
}
|
|
3399
|
+
|
|
3400
|
+
// Build the order's ship_to from a checkout POST body. Shared by the
|
|
3401
|
+
// card-form POST and the PayPal create call so both persist an identical
|
|
3402
|
+
// address shape. A blank field becomes `undefined` (omitted) rather than
|
|
3403
|
+
// "" so checkout._shipTo's optional-field checks aren't tripped by an
|
|
3404
|
+
// empty input; country + state are upper-cased to match the ISO
|
|
3405
|
+
// validators, the free-text street/city fields are trimmed only.
|
|
3406
|
+
function _shipToFromBody(body) {
|
|
3407
|
+
body = body || {};
|
|
3408
|
+
return {
|
|
3409
|
+
country: (body.country || "").toUpperCase(),
|
|
3410
|
+
state: body.state ? String(body.state).toUpperCase() : undefined,
|
|
3411
|
+
postal: body.postal || undefined,
|
|
3412
|
+
line1: body.line1 ? String(body.line1).trim() : undefined,
|
|
3413
|
+
line2: body.line2 ? String(body.line2).trim() : undefined,
|
|
3414
|
+
city: body.city ? String(body.city).trim() : undefined,
|
|
3415
|
+
};
|
|
3416
|
+
}
|
|
3417
|
+
|
|
3370
3418
|
var CHECKOUT_PAGE =
|
|
3371
3419
|
"<section class=\"checkout-page\">\n" +
|
|
3372
3420
|
" <header class=\"section-head\">\n" +
|
|
@@ -3375,13 +3423,7 @@ var CHECKOUT_PAGE =
|
|
|
3375
3423
|
" <p class=\"section-head__lede\">Enter where the order should ship. Payment runs through Stripe on the next step.</p>\n" +
|
|
3376
3424
|
" </header>\n" +
|
|
3377
3425
|
" <form method=\"post\" action=\"/checkout\" class=\"form-stack\">\n" +
|
|
3378
|
-
"
|
|
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" +
|
|
3426
|
+
" RAW_SHIPPING_FIELDS" +
|
|
3385
3427
|
" <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
3428
|
" <div class=\"checkout-summary\">\n" +
|
|
3387
3429
|
" <h3>Order summary</h3>\n" +
|
|
@@ -3431,7 +3473,8 @@ function renderCheckoutForm(opts) {
|
|
|
3431
3473
|
asset_css_main: opts.theme.assetUrl("css/main.css"),
|
|
3432
3474
|
});
|
|
3433
3475
|
}
|
|
3434
|
-
var body = _render(CHECKOUT_PAGE, { subtotal: subtotal })
|
|
3476
|
+
var body = _render(CHECKOUT_PAGE, { subtotal: subtotal })
|
|
3477
|
+
.replace("RAW_SHIPPING_FIELDS", _checkoutShippingFields(opts.prefill));
|
|
3435
3478
|
// Signed-in customer with a spendable points balance — surface a
|
|
3436
3479
|
// redeem-at-checkout field. The block is appended as raw HTML (the
|
|
3437
3480
|
// balance + value are numbers we control, the conversion ratio is the
|
|
@@ -3534,7 +3577,7 @@ function _paypalCheckoutBlock(clientId, currency) {
|
|
|
3534
3577
|
"if(!window.paypal){return;}" +
|
|
3535
3578
|
"var form=document.querySelector('.checkout-page form');" +
|
|
3536
3579
|
"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;}" +
|
|
3580
|
+
"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
3581
|
"function showErr(m){if(errEl){errEl.hidden=false;errEl.textContent=m||'PayPal checkout could not be completed.';}}" +
|
|
3539
3582
|
"paypal.Buttons({" +
|
|
3540
3583
|
"onClick:function(_d,actions){if(form&&form.reportValidity&&!form.reportValidity()){return actions.reject();}return actions.resolve();}," +
|
|
@@ -3664,10 +3707,31 @@ var ORDER_PAGE =
|
|
|
3664
3707
|
" <div><dt>Shipping</dt><dd>{{shipping}}</dd></div>\n" +
|
|
3665
3708
|
" <div class=\"totals-list__grand\"><dt>Total</dt><dd>{{total}}</dd></div>\n" +
|
|
3666
3709
|
" </dl>\n" +
|
|
3667
|
-
" </aside>\n" +
|
|
3710
|
+
" RAW_SHIP_TO</aside>\n" +
|
|
3668
3711
|
" </div>\n" +
|
|
3669
3712
|
"</section>\n";
|
|
3670
3713
|
|
|
3714
|
+
// Renders the order's shipping address as an <address> block for the
|
|
3715
|
+
// confirmation page. Built from the stored `ship_to` (country/state/
|
|
3716
|
+
// postal + line1/line2/city); shows whichever parts are present so a
|
|
3717
|
+
// legacy order with only a country still renders, and a digital order
|
|
3718
|
+
// with no address renders nothing. Every part is HTML-escaped.
|
|
3719
|
+
function _shipToAddressBlock(s) {
|
|
3720
|
+
if (!s || typeof s !== "object") return "";
|
|
3721
|
+
var esc = b.template.escapeHtml;
|
|
3722
|
+
var cityLine = [s.city, s.state, s.postal]
|
|
3723
|
+
.filter(function (x) { return x != null && String(x).trim().length; })
|
|
3724
|
+
.map(String).join(", ");
|
|
3725
|
+
var parts = [s.line1, s.line2, cityLine, s.country]
|
|
3726
|
+
.filter(function (x) { return x != null && String(x).trim().length; })
|
|
3727
|
+
.map(function (x) { return "<span>" + esc(String(x)) + "</span>"; });
|
|
3728
|
+
if (!parts.length) return "";
|
|
3729
|
+
return "<div class=\"order-page__ship\">" +
|
|
3730
|
+
"<h2 class=\"pdp__variants-title\">Ship to</h2>" +
|
|
3731
|
+
"<address class=\"order-ship-address\">" + parts.join("") + "</address>" +
|
|
3732
|
+
"</div>";
|
|
3733
|
+
}
|
|
3734
|
+
|
|
3671
3735
|
function renderOrder(opts) {
|
|
3672
3736
|
if (!opts || !opts.order) throw new TypeError("storefront.renderOrder: opts.order required");
|
|
3673
3737
|
var o = opts.order;
|
|
@@ -3714,6 +3778,7 @@ function renderOrder(opts) {
|
|
|
3714
3778
|
tax: tax,
|
|
3715
3779
|
shipping: shipping,
|
|
3716
3780
|
total: total,
|
|
3781
|
+
ship_to: o.ship_to || null,
|
|
3717
3782
|
recommendations: recs,
|
|
3718
3783
|
has_recommendations: recs.length > 0,
|
|
3719
3784
|
asset_css_main: opts.theme.assetUrl("css/main.css"),
|
|
@@ -3742,7 +3807,7 @@ function renderOrder(opts) {
|
|
|
3742
3807
|
tax: tax,
|
|
3743
3808
|
shipping: shipping,
|
|
3744
3809
|
total: total,
|
|
3745
|
-
}).replace("RAW_LINES", rows);
|
|
3810
|
+
}).replace("RAW_LINES", rows).replace("RAW_SHIP_TO", _shipToAddressBlock(o.ship_to));
|
|
3746
3811
|
// Post-purchase cross-sell rail — reuses the catalog grid + product-card
|
|
3747
3812
|
// markup (so it inherits the storefront's card styling), rendered only
|
|
3748
3813
|
// when the picker returned something.
|
|
@@ -6335,22 +6400,49 @@ function mount(router, deps) {
|
|
|
6335
6400
|
return res.end ? res.end() : res.send("");
|
|
6336
6401
|
}
|
|
6337
6402
|
var totals = pricing.totals(c, lines, {});
|
|
6338
|
-
//
|
|
6339
|
-
//
|
|
6340
|
-
//
|
|
6403
|
+
// A signed-in customer drives two best-effort lookups, both keyed
|
|
6404
|
+
// off the same auth env: the loyalty balance (for the redeem field)
|
|
6405
|
+
// and the default shipping address (to pre-fill the form). Either
|
|
6406
|
+
// read failing (table not migrated, no saved address) degrades to
|
|
6407
|
+
// the un-prefilled / no-redeem checkout rather than 500-ing it.
|
|
6408
|
+
var coAuth = _currentCustomerEnv(req);
|
|
6341
6409
|
var loyaltyBalance = null;
|
|
6342
|
-
if (deps.loyalty) {
|
|
6343
|
-
|
|
6344
|
-
|
|
6345
|
-
|
|
6346
|
-
|
|
6347
|
-
|
|
6410
|
+
if (deps.loyalty && coAuth) {
|
|
6411
|
+
try { loyaltyBalance = await deps.loyalty.balance(coAuth.customer_id); }
|
|
6412
|
+
catch (_e) { loyaltyBalance = null; }
|
|
6413
|
+
}
|
|
6414
|
+
var prefill = null;
|
|
6415
|
+
if (deps.addresses && coAuth) {
|
|
6416
|
+
try {
|
|
6417
|
+
var addrRows = await deps.addresses.listForCustomer(coAuth.customer_id, { limit: 50 });
|
|
6418
|
+
var pick = null;
|
|
6419
|
+
for (var ai = 0; ai < addrRows.length; ai += 1) {
|
|
6420
|
+
if (Number(addrRows[ai].is_default_shipping) === 1) { pick = addrRows[ai]; break; }
|
|
6421
|
+
}
|
|
6422
|
+
if (!pick && addrRows.length) pick = addrRows[0];
|
|
6423
|
+
if (pick) {
|
|
6424
|
+
prefill = {
|
|
6425
|
+
name: pick.recipient_name,
|
|
6426
|
+
line1: pick.street_line1,
|
|
6427
|
+
line2: pick.street_line2,
|
|
6428
|
+
city: pick.city,
|
|
6429
|
+
postal: pick.postal_code,
|
|
6430
|
+
country: pick.country,
|
|
6431
|
+
// The saved `region` is free-text ("California"); checkout's
|
|
6432
|
+
// `state` is a short subdivision code. Carry it over only when
|
|
6433
|
+
// it already matches the code shape so we never pre-seed a
|
|
6434
|
+
// value the field's own pattern would reject.
|
|
6435
|
+
state: (pick.region && /^[A-Za-z0-9]{1,5}$/.test(pick.region)) ? pick.region : undefined,
|
|
6436
|
+
};
|
|
6437
|
+
}
|
|
6438
|
+
} catch (_e) { prefill = null; }
|
|
6348
6439
|
}
|
|
6349
6440
|
_send(res, 200, renderCheckoutForm({
|
|
6350
6441
|
lines: lines, totals: totals, shop_name: shopName, theme: theme,
|
|
6351
6442
|
paypal_client_id: deps.paypal ? deps.paypal_client_id : null,
|
|
6352
6443
|
loyalty_balance: loyaltyBalance,
|
|
6353
6444
|
loyalty_points_per_usd: deps.loyalty ? deps.loyalty.REDEMPTION_POINTS_PER_USD : null,
|
|
6445
|
+
prefill: prefill,
|
|
6354
6446
|
}));
|
|
6355
6447
|
});
|
|
6356
6448
|
|
|
@@ -6371,11 +6463,7 @@ function mount(router, deps) {
|
|
|
6371
6463
|
res.status(303); res.setHeader && res.setHeader("location", "/cart");
|
|
6372
6464
|
return res.end ? res.end() : res.send("");
|
|
6373
6465
|
}
|
|
6374
|
-
var shipTo =
|
|
6375
|
-
country: (body.country || "").toUpperCase(),
|
|
6376
|
-
state: body.state ? String(body.state).toUpperCase() : undefined,
|
|
6377
|
-
postal: body.postal || undefined,
|
|
6378
|
-
};
|
|
6466
|
+
var shipTo = _shipToFromBody(body);
|
|
6379
6467
|
try {
|
|
6380
6468
|
// default_shipping_id may be a literal string or an
|
|
6381
6469
|
// operator-supplied async resolver (e.g. backed by the
|
|
@@ -6446,11 +6534,7 @@ function mount(router, deps) {
|
|
|
6446
6534
|
if (!sid) return _json(400, { error: "no-session" });
|
|
6447
6535
|
var c = await deps.cart.bySession(sid);
|
|
6448
6536
|
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
|
-
};
|
|
6537
|
+
var shipTo = _shipToFromBody(body);
|
|
6454
6538
|
try {
|
|
6455
6539
|
var defaultShipId = typeof deps.default_shipping_id === "function"
|
|
6456
6540
|
? await deps.default_shipping_id() : deps.default_shipping_id;
|
package/lib/vendor/MANIFEST.json
CHANGED
|
@@ -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.
|
|
7
|
-
"tag": "v0.13.
|
|
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
|
|
|
@@ -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:
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
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
|
-
|
|
295
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
346
|
-
? backupCrypto.checksum(
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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:
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
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.
|
|
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
|
|
910
|
-
|
|
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
|
|
|
@@ -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
|
-
|
|
299
|
+
settle({
|
|
252
300
|
ok: code === 0 && parsed.ok,
|
|
253
301
|
ms: ms,
|
|
254
302
|
checks: parsed.checks || 0,
|
package/package.json
CHANGED