@blamejs/blamejs-shop 0.2.9 → 0.2.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/lib/asset-manifest.json +5 -5
- package/lib/storefront.js +231 -41
- package/lib/vendor/MANIFEST.json +3 -3
- package/lib/vendor/blamejs/CHANGELOG.md +6 -0
- package/lib/vendor/blamejs/README.md +2 -2
- package/lib/vendor/blamejs/api-snapshot.json +10 -2
- package/lib/vendor/blamejs/lib/agent-tenant.js +30 -0
- package/lib/vendor/blamejs/lib/archive-read.js +53 -40
- package/lib/vendor/blamejs/lib/archive-wrap.js +100 -32
- package/lib/vendor/blamejs/lib/cose.js +84 -0
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.13.20.json +22 -0
- package/lib/vendor/blamejs/release-notes/v0.13.21.json +18 -0
- package/lib/vendor/blamejs/release-notes/v0.13.22.json +18 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/archive-read.test.js +46 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/archive-wrap.test.js +50 -7
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +10 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/cose.test.js +42 -0
- package/package.json +1 -1
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.11 (2026-05-27) — **A clearer checkout — itemised order summary, honest pricing, and recoverable errors.** Reworks the checkout and cart experience. Checkout now uses the same two-column layout as the cart, with a sticky order-summary rail that lists the actual items being bought (thumbnail, title, quantity, line total) rather than a bare number in a lonely column. The summary no longer shows a misleading 'Total' equal to the subtotal — it shows the subtotal plus an honest note that tax and shipping are calculated at the payment step, matching the cart. Adding an item now confirms it and offers to keep shopping, checkout and cart failures render as proper pages you can recover from instead of a blank error string, required fields announce themselves to screen readers, and the header 'Framework' link works from every page. **Changed:** *Checkout shows an itemised, sticky order summary* — The checkout page adopts the cart's two-column layout: the shipping form on the left and a sticky order summary on the right that lists each item (thumbnail, title, quantity, line total) with an 'Edit cart' link back to the cart. Previously the form was a narrow column with a bare subtotal box and no way back. · *Honest checkout pricing* — The order summary no longer renders a 'Total (plus tax + shipping)' line bound to the subtotal value (which read as the final price but wasn't). It now shows the subtotal and the same honest note the cart uses — tax and shipping are calculated at the payment step. · *Add-to-cart confirmation + continue shopping* — Adding an item lands on the cart with a dismissible 'Added to cart' confirmation, and the cart now has a 'Continue shopping' link so multi-item shoppers aren't dead-ended. **Fixed:** *Checkout and cart errors are recoverable, not blank pages* — A declined gift card, an invalid input, or a checkout failure previously dumped an unstyled plain-text error. These now render as styled pages with the reason and a way back — gift-card and loyalty errors re-display the checkout form with the message inline so the shopper can correct it without losing their place. · *Required fields announce to screen readers* — Required form fields kept only a red asterisk (color-only); they now include a visually-hidden '(required)' so assistive technology conveys it. · *Header 'Framework' link works on every page* — It pointed at an on-page anchor that only existed on the home page (a no-op elsewhere); it now resolves from any page.
|
|
12
|
+
|
|
13
|
+
- v0.2.10 (2026-05-27) — **Accessibility and mobile polish — the cookie bar stops covering the page, and forms behave on phones.** A round of default-theme refinements focused on the cookie consent bar, mobile ergonomics, and accessibility. The consent bar previously floated over page content — on a product page or checkout it could sit on top of the price, variant picker, and the add-to-cart / continue-to-payment button on a first visit. It is now a compact bottom bar that reserves its own space (so nothing important is ever hidden under it), scrolls within itself if the text is long, and receives keyboard focus when it appears. Alongside it: mobile form inputs no longer trigger iOS zoom-on-focus, the header touch targets meet the 44px minimum, inline text links are underlined, and a consistency pass tightened button shapes, table-label typography, and small-screen layout. **Changed:** *Visual consistency pass* — Action buttons share a single corner radius (primary and secondary no longer differ in shape), uppercase micro-labels use the monospace face consistently across tables and forms, the catalog grid shows two columns on small phones instead of one, the hero stat row breathes on narrow screens, and a few dark-theme borders/placeholders that read muddy were moved onto the semantic token palette. **Fixed:** *Cookie consent bar no longer covers page content* — On a first visit the consent bar could overlap the product buy box and the checkout form (the price, variant selector, and primary button). It is now a compact bottom-docked bar that reserves matching space at the foot of the page until a choice is made, caps its height and scrolls internally for long text, and moves keyboard focus to its first control when shown — so the page's primary action is never hidden and the bar is reachable without tabbing the whole page. · *Mobile form inputs no longer zoom on iOS* — Text inputs, selects, and textareas render at a 16px minimum on touch devices, so mobile Safari no longer zooms the viewport when a field is focused (which previously left the page zoomed-in and pannable mid-checkout). · *Header touch targets meet the 44px minimum on mobile* — The account and cart controls in the header — the two primary controls on a phone — are now at least 44×44px. · *Clearer link and prompt-glyph affordances* — Inline text links in body copy are underlined (no longer distinguished by color alone), and the terminal prompt glyph on the dark accent bands renders in full contrast.
|
|
14
|
+
|
|
11
15
|
- 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
16
|
|
|
13
17
|
- 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.
|
package/lib/asset-manifest.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.2.
|
|
2
|
+
"version": "0.2.11",
|
|
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-8sqFG/ZiALsYtuuzk8cAlSI1exfg9l2UOFs/EmWacVW83DGwOI4g+r29MUCGlKSE",
|
|
10
|
+
"fingerprinted": "css/main.7f09021a222915e6.css"
|
|
11
11
|
},
|
|
12
12
|
"js/announcement.js": {
|
|
13
13
|
"integrity": "sha384-z4zcEMn+tScoVnYRE4nEf8N/oyvpxdpaxTNrT4QO/jURChid4+qjAvWkzatCaAPq",
|
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
"fingerprinted": "js/cart-count.bfc2eb434b19c00a.js"
|
|
19
19
|
},
|
|
20
20
|
"js/consent.js": {
|
|
21
|
-
"integrity": "sha384-
|
|
22
|
-
"fingerprinted": "js/consent.
|
|
21
|
+
"integrity": "sha384-XY3GHA5QDj/Nri03ZO1t7nsr9RGepZuanY45CTpFQnnTorQnygbtAVG4vVGfkNIP",
|
|
22
|
+
"fingerprinted": "js/consent.59060d7723d050b2.js"
|
|
23
23
|
},
|
|
24
24
|
"js/passkey-login.js": {
|
|
25
25
|
"integrity": "sha384-65i1wVHIkiS2e0YiOV6oUOF9+tNR7s6QRvpnWaEne43P+B8UvKLTpDgp4MSKNGqX",
|
package/lib/storefront.js
CHANGED
|
@@ -186,7 +186,7 @@ var LAYOUT =
|
|
|
186
186
|
" </form>\n" +
|
|
187
187
|
" <nav class=\"site-nav\" aria-label=\"Primary\">\n" +
|
|
188
188
|
" <a class=\"site-nav__link\" href=\"/\">{{nav_shop}}</a>\n" +
|
|
189
|
-
" <a class=\"site-nav__link\" href=\"
|
|
189
|
+
" <a class=\"site-nav__link\" href=\"/#framework\">{{nav_framework}}</a>\n" +
|
|
190
190
|
" <a class=\"site-nav__icon\" href=\"/account\" aria-label=\"{{nav_account}}\"><svg viewBox=\"0 0 24 24\" width=\"20\" height=\"20\" aria-hidden=\"true\"><path d=\"M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm-7 9a7 7 0 0 1 14 0\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\"/></svg></a>\n" +
|
|
191
191
|
" <a class=\"cart-pill\" href=\"/cart\" aria-label=\"{{nav_cart_aria}}\"><svg viewBox=\"0 0 24 24\" width=\"18\" height=\"18\" aria-hidden=\"true\"><path d=\"M3 4h2l2.4 12.1a2 2 0 0 0 2 1.6h7.6a2 2 0 0 0 1.95-1.55L21 8H6\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/><circle cx=\"10\" cy=\"21\" r=\"1.4\" fill=\"currentColor\"/><circle cx=\"17\" cy=\"21\" r=\"1.4\" fill=\"currentColor\"/></svg><span class=\"cart-pill__count\">{{cart_count}}</span></a>\n" +
|
|
192
192
|
" </nav>\n" +
|
|
@@ -2150,7 +2150,9 @@ function _addrField(name, labelText, value, opts) {
|
|
|
2150
2150
|
if (opts.maxlength) attrs += " maxlength=\"" + opts.maxlength + "\"";
|
|
2151
2151
|
if (opts.pattern) attrs += " pattern=\"" + esc(opts.pattern) + "\"";
|
|
2152
2152
|
if (opts.autocomplete) attrs += " autocomplete=\"" + esc(opts.autocomplete) + "\"";
|
|
2153
|
-
|
|
2153
|
+
// The `*` is a color-only visual cue; pair it with a visually-hidden
|
|
2154
|
+
// "(required)" so a screen reader announces the field's requiredness.
|
|
2155
|
+
var req = opts.required ? " <span class=\"form-field__req\" aria-hidden=\"true\">*</span><span class=\"sr-only\">(required)</span>" : "";
|
|
2154
2156
|
return "<label class=\"form-field\">" +
|
|
2155
2157
|
"<span class=\"form-field__label\">" + esc(labelText) + req + "</span>" +
|
|
2156
2158
|
"<input type=\"text\" name=\"" + esc(name) + "\" value=\"" + esc(value == null ? "" : String(value)) + "\"" + attrs + ">" +
|
|
@@ -3382,7 +3384,7 @@ function _checkoutShippingFields(p) {
|
|
|
3382
3384
|
var esc = b.template.escapeHtml;
|
|
3383
3385
|
var email =
|
|
3384
3386
|
"<label class=\"form-field\">" +
|
|
3385
|
-
"<span class=\"form-field__label\">Email <span class=\"form-field__req\" aria-hidden=\"true\">*</span></span>" +
|
|
3387
|
+
"<span class=\"form-field__label\">Email <span class=\"form-field__req\" aria-hidden=\"true\">*</span><span class=\"sr-only\">(required)</span></span>" +
|
|
3386
3388
|
"<input type=\"email\" name=\"email\" value=\"" + esc(p.email == null ? "" : String(p.email)) + "\" required autocomplete=\"email\">" +
|
|
3387
3389
|
"</label>";
|
|
3388
3390
|
return email +
|
|
@@ -3417,6 +3419,12 @@ function _shipToFromBody(body) {
|
|
|
3417
3419
|
};
|
|
3418
3420
|
}
|
|
3419
3421
|
|
|
3422
|
+
// Checkout mirrors the cart's two-column shell: the shipping form on the
|
|
3423
|
+
// left, a sticky order-summary rail on the right. The summary lists the
|
|
3424
|
+
// cart line items + the Subtotal, then the cart's exact honest microcopy
|
|
3425
|
+
// — tax + shipping can't be computed until the address is entered, so no
|
|
3426
|
+
// fabricated "Total" is shown. An "Edit cart" link near the summary lets
|
|
3427
|
+
// the shopper change quantities without losing form data.
|
|
3420
3428
|
var CHECKOUT_PAGE =
|
|
3421
3429
|
"<section class=\"checkout-page\">\n" +
|
|
3422
3430
|
" <header class=\"section-head\">\n" +
|
|
@@ -3424,20 +3432,54 @@ var CHECKOUT_PAGE =
|
|
|
3424
3432
|
" <h1 class=\"section-head__title\">Shipping details</h1>\n" +
|
|
3425
3433
|
" <p class=\"section-head__lede\">Enter where the order should ship. Payment runs through Stripe on the next step.</p>\n" +
|
|
3426
3434
|
" </header>\n" +
|
|
3427
|
-
" <
|
|
3428
|
-
"
|
|
3429
|
-
"
|
|
3430
|
-
"
|
|
3431
|
-
"
|
|
3432
|
-
"
|
|
3433
|
-
" <div><
|
|
3434
|
-
"
|
|
3435
|
-
" </dl>\n" +
|
|
3435
|
+
" <div class=\"cart-page__grid\">\n" +
|
|
3436
|
+
" <div class=\"checkout-page__form-col\">\n" +
|
|
3437
|
+
" <form method=\"post\" action=\"/checkout\" class=\"form-stack\">\n" +
|
|
3438
|
+
" RAW_INLINE_ERROR" +
|
|
3439
|
+
" RAW_SHIPPING_FIELDS" +
|
|
3440
|
+
" <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" +
|
|
3441
|
+
" <div class=\"form-actions\"><button type=\"submit\" class=\"btn-primary\">Continue to payment <span aria-hidden=\"true\">→</span></button></div>\n" +
|
|
3442
|
+
" </form>\n" +
|
|
3436
3443
|
" </div>\n" +
|
|
3437
|
-
" <
|
|
3438
|
-
"
|
|
3444
|
+
" <aside class=\"cart-page__summary\">\n" +
|
|
3445
|
+
" <div class=\"checkout-page__summary-head\">\n" +
|
|
3446
|
+
" <h2 class=\"pdp__variants-title\">Order summary</h2>\n" +
|
|
3447
|
+
" <a href=\"/cart\" class=\"checkout-page__edit-cart\">Edit cart</a>\n" +
|
|
3448
|
+
" </div>\n" +
|
|
3449
|
+
" <ul class=\"checkout-page__lines\">RAW_SUMMARY_LINES</ul>\n" +
|
|
3450
|
+
" <dl class=\"totals-list\">\n" +
|
|
3451
|
+
" <div class=\"totals-list__grand\"><dt>Subtotal</dt><dd>{{subtotal}}</dd></div>\n" +
|
|
3452
|
+
" </dl>\n" +
|
|
3453
|
+
" <p class=\"cart-page__note\">Tax and shipping are calculated on the next step. Payment runs through Stripe.</p>\n" +
|
|
3454
|
+
" </aside>\n" +
|
|
3455
|
+
" </div>\n" +
|
|
3439
3456
|
"</section>\n";
|
|
3440
3457
|
|
|
3458
|
+
// One order-summary line item in the checkout rail: thumbnail + title +
|
|
3459
|
+
// qty + line total, mirroring the cart row but compact. `l` is the raw
|
|
3460
|
+
// cart line; `lookup` is the variant_id → { product, hero_media } map the
|
|
3461
|
+
// route passes (same shape renderCart uses). Missing lookup entries fall
|
|
3462
|
+
// back to the SKU title + a dashed placeholder tile.
|
|
3463
|
+
function _checkoutSummaryLine(l, lookup, assetPrefix, fmt) {
|
|
3464
|
+
var esc = b.template.escapeHtml;
|
|
3465
|
+
var match = lookup[l.variant_id] || null;
|
|
3466
|
+
var prod = match && match.product;
|
|
3467
|
+
var hero = match && match.hero_media;
|
|
3468
|
+
var title = (prod && prod.title) || l.sku;
|
|
3469
|
+
var lineTotal = fmt(l.line_total_minor || (l.qty * l.unit_amount_minor), l.unit_currency);
|
|
3470
|
+
var thumb = (hero && hero.r2_key)
|
|
3471
|
+
? "<span class=\"checkout-line__thumb\"><img src=\"" + esc(assetPrefix + hero.r2_key) + "\" alt=\"" + esc(hero.alt_text || title) + "\" loading=\"lazy\"></span>"
|
|
3472
|
+
: "<span class=\"checkout-line__thumb checkout-line__thumb--empty\" aria-hidden=\"true\"></span>";
|
|
3473
|
+
return "<li class=\"checkout-line\">" +
|
|
3474
|
+
thumb +
|
|
3475
|
+
"<span class=\"checkout-line__meta\">" +
|
|
3476
|
+
"<span class=\"checkout-line__title\">" + esc(title) + "</span>" +
|
|
3477
|
+
"<span class=\"checkout-line__qty\">Qty " + esc(String(l.qty)) + "</span>" +
|
|
3478
|
+
"</span>" +
|
|
3479
|
+
"<span class=\"checkout-line__total price\">" + esc(lineTotal) + "</span>" +
|
|
3480
|
+
"</li>";
|
|
3481
|
+
}
|
|
3482
|
+
|
|
3441
3483
|
// Redeem-points-at-checkout field — appended to the checkout form for a
|
|
3442
3484
|
// signed-in customer with a spendable balance. The customer types how
|
|
3443
3485
|
// many points to spend; the server caps the credit at the order total
|
|
@@ -3465,6 +3507,8 @@ function renderCheckoutForm(opts) {
|
|
|
3465
3507
|
var lines = opts.lines || [];
|
|
3466
3508
|
var totals = opts.totals || { subtotal_minor: 0, currency: "USD" };
|
|
3467
3509
|
var shopName = opts.shop_name || "blamejs.shop";
|
|
3510
|
+
var assetPrefix = opts.asset_prefix || "/assets/";
|
|
3511
|
+
var lookup = opts.product_lookup || {};
|
|
3468
3512
|
var subtotal = pricing.format(totals.subtotal_minor, totals.currency);
|
|
3469
3513
|
if (opts.theme) {
|
|
3470
3514
|
return opts.theme.render("checkout", {
|
|
@@ -3475,8 +3519,23 @@ function renderCheckoutForm(opts) {
|
|
|
3475
3519
|
asset_css_main: opts.theme.assetUrl("css/main.css"),
|
|
3476
3520
|
});
|
|
3477
3521
|
}
|
|
3522
|
+
// Order-summary line items in the sticky rail — same thumbnail + title
|
|
3523
|
+
// pattern as the cart, compact. Formatted in the order's own currency
|
|
3524
|
+
// (the checkout total is computed server-side; no display-currency
|
|
3525
|
+
// conversion happens at this step).
|
|
3526
|
+
var summaryLines = lines.map(function (l) {
|
|
3527
|
+
return _checkoutSummaryLine(l, lookup, assetPrefix, pricing.format);
|
|
3528
|
+
}).join("");
|
|
3529
|
+
// A coded gift-card / loyalty error from a rejected POST re-renders the
|
|
3530
|
+
// form with the message inline (role="alert") rather than dead-ending on
|
|
3531
|
+
// a separate error page — the shopper fixes the bad code in place.
|
|
3532
|
+
var inlineError = opts.inline_error
|
|
3533
|
+
? "<p class=\"auth-form__message auth-form__message--err\" role=\"alert\">" + b.template.escapeHtml(String(opts.inline_error)) + "</p>"
|
|
3534
|
+
: "";
|
|
3478
3535
|
var body = _render(CHECKOUT_PAGE, { subtotal: subtotal })
|
|
3479
|
-
.replace("
|
|
3536
|
+
.replace("RAW_INLINE_ERROR", inlineError)
|
|
3537
|
+
.replace("RAW_SHIPPING_FIELDS", _checkoutShippingFields(opts.prefill))
|
|
3538
|
+
.replace("RAW_SUMMARY_LINES", summaryLines);
|
|
3480
3539
|
// Signed-in customer with a spendable points balance — surface a
|
|
3481
3540
|
// redeem-at-checkout field. The block is appended as raw HTML (the
|
|
3482
3541
|
// balance + value are numbers we control, the conversion ratio is the
|
|
@@ -3839,6 +3898,7 @@ var CART_PAGE =
|
|
|
3839
3898
|
" <li aria-current=\"page\">Cart</li>\n" +
|
|
3840
3899
|
" </ol>\n" +
|
|
3841
3900
|
" </nav>\n" +
|
|
3901
|
+
"RAW_CART_NOTICE" +
|
|
3842
3902
|
" <header class=\"section-head\">\n" +
|
|
3843
3903
|
" <p class=\"eyebrow\">Your cart</p>\n" +
|
|
3844
3904
|
" <h1 class=\"section-head__title\">Review your items</h1>\n" +
|
|
@@ -3851,6 +3911,7 @@ var CART_PAGE =
|
|
|
3851
3911
|
" <tbody>{{line_rows}}</tbody>\n" +
|
|
3852
3912
|
" </table>\n" +
|
|
3853
3913
|
" </div>\n" +
|
|
3914
|
+
" <a href=\"/\" class=\"btn-ghost cart-page__continue\">← Continue shopping</a>\n" +
|
|
3854
3915
|
" </div>\n" +
|
|
3855
3916
|
" <aside class=\"cart-page__summary\">\n" +
|
|
3856
3917
|
" <h2 class=\"pdp__variants-title\">Order summary</h2>\n" +
|
|
@@ -3863,6 +3924,16 @@ var CART_PAGE =
|
|
|
3863
3924
|
" </div>\n" +
|
|
3864
3925
|
"</section>\n";
|
|
3865
3926
|
|
|
3927
|
+
// The "added to cart" status banner shown after a POST /cart/lines
|
|
3928
|
+
// redirect (`?added=1`). role="status" so a screen reader announces it
|
|
3929
|
+
// without stealing focus; the dismiss link drops the query param so a
|
|
3930
|
+
// refresh doesn't re-announce. Container-only — the edge serves the
|
|
3931
|
+
// cookie-less empty-cart view, never a post-add render.
|
|
3932
|
+
var CART_ADDED_NOTICE =
|
|
3933
|
+
" <p class=\"cart-page__added\" role=\"status\">\n" +
|
|
3934
|
+
" <span>Added to cart.</span> <a href=\"/cart\">Dismiss</a>\n" +
|
|
3935
|
+
" </p>\n";
|
|
3936
|
+
|
|
3866
3937
|
var CART_EMPTY_PAGE =
|
|
3867
3938
|
"<section class=\"cart-page cart-page--empty\">\n" +
|
|
3868
3939
|
" <nav class=\"breadcrumb\" aria-label=\"Breadcrumb\">\n" +
|
|
@@ -3885,6 +3956,40 @@ var CART_EMPTY_PAGE =
|
|
|
3885
3956
|
" </div>\n" +
|
|
3886
3957
|
"</section>\n";
|
|
3887
3958
|
|
|
3959
|
+
// Styled, recoverable error page for a cart/checkout dead-end (a bad add,
|
|
3960
|
+
// a session that vanished, a non-coded checkout failure). Reuses the
|
|
3961
|
+
// cart-empty card so the failure reads as a designed state, not raw text —
|
|
3962
|
+
// with the human-readable reason + a recovery link. `opts.reason` is the
|
|
3963
|
+
// message, `opts.back_href`/`opts.back_label` the recovery action (default
|
|
3964
|
+
// "Back to cart"), and `opts.eyebrow` the kicker (default "Cart").
|
|
3965
|
+
function renderCheckoutError(opts) {
|
|
3966
|
+
opts = opts || {};
|
|
3967
|
+
var esc = b.template.escapeHtml;
|
|
3968
|
+
var shopName = opts.shop_name || "blamejs.shop";
|
|
3969
|
+
var eyebrow = opts.eyebrow || "Cart";
|
|
3970
|
+
var title = opts.title_text || "Something went wrong";
|
|
3971
|
+
var reason = opts.reason || "We couldn't process that request. Your cart is saved — please try again.";
|
|
3972
|
+
var backHref = opts.back_href || "/cart";
|
|
3973
|
+
var backLabel = opts.back_label || "Back to cart";
|
|
3974
|
+
var secondary = opts.secondary_href
|
|
3975
|
+
? " <a href=\"" + esc(opts.secondary_href) + "\" class=\"btn-ghost\">" + esc(opts.secondary_label || "Keep browsing") + "</a>"
|
|
3976
|
+
: " <a href=\"/\" class=\"btn-ghost\">Keep browsing</a>";
|
|
3977
|
+
var body =
|
|
3978
|
+
"<section class=\"cart-page cart-page--empty\"><div class=\"cart-empty\"><div class=\"cart-empty__card\">" +
|
|
3979
|
+
"<p class=\"eyebrow cart-empty__eyebrow\">" + esc(eyebrow) + "</p>" +
|
|
3980
|
+
"<h1 class=\"cart-empty__title\">" + esc(title) + "</h1>" +
|
|
3981
|
+
"<p class=\"cart-empty__lede\">" + esc(reason) + "</p>" +
|
|
3982
|
+
"<div class=\"cart-empty__cta\"><a class=\"btn-primary\" href=\"" + esc(backHref) + "\">" + esc(backLabel) + "</a>" + secondary + "</div>" +
|
|
3983
|
+
"</div></div></section>";
|
|
3984
|
+
return _wrap({
|
|
3985
|
+
title: opts.title || "Checkout",
|
|
3986
|
+
shop_name: shopName,
|
|
3987
|
+
theme_css: opts.theme_css,
|
|
3988
|
+
cart_count: opts.cart_count == null ? 0 : opts.cart_count,
|
|
3989
|
+
body: body,
|
|
3990
|
+
});
|
|
3991
|
+
}
|
|
3992
|
+
|
|
3888
3993
|
function renderCart(opts) {
|
|
3889
3994
|
if (!opts) throw new TypeError("storefront.renderCart: opts required");
|
|
3890
3995
|
var lines = opts.lines || [];
|
|
@@ -3965,11 +4070,15 @@ function renderCart(opts) {
|
|
|
3965
4070
|
" <p class=\"cart-page__note\">Tax and shipping are calculated on the next step. Payment runs through Stripe.</p>\n"
|
|
3966
4071
|
: " <button type=\"button\" class=\"btn-primary cart-page__checkout\" disabled aria-disabled=\"true\">Checkout unavailable</button>\n" +
|
|
3967
4072
|
" <p class=\"cart-page__note cart-page__note--warn\" role=\"status\">Online checkout isn't set up for this store yet — payments aren't configured. Your cart is saved; please check back soon.</p>\n";
|
|
4073
|
+
// Post-add confirmation banner — rendered only on the `?added=1`
|
|
4074
|
+
// redirect from POST /cart/lines so the shopper gets explicit feedback
|
|
4075
|
+
// their item landed (the audit found the silent 303 left no cue).
|
|
4076
|
+
var notice = opts.added ? CART_ADDED_NOTICE : "";
|
|
3968
4077
|
body = _render(CART_PAGE, {
|
|
3969
4078
|
line_rows: "RAW_LINES",
|
|
3970
4079
|
subtotal: subtotal,
|
|
3971
4080
|
total: total,
|
|
3972
|
-
}).replace("RAW_LINES", rows).replace("RAW_CHECKOUT_CTA", checkoutCta);
|
|
4081
|
+
}).replace("RAW_LINES", rows).replace("RAW_CHECKOUT_CTA", checkoutCta).replace("RAW_CART_NOTICE", notice);
|
|
3973
4082
|
}
|
|
3974
4083
|
return _wrap(Object.assign({
|
|
3975
4084
|
title: "Cart",
|
|
@@ -6302,6 +6411,12 @@ function mount(router, deps) {
|
|
|
6302
6411
|
router.get("/cart", async function (req, res) {
|
|
6303
6412
|
var ccy = await _currencyForReq(req);
|
|
6304
6413
|
var sid = _readSidCookie(req);
|
|
6414
|
+
// `?added=1` after a POST /cart/lines redirect — drives the
|
|
6415
|
+
// "Added to cart" status banner. Read from the parsed query when the
|
|
6416
|
+
// router populated it, else from the raw URL.
|
|
6417
|
+
var cartUrl = req.url ? new URL(req.url, "http://localhost") : null;
|
|
6418
|
+
var added = (req.query && req.query.added === "1") ||
|
|
6419
|
+
(cartUrl && cartUrl.searchParams.get("added") === "1") || false;
|
|
6305
6420
|
if (!sid) {
|
|
6306
6421
|
return _send(res, 200, renderCart(Object.assign({
|
|
6307
6422
|
lines: [], totals: { subtotal_minor: 0, grand_total_minor: 0, currency: "USD" },
|
|
@@ -6346,6 +6461,7 @@ function mount(router, deps) {
|
|
|
6346
6461
|
product_lookup: productLookup,
|
|
6347
6462
|
can_save: !!(deps.saveForLater && deps.customers),
|
|
6348
6463
|
checkout_available: !!(deps.checkout && deps.order),
|
|
6464
|
+
added: added,
|
|
6349
6465
|
shop_name: shopName,
|
|
6350
6466
|
theme: theme,
|
|
6351
6467
|
}, ccy)));
|
|
@@ -6387,20 +6503,26 @@ function mount(router, deps) {
|
|
|
6387
6503
|
}
|
|
6388
6504
|
|
|
6389
6505
|
if (deps.checkout && deps.order) {
|
|
6390
|
-
|
|
6391
|
-
|
|
6392
|
-
|
|
6393
|
-
|
|
6394
|
-
|
|
6395
|
-
|
|
6396
|
-
|
|
6397
|
-
}
|
|
6398
|
-
var lines = await deps.cart.listLines(c.id);
|
|
6399
|
-
if (!lines.length) {
|
|
6400
|
-
res.status(303); res.setHeader && res.setHeader("location", "/cart");
|
|
6401
|
-
return res.end ? res.end() : res.send("");
|
|
6402
|
-
}
|
|
6506
|
+
// Build the renderCheckoutForm() opts for an active cart `c`: repriced
|
|
6507
|
+
// lines, totals, the thumbnail lookup, and the signed-in customer's
|
|
6508
|
+
// loyalty balance + prefill. Shared by the GET handler and the POST
|
|
6509
|
+
// catch (so a rejected gift-card / loyalty code re-renders the same
|
|
6510
|
+
// form inline instead of dead-ending). `inlineError` is the optional
|
|
6511
|
+
// message to surface at the top of the form.
|
|
6512
|
+
async function _checkoutRenderOpts(req, c, lines, inlineError) {
|
|
6403
6513
|
var totals = pricing.totals(c, lines, {});
|
|
6514
|
+
// variant_id → { product, hero_media } lookup for the summary
|
|
6515
|
+
// thumbnails + titles — same shape (and caching) the cart route uses.
|
|
6516
|
+
var checkoutLookup = {};
|
|
6517
|
+
for (var li = 0; li < lines.length; li += 1) {
|
|
6518
|
+
var lvId = lines[li].variant_id;
|
|
6519
|
+
if (checkoutLookup[lvId]) continue;
|
|
6520
|
+
var lv = await deps.catalog.variants.get(lvId);
|
|
6521
|
+
if (!lv) { checkoutLookup[lvId] = null; continue; }
|
|
6522
|
+
var lprod = await deps.catalog.products.get(lv.product_id);
|
|
6523
|
+
var lmedia = await deps.catalog.media.listForProduct(lv.product_id);
|
|
6524
|
+
checkoutLookup[lvId] = { product: lprod, hero_media: lmedia.length ? lmedia[0] : null };
|
|
6525
|
+
}
|
|
6404
6526
|
// A signed-in customer drives two best-effort lookups, both keyed
|
|
6405
6527
|
// off the same auth env: the loyalty balance (for the redeem field)
|
|
6406
6528
|
// and the default shipping address (to pre-fill the form). Either
|
|
@@ -6438,24 +6560,54 @@ function mount(router, deps) {
|
|
|
6438
6560
|
}
|
|
6439
6561
|
} catch (_e) { prefill = null; }
|
|
6440
6562
|
}
|
|
6441
|
-
|
|
6563
|
+
return {
|
|
6442
6564
|
lines: lines, totals: totals, shop_name: shopName, theme: theme,
|
|
6565
|
+
product_lookup: checkoutLookup,
|
|
6443
6566
|
paypal_client_id: deps.paypal ? deps.paypal_client_id : null,
|
|
6444
6567
|
loyalty_balance: loyaltyBalance,
|
|
6445
6568
|
loyalty_points_per_usd: deps.loyalty ? deps.loyalty.REDEMPTION_POINTS_PER_USD : null,
|
|
6446
6569
|
prefill: prefill,
|
|
6447
|
-
|
|
6570
|
+
inline_error: inlineError || null,
|
|
6571
|
+
};
|
|
6572
|
+
}
|
|
6573
|
+
|
|
6574
|
+
router.get("/checkout", async function (req, res) {
|
|
6575
|
+
var sid = _readSidCookie(req);
|
|
6576
|
+
if (!sid) return _send(res, 303, "<a href=\"/cart\">Cart is empty</a>"), res.setHeader && res.setHeader("location", "/cart");
|
|
6577
|
+
var c = await deps.cart.bySession(sid);
|
|
6578
|
+
if (!c) {
|
|
6579
|
+
res.status(303); res.setHeader && res.setHeader("location", "/cart");
|
|
6580
|
+
return res.end ? res.end() : res.send("");
|
|
6581
|
+
}
|
|
6582
|
+
var rawLines = await deps.cart.listLines(c.id);
|
|
6583
|
+
if (!rawLines.length) {
|
|
6584
|
+
res.status(303); res.setHeader && res.setHeader("location", "/cart");
|
|
6585
|
+
return res.end ? res.end() : res.send("");
|
|
6586
|
+
}
|
|
6587
|
+
// Reprice the lines through the active quantity-break (same as the
|
|
6588
|
+
// cart page) so the summary's per-line totals + subtotal match what
|
|
6589
|
+
// the shopper saw on /cart.
|
|
6590
|
+
var lines = await _repriceCartLines(rawLines);
|
|
6591
|
+
_send(res, 200, renderCheckoutForm(await _checkoutRenderOpts(req, c, lines, null)));
|
|
6448
6592
|
});
|
|
6449
6593
|
|
|
6450
6594
|
router.post("/checkout", async function (req, res) {
|
|
6451
6595
|
var body = req.body || {};
|
|
6452
6596
|
var sid = _readSidCookie(req);
|
|
6453
6597
|
if (!sid) {
|
|
6454
|
-
|
|
6598
|
+
return _send(res, 400, renderCheckoutError({
|
|
6599
|
+
shop_name: shopName, theme: theme, eyebrow: "Checkout",
|
|
6600
|
+
title_text: "Your session expired",
|
|
6601
|
+
reason: "We couldn't find an active cart session. Open your cart and continue from there.",
|
|
6602
|
+
}));
|
|
6455
6603
|
}
|
|
6456
6604
|
var c = await deps.cart.bySession(sid);
|
|
6457
6605
|
if (!c) {
|
|
6458
|
-
|
|
6606
|
+
return _send(res, 400, renderCheckoutError({
|
|
6607
|
+
shop_name: shopName, theme: theme, eyebrow: "Checkout",
|
|
6608
|
+
title_text: "Your cart couldn't be found",
|
|
6609
|
+
reason: "We couldn't find an active cart for this session. Open your cart and continue from there.",
|
|
6610
|
+
}));
|
|
6459
6611
|
}
|
|
6460
6612
|
// Defensive cart-state guard — if the cart has already been
|
|
6461
6613
|
// converted (e.g. duplicate-submit on POST refresh), redirect
|
|
@@ -6509,10 +6661,33 @@ function mount(router, deps) {
|
|
|
6509
6661
|
// GIFTCARD_* code and the loyalty errors a LOYALTY_* code so a
|
|
6510
6662
|
// fat-fingered value re-prompts rather than 500-ing checkout.
|
|
6511
6663
|
var code = (e && typeof e.code === "string") ? e.code : "";
|
|
6512
|
-
var clientErr = (e instanceof TypeError) || code.indexOf("GIFTCARD_") === 0 || code.indexOf("LOYALTY_") === 0;
|
|
6513
|
-
res.status(clientErr ? 400 : 500);
|
|
6514
6664
|
var msg = (e && e.message) || "checkout failed";
|
|
6515
|
-
|
|
6665
|
+
// A coded gift-card / loyalty error is something the shopper can
|
|
6666
|
+
// fix in place — re-render the checkout form with the message
|
|
6667
|
+
// inline (preserving the cart + their prefilled fields where
|
|
6668
|
+
// possible) rather than dead-ending on a separate page.
|
|
6669
|
+
if (code.indexOf("GIFTCARD_") === 0 || code.indexOf("LOYALTY_") === 0) {
|
|
6670
|
+
try {
|
|
6671
|
+
var coLines = await _repriceCartLines(await deps.cart.listLines(c.id));
|
|
6672
|
+
if (coLines.length) {
|
|
6673
|
+
return _send(res, 400, renderCheckoutForm(await _checkoutRenderOpts(req, c, coLines, msg)));
|
|
6674
|
+
}
|
|
6675
|
+
} catch (_re) { /* fall through to the styled error page */ }
|
|
6676
|
+
}
|
|
6677
|
+
// A malformed address shape (TypeError) is still the shopper's to
|
|
6678
|
+
// fix; anything else is a server-side failure. Either way, render a
|
|
6679
|
+
// styled, recoverable page rather than raw text — back to the cart,
|
|
6680
|
+
// or back to the shipping form to re-enter the address.
|
|
6681
|
+
var clientErr = (e instanceof TypeError);
|
|
6682
|
+
return _send(res, clientErr ? 400 : 500, renderCheckoutError({
|
|
6683
|
+
shop_name: shopName, theme: theme, eyebrow: "Checkout",
|
|
6684
|
+
title_text: clientErr ? "We couldn't process your shipping details" : "Checkout didn't go through",
|
|
6685
|
+
reason: clientErr
|
|
6686
|
+
? "Some shipping details couldn't be read: " + msg + " Go back and check the address fields."
|
|
6687
|
+
: "Something went wrong completing your order. Your cart is saved — please try again. (" + msg + ")",
|
|
6688
|
+
back_href: "/checkout", back_label: "Edit shipping",
|
|
6689
|
+
secondary_href: "/cart", secondary_label: "Back to cart",
|
|
6690
|
+
}));
|
|
6516
6691
|
}
|
|
6517
6692
|
});
|
|
6518
6693
|
|
|
@@ -8510,18 +8685,32 @@ function mount(router, deps) {
|
|
|
8510
8685
|
var qtyRaw = body.qty;
|
|
8511
8686
|
var qty = parseInt(qtyRaw, 10);
|
|
8512
8687
|
if (!variantId || !Number.isFinite(qty) || qty < 1 || qty > 99) {
|
|
8513
|
-
res
|
|
8514
|
-
|
|
8688
|
+
return _send(res, 400, renderCheckoutError({
|
|
8689
|
+
shop_name: shopName, theme: theme,
|
|
8690
|
+
title_text: "We couldn't add that item",
|
|
8691
|
+
reason: "The product or quantity in that request wasn't valid. Pick the item again from its page.",
|
|
8692
|
+
back_href: "/cart", back_label: "Back to cart",
|
|
8693
|
+
}));
|
|
8515
8694
|
}
|
|
8516
8695
|
var resolved = await _getOrCreateCart(req, res, "USD");
|
|
8517
8696
|
try {
|
|
8518
8697
|
await deps.cart.addLine(resolved.cart.id, { variant_id: variantId, qty: qty });
|
|
8519
8698
|
} catch (e) {
|
|
8520
|
-
|
|
8521
|
-
return res
|
|
8699
|
+
var addMsg = (e && e.message) || "Error";
|
|
8700
|
+
return _send(res, e instanceof TypeError ? 400 : 500, renderCheckoutError({
|
|
8701
|
+
shop_name: shopName, theme: theme,
|
|
8702
|
+
title_text: "We couldn't add that item",
|
|
8703
|
+
reason: (e instanceof TypeError)
|
|
8704
|
+
? "That item couldn't be added: " + addMsg
|
|
8705
|
+
: "Something went wrong adding that item to your cart. Please try again.",
|
|
8706
|
+
back_href: "/cart", back_label: "Back to cart",
|
|
8707
|
+
}));
|
|
8522
8708
|
}
|
|
8709
|
+
// `?added=1` so the cart page can confirm the item landed (the page
|
|
8710
|
+
// surfaces an "Added to cart" status banner). Still a 303 so a refresh
|
|
8711
|
+
// re-issues the GET, not the POST.
|
|
8523
8712
|
res.status(303);
|
|
8524
|
-
res.setHeader && res.setHeader("location", "/cart");
|
|
8713
|
+
res.setHeader && res.setHeader("location", "/cart?added=1");
|
|
8525
8714
|
res.end ? res.end() : res.send("");
|
|
8526
8715
|
});
|
|
8527
8716
|
|
|
@@ -8985,6 +9174,7 @@ module.exports = {
|
|
|
8985
9174
|
renderProduct: renderProduct,
|
|
8986
9175
|
renderCart: renderCart,
|
|
8987
9176
|
renderCheckoutForm: renderCheckoutForm,
|
|
9177
|
+
renderCheckoutError: renderCheckoutError,
|
|
8988
9178
|
renderGiftCardBalance: renderGiftCardBalance,
|
|
8989
9179
|
renderPayPage: renderPayPage,
|
|
8990
9180
|
renderOrder: renderOrder,
|
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.22",
|
|
7
|
+
"tag": "v0.13.22",
|
|
8
8
|
"license": "Apache-2.0",
|
|
9
9
|
"author": "blamejs contributors",
|
|
10
10
|
"source": "https://github.com/blamejs/blamejs",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"server": "lib/vendor/blamejs/"
|
|
14
14
|
},
|
|
15
15
|
"bundler": "shallow git clone of release tag from github.com/blamejs/blamejs",
|
|
16
|
-
"bundledAt": "2026-05-
|
|
16
|
+
"bundledAt": "2026-05-28"
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
19
|
}
|
|
@@ -8,6 +8,12 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.13.x
|
|
10
10
|
|
|
11
|
+
- v0.13.22 (2026-05-27) — **`b.archive.read.zip.fromTrustedStream` reads a ZIP from a Readable — no longer an experimental stub.** fromTrustedStream was an experimental stub whose inspect / entries / extract methods threw, forcing callers to buffer the stream themselves and use the random-access reader. It now works, with the same shape as the tar trusted-stream reader: pass b.archive.adapters.trustedStream(readable) and the bytes are collected into a size-capped buffer (1 GiB hard ceiling) and read through the same bomb-cap, path-traversal, and entry-type decode as the random-access reader — so bombPolicy, guardProfile, entryTypePolicy, and audit all apply, and inspect / entries / extract / extractEntries all return data. This is a bounded-memory reader (the archive is held in memory under the ceiling), not zero-buffer streaming; a future forward-inflate walker shared with the tar reader would lift the ceiling. **Added:** *`b.archive.read.zip.fromTrustedStream` now reads — `inspect` / `entries` / `extract` / `extractEntries`* — The ZIP trusted-stream reader is implemented (was an experimental stub that threw). Pass `b.archive.adapters.trustedStream(readable)` to read a ZIP straight from a Node Readable without buffering it yourself. The stream is collected into a size-capped buffer (1 GiB ceiling, matching `b.archive.read.tar`'s trusted-stream reader) and decoded through the same adversarial-safe path as the random-access reader, so `bombPolicy` / `guardProfile` / `entryTypePolicy` / `audit` are honored on decode. Adversarial archives remain fully bomb-capped; "trusted" refers only to the source-size bound. A non-trusted-stream adapter is refused with `archive-read/bad-adapter`.
|
|
12
|
+
|
|
13
|
+
- v0.13.21 (2026-05-27) — **`b.cose.exportKey` — serialize a public key as a COSE_Key, the inverse of `b.cose.importKey`.** b.cose could import a COSE_Key (RFC 9052 §7) into a node:crypto key for verification, but had no way to produce one — so a key used with b.cose.sign could not be shipped to a verifier in COSE form without hand-building the CBOR map. b.cose.exportKey(keyObject, opts?) closes the round-trip: it serializes an EC2 (P-256 / P-384 / P-521) or OKP (Ed25519) public key as the CBOR-encoded COSE_Key map, with optional alg and kid common parameters. A private key has its public half exported; unsupported curves / key types are refused rather than emitting a COSE_Key no verifier here would accept. The bytes round-trip through b.cose.importKey, and feed the mdoc MSO / COSE_Key header / SCITT / C2PA verification-key paths. **Added:** *`b.cose.exportKey(keyObject, { alg?, kid? })` — KeyObject → COSE_Key (RFC 9052 §7)* — Serialize a `node:crypto` public key as the CBOR-encoded COSE_Key map — the inverse of `b.cose.importKey`. Supports EC2 (P-256 / P-384 / P-521) and OKP (Ed25519), the same key types `b.cose.verify` accepts; `opts.alg` (e.g. `"ES256"`) and `opts.kid` populate the COSE_Key alg (label 3) and kid (label 2) common parameters. A private key exports its public half; unsupported curves / key types throw rather than producing a COSE_Key no verifier would accept. `b.cose.importKey(b.cbor.decode(exportKey(k)))` round-trips, so a key signed with `b.cose.sign` can be shipped to a verifier as bytes — the mdoc MSO / COSE_Key header / SCITT / C2PA verification-key paths.
|
|
14
|
+
|
|
15
|
+
- v0.13.20 (2026-05-27) — **`b.archive.wrap` can seal an archive for a tenant with no key-pair to manage — `recipient: "tenant"`.** b.archive.wrap previously sealed only to an explicit hybrid-PQC key-pair or a peer certificate; the documented recipient: "tenant" strategy threw. It now works: pass { recipient: "tenant", tenantId } and the archive is sealed under a deterministic per-tenant key derived from the vault root (SHAKE256 KDF) with XChaCha20-Poly1305, the tenant id mixed into the AEAD additional-authenticated-data so one tenant's envelope cannot be opened under another tenant's key. There is no recipient key-pair for the operator to generate, store, or rotate — b.archive.unwrap re-derives the key from the same tenantId. Rotating the vault re-keys every tenant (rotation intent is re-seal). The derivation is exposed directly as b.agent.tenant.derivedKey(tenantId, purpose) for operators who need the raw per-tenant key for their own AEAD. Requires an initialized vault. **Added:** *`b.archive.wrap` / `b.archive.unwrap` `recipient: "tenant"` — per-tenant archive sealing, no key-pair* — `b.archive.wrap(bytes, { recipient: "tenant", tenantId })` seals under a deterministic per-tenant key derived from the vault root with XChaCha20-Poly1305 (draft-irtf-cfrg-xchacha-03) and a SHAKE256 KDF (FIPS 202); the tenant id is bound into the AEAD AAD so a tenant-A envelope cannot decrypt under tenant-B's key even if an attacker swaps envelope headers. `b.archive.unwrap(sealed, { recipient: "tenant", tenantId })` (or just `{ tenantId }`) re-derives the key and recovers the bytes — no recipient key-pair to manage. The tenant envelope carries a distinct version byte so it is never fed to the hybrid-KEM decrypt path. The static-key and peer-cert recipient strategies are unchanged. · *`b.agent.tenant.derivedKey(tenantId, purpose)` — direct per-tenant key derivation* — The deterministic, domain-separated per-tenant key derivation (vault root + tenantId + purpose, SHAKE256, NUL-separated) is now exported at the module level, returning a 64-char hex key. Previously reachable only as a method on a created tenant manager; operators who need the raw key for their own AEAD can now call it directly. Throws if the vault is not initialized.
|
|
16
|
+
|
|
11
17
|
- 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
18
|
|
|
13
19
|
- 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.
|
|
@@ -141,7 +141,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
|
|
|
141
141
|
- **JSON / SQL / schema** — `b.safeJson` (with `maxKeys` cap defending CVE-2026-21717 V8 HashDoS), `b.safeBuffer`, `b.safeSql`, `b.safeSchema`
|
|
142
142
|
- **URL + path** — `b.safeUrl` (IDN mixed-script / homograph refuse); `b.safeJsonPath` (refuses filter `?(...)`, deep-scan `$..`, script-shape `(@.x)` for safe Postgres JSONB ops)
|
|
143
143
|
- **Binary codec** — `b.cbor` bounded deterministic CBOR (RFC 8949 §4.2): depth/size caps, indefinite-length + reserved-info + tag + duplicate-key refusal, `requireDeterministic` canonical-form check; the in-tree substrate under COSE / CWT / SCITT / WebAuthn attestation
|
|
144
|
-
- **COSE messages** — `b.cose` the full RFC 9052 message-type set over `b.cbor`: COSE_Sign1 sign/verify (attached or detached payload), COSE_Encrypt0 single-recipient AEAD, COSE_Mac0 shared-key HMAC (mac0/macVerify0), plus `importKey` (COSE_Key → KeyObject). Signatures use classical ES256/384/512 + EdDSA (final COSE ids, interoperable today) plus ML-DSA-87 (PQC-forward, draft id); bounded + alg-allowlisted + crit-bypass-checked verification; AEAD ChaCha20/Poly1305 default (AES-GCM opt-in); the signed-statement substrate under SCITT / CWT / mdoc / C2PA
|
|
144
|
+
- **COSE messages** — `b.cose` the full RFC 9052 message-type set over `b.cbor`: COSE_Sign1 sign/verify (attached or detached payload), COSE_Encrypt0 single-recipient AEAD, COSE_Mac0 shared-key HMAC (mac0/macVerify0), plus `importKey` (COSE_Key → KeyObject) and `exportKey` (KeyObject → COSE_Key, the inverse — ship a verification key as RFC 9052 §7 bytes). Signatures use classical ES256/384/512 + EdDSA (final COSE ids, interoperable today) plus ML-DSA-87 (PQC-forward, draft id); bounded + alg-allowlisted + crit-bypass-checked verification; AEAD ChaCha20/Poly1305 default (AES-GCM opt-in); the signed-statement substrate under SCITT / CWT / mdoc / C2PA
|
|
145
145
|
- **CBOR Web Token** — `b.cwt` CWT sign/verify (RFC 8392) over `b.cose`: standard-claim mapping (iss/sub/aud/exp/nbf/iat/cti) + `exp`/`nbf` clock-skew enforcement + `iss`/`aud` matching; the CBOR-native JWT for constrained / IoT / FIDO / verifiable-credential contexts
|
|
146
146
|
- **Entity Attestation Token** — `b.eat` EAT sign/verify (RFC 9711) over `b.cwt`: device + software attestation claims (ueid / oemid / hwmodel / measurements / submods) with verifier-nonce freshness binding, `dbgstat` debug-status policy, and `eat_profile` pinning
|
|
147
147
|
- **SCITT signed statements** — `b.scitt` sign/verify a signed, attributable claim about an artifact (signed SBOM, build attestation, release approval) over `b.cose`: the issuer + subject bind in the integrity-protected CWT_Claims header (RFC 9597); verification refuses any statement missing the iss/sub binding. The issuer side, on finalized RFCs; the transparency receipt (COSE Receipts draft) opts in on publication
|
|
@@ -228,7 +228,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
|
|
|
228
228
|
- **i18n** — CLDR plural rules, Accept-Language negotiation, Intl formatters, RTL (`b.i18n`)
|
|
229
229
|
- **CSV** — RFC 4180 with Excel formula-injection prevention (`b.csv`)
|
|
230
230
|
- **IDs + slugs** — RFC 9562 UUID v4 + v7 (`b.uuid`); URL-safe slugs (`b.slug`)
|
|
231
|
-
- **Time + archive** — TZ-aware datetime (`b.time`); ZIP creation + adversarial-safe read with bomb caps + path-traversal + LFH/CD-skew defense (`b.archive` + `b.archive.read.zip`); one-liner quarantine extraction (`b.safeArchive.extract`); in-memory extraction with no disk write for read-only / serverless filesystems (`b.archive.read.zip(...).extractEntries()` / `.tar`); fs / objectStore / http / buffer / trusted-stream adapter contract (`b.archive.adapters`)
|
|
231
|
+
- **Time + archive** — TZ-aware datetime (`b.time`); ZIP creation + adversarial-safe read with bomb caps + path-traversal + LFH/CD-skew defense (`b.archive` + `b.archive.read.zip`); one-liner quarantine extraction (`b.safeArchive.extract`); in-memory extraction with no disk write for read-only / serverless filesystems (`b.archive.read.zip(...).extractEntries()` / `.tar`); fs / objectStore / http / buffer / trusted-stream adapter contract (`b.archive.adapters`); recipient-sealed envelopes — hybrid-PQC key-pair, peer certificate, or per-tenant key with no key-pair to manage (`b.archive.wrap({ recipient: "tenant", tenantId })`)
|
|
232
232
|
- **Pagination + forms** — HMAC-signed cursor pagination (`b.pagination`); HTML form rendering + validation + CSRF (`b.forms`)
|
|
233
233
|
|
|
234
234
|
### Production
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 1,
|
|
3
|
-
"frameworkVersion": "0.13.
|
|
4
|
-
"createdAt": "2026-05-
|
|
3
|
+
"frameworkVersion": "0.13.22",
|
|
4
|
+
"createdAt": "2026-05-27T23:32:55.833Z",
|
|
5
5
|
"exports": {
|
|
6
6
|
"a2a": {
|
|
7
7
|
"type": "object",
|
|
@@ -992,6 +992,10 @@
|
|
|
992
992
|
"type": "function",
|
|
993
993
|
"arity": 1
|
|
994
994
|
},
|
|
995
|
+
"derivedKey": {
|
|
996
|
+
"type": "function",
|
|
997
|
+
"arity": 2
|
|
998
|
+
},
|
|
995
999
|
"guards": {
|
|
996
1000
|
"type": "object",
|
|
997
1001
|
"members": {
|
|
@@ -13663,6 +13667,10 @@
|
|
|
13663
13667
|
"type": "function",
|
|
13664
13668
|
"arity": 2
|
|
13665
13669
|
},
|
|
13670
|
+
"exportKey": {
|
|
13671
|
+
"type": "function",
|
|
13672
|
+
"arity": 2
|
|
13673
|
+
},
|
|
13666
13674
|
"importKey": {
|
|
13667
13675
|
"type": "function",
|
|
13668
13676
|
"arity": 1
|