@blamejs/blamejs-shop 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.2.x
10
10
 
11
+ - v0.2.2 (2026-05-27) — **Bespoke line-art illustrations across the storefront — category tiles, image placeholders, and every empty state.** The storefront's stand-in graphics are now a single, cohesive set of hand-drawn line illustrations in the brand violet, replacing the previous mix of gradient-only tiles, single-letter image marks, and emoji. The six featured-collection tiles carry a matching icon (apparel, hardware, digital, subscriptions, bundles, gift cards). Products without an image — in the grid and on the product page — show a bespoke "no image yet" placeholder instead of a letter glyph. Every empty state has its own illustration: cart, search, wishlist, saved-for-later, addresses, returns, and orders. The artwork is inline SVG (no extra files fetched, consistent with the strict asset policy) and adapts to the surface — the fine detail strokes follow the text colour, so the same set reads correctly on a dark or a light background. **Changed:** *Category tiles carry line-art icons* — Each of the six featured-collection tiles now shows a violet line icon for its category, centered over the existing tile gradient. The tile links and labels are unchanged. · *Bespoke image placeholder replaces the letter mark* — When a product has no image, the grid card and the product-page gallery render a line-art "no image yet" placeholder — an isometric package on a faint grid (the product page adds a shield motif and a terminal-prompt accent) — instead of the previous single uppercase letter. The no-image grid card now uses the same media-card layout as image-bearing cards for a consistent grid. · *Every empty state has matching artwork* — The empty cart, no-search-results, empty wishlist, saved-for-later, addresses, returns, and orders states now show a bespoke line illustration in place of the previous emoji or unicode glyphs, so the storefront and account sections share one visual language. · *Inline, theme-adaptive SVG* — All of the new artwork is inline SVG with presentation attributes only — nothing is fetched from a CDN and no inline styles are used, consistent with the strict content-security and asset policies. The detail strokes use currentColor, so the set adapts to the background it sits on. The edge and container renderers emit identical markup.
12
+
13
+ - v0.2.1 (2026-05-27) — **Theme tuning — stronger contrast, a cleaner product buy-box, and natural code-sample colors.** A polish pass over the new dark theme. The palette is rebalanced so the brand violet reads as a deliberate accent rather than the whole field: surfaces, borders, and shadows are now neutral dark, body text and prices are high-contrast near-white, and violet is concentrated on calls-to-action, the hero accent, and the newsletter band. The product page's buy-box is now a single contained card — price, variant, quantity, and add-to-cart group cleanly with proper spacing and alignment instead of blending into the column, and the wishlist/compare controls sit in their own separated row. The homepage code sample uses natural syntax-highlight colours (green strings, blue functions, violet keywords) with the usual window dots. The home collection tiles are calmer dark tiles with a subtle violet glow rather than solid magenta swatches. **Changed:** *Rebalanced palette for contrast* — Surfaces (`--surface`, card and raised tiers) and borders are now neutral dark instead of violet-tinted; secondary text and prices are high-contrast near-white; default shadows are neutral. The violet accent is reserved for primary buttons, the hero accent word, focus states, and the newsletter/framework bands — so contrast and hierarchy are clearer and the page no longer reads as a single wash of magenta. · *Product buy-box is a contained, aligned card* — Price, variant chip(s), quantity, and the full-width add-to-cart now sit in one bordered buy-box card with consistent spacing, the price rendered in high-contrast white, and the post-quantum-secured-checkout trust line as a calm note beneath it. The wishlist and compare controls are grouped into their own row, separated from the buy-box. No change to add-to-cart, variant, or currency behaviour. · *Natural code-sample colours and calmer collection tiles* — The homepage server-rendered code sample now uses a natural console palette — green strings, blue function names, violet keywords, grey comments — with red/yellow/green window dots. The home collection tiles are dark with a subtle, position-varied violet glow instead of solid violet gradient fills.
14
+
11
15
  - v0.2.0 (2026-05-27) — **A new storefront visual identity — a dark, violet brand theme with self-hosted typefaces and a redesigned product buy-box.** The default storefront theme has been redesigned around the project's actual brand: a dark, near-black canvas with a violet-to-magenta accent system that matches the blamejs shield-and-terminal mark, replacing the previous light theme and its unrelated orange accent. The stylesheet is restructured onto a semantic design-token layer (a purpose tier — surface, text, border, accent, price — sitting over the raw palette), so colour, contrast, and surfaces are now consistent and themeable rather than hard-coded per component. Two distinctive open-licensed typefaces are self-hosted in place of Inter: Hanken Grotesk for display and body, Space Mono for the terminal-style chrome (navigation, labels, SKUs, prices). The product page's variant selector is rebuilt from a data table into a proper buy-box: a prominent price, the variant(s) as selectable chips, a full-width add-to-cart, and a post-quantum-secured-checkout trust line — the add-to-cart, variant, and currency-conversion behaviour is unchanged. Accessibility is preserved (contrast targets met on the dark surfaces, focus-visible, reduced-motion guards, the consent banner restyled as legible dark glass). Operators on the default theme get the new look on upgrade; a custom theme passed via the theme primitive is unaffected. **Changed:** *Dark violet brand theme replaces the light theme* — The default theme is now a dark, near-black storefront with a violet→magenta accent family (matching the logo's shield/terminal mark) carried across the chrome, hero, cards, forms, badges, switchers, and footer. The previous light canvas and its off-brand orange accent are gone. Depth comes from hard-edged and soft violet shadows and a faint circuit-grid texture; motion (a staggered page-load reveal, a glitch treatment on the hero accent word, a terminal cursor) is CSS-only and disabled under `prefers-reduced-motion`. · *Semantic design-token layer + self-hosted typefaces* — The stylesheet now has a primitive→semantic token structure (`--surface`/`--surface-raised`, `--text`/`--text-muted`, `--border`, `--color-accent`, `--color-price`, glow tokens) so components reference purpose, not raw values — fixing the previously undefined `--font-sans`/`--danger-l` tokens and a duplicated button definition along the way. Hanken Grotesk (display/body) and Space Mono (mono chrome) are self-hosted as OFL woff2, replacing Inter; nothing is fetched from a font CDN, consistent with the strict `font-src 'self'` policy. · *Product buy-box redesign* — The product page's variant table is replaced by a buy-box: a large monospace price, each variant as a selectable pill chip (with a clear selected state, fully operable with JavaScript off), a full-width add-to-cart, and a trust line stating the checkout is post-quantum secured. The underlying add-to-cart form, multi-variant selection, and multi-currency price formatting are unchanged; products with many variants keep a compact fallback. The PDP trust badge wording "Ships from origin" is replaced with a real delivery estimate.
12
16
 
13
17
  ## v0.1.x
@@ -1,13 +1,13 @@
1
1
  {
2
- "version": "0.2.0",
2
+ "version": "0.2.2",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-oUi6NkXA0ULSy8+8+LG0FV6jsgr7Y11Xf3VdnwUESnvUPaN7anYEC+QOAUXwgsap",
6
6
  "fingerprinted": "css/admin.7b692a8965624a0c.css"
7
7
  },
8
8
  "css/main.css": {
9
- "integrity": "sha384-4RH2PIC1FVxNFfphco1poLi1gMXa8MkvmOK8tXLSSwUKfhbGi0VYXlWtWBXu4Msd",
10
- "fingerprinted": "css/main.f61829cdefb48dda.css"
9
+ "integrity": "sha384-iKbkn3AR4k733EgN+1Ju51qbKfz9yVmDk8lE2l99asnRGbSo4BJu4Lo+OTxcBME4",
10
+ "fingerprinted": "css/main.5678b2596038b019.css"
11
11
  },
12
12
  "js/consent.js": {
13
13
  "integrity": "sha384-KKMQ0og8HPOykRRPpUyxX7dMhTvKySfVtpGX/jGWzZwNaN/c4OykvRvXpqBHcQST",
package/lib/storefront.js CHANGED
@@ -512,11 +512,15 @@ var PRODUCT_CARD_IMAGE =
512
512
  "</a>\n";
513
513
 
514
514
  var PRODUCT_CARD =
515
- "<div class=\"card\">\n" +
516
- " <h2>{{title}}</h2>\n" +
517
- " <p class=\"price\">{{price}}</p>\n" +
518
- " <a href=\"/products/{{slug}}\" class=\"card-link\">View product →</a>\n" +
519
- "</div>\n";
515
+ "<a class=\"product-card\" href=\"/products/{{slug}}\">\n" +
516
+ " <figure class=\"product-card__media product-card__media--placeholder\">\n" +
517
+ " <svg class=\"media-ph__svg\" viewBox=\"0 0 160 120\" aria-hidden=\"true\"><rect width=\"160\" height=\"120\" fill=\"none\"/><g stroke=\"currentColor\" stroke-opacity=\"0.10\" stroke-width=\"1\"><path d=\"M0 30 H160 M0 60 H160 M0 90 H160 M40 0 V120 M80 0 V120 M120 0 V120\"/></g><g fill=\"none\" stroke=\"#AD38DB\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M80 38 L104 50 L104 76 L80 88 L56 76 L56 50 Z\"/><path d=\"M56 50 L80 62 L104 50 M80 62 V88\" stroke=\"#732A8D\"/><path d=\"M70 47 L74 50 L70 53\" stroke=\"currentColor\" stroke-width=\"2\"/><path d=\"M77 54 H86\" stroke=\"currentColor\" stroke-width=\"2\"/></g><text x=\"80\" y=\"106\" text-anchor=\"middle\" font-family=\"ui-monospace,Menlo,Consolas,monospace\" font-size=\"9\" letter-spacing=\"1.5\" fill=\"#6b6b78\">no image yet</text></svg>\n" +
518
+ " </figure>\n" +
519
+ " <div class=\"product-card__meta\">\n" +
520
+ " <h3 class=\"product-card__title\">{{title}}</h3>\n" +
521
+ " <p class=\"product-card__price\">{{price}}</p>\n" +
522
+ " </div>\n" +
523
+ "</a>\n";
520
524
 
521
525
  // Render-time picker. Image-bearing cards become the dominant
522
526
  // surface as soon as a product carries media; text-only cards
@@ -625,42 +629,42 @@ var HOME_HERO =
625
629
  " </header>\n" +
626
630
  " <div class=\"collections__grid\">\n" +
627
631
  " <a class=\"collection-card\" href=\"/search?q=tee\">\n" +
628
- " <div class=\"collection-card__art collection-card__art--1\" aria-hidden=\"true\"></div>\n" +
632
+ " <div class=\"collection-card__art collection-card__art--1\" aria-hidden=\"true\"><svg class=\"collection-card__icon\" viewBox=\"0 0 160 120\" fill=\"none\" stroke=\"#AD38DB\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M70 38 C74 44 86 44 90 38 L104 44 L112 58 L100 64 L96 58 L96 92 L64 92 L64 58 L60 64 L48 58 L56 44 Z\"/><path d=\"M71 40 C75 47 85 47 89 40\" stroke=\"#732A8D\" stroke-width=\"2\"/><path d=\"M73 76 H87\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-dasharray=\"2 3\"/></svg></div>\n" +
629
633
  " <div class=\"collection-card__meta\">\n" +
630
634
  " <h3>Apparel</h3>\n" +
631
635
  " <p>Sized, colored, inventoried.</p>\n" +
632
636
  " </div>\n" +
633
637
  " </a>\n" +
634
638
  " <a class=\"collection-card\" href=\"/search?q=edge\">\n" +
635
- " <div class=\"collection-card__art collection-card__art--2\" aria-hidden=\"true\"></div>\n" +
639
+ " <div class=\"collection-card__art collection-card__art--2\" aria-hidden=\"true\"><svg class=\"collection-card__icon\" viewBox=\"0 0 160 120\" fill=\"none\" stroke=\"#AD38DB\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><rect x=\"58\" y=\"38\" width=\"44\" height=\"44\" rx=\"4\"/><rect x=\"70\" y=\"50\" width=\"20\" height=\"20\" rx=\"2\" stroke=\"#732A8D\"/><circle cx=\"80\" cy=\"60\" r=\"3\" fill=\"#AD38DB\" stroke=\"none\"/><path d=\"M66 38 V30 M76 38 V30 M86 38 V30 M96 38 V30\" stroke=\"currentColor\" stroke-width=\"2\"/><path d=\"M66 82 V90 M76 82 V90 M86 82 V90 M96 82 V90\" stroke=\"currentColor\" stroke-width=\"2\"/><path d=\"M58 48 H50 M58 60 H50 M58 72 H50\" stroke=\"currentColor\" stroke-width=\"2\"/><path d=\"M102 48 H110 M102 60 H110 M102 72 H110\" stroke=\"currentColor\" stroke-width=\"2\"/></svg></div>\n" +
636
640
  " <div class=\"collection-card__meta\">\n" +
637
641
  " <h3>Hardware</h3>\n" +
638
642
  " <p>Serialized, warranty-tracked.</p>\n" +
639
643
  " </div>\n" +
640
644
  " </a>\n" +
641
645
  " <a class=\"collection-card\" href=\"/search?q=license\">\n" +
642
- " <div class=\"collection-card__art collection-card__art--3\" aria-hidden=\"true\"></div>\n" +
646
+ " <div class=\"collection-card__art collection-card__art--3\" aria-hidden=\"true\"><svg class=\"collection-card__icon\" viewBox=\"0 0 160 120\" fill=\"none\" stroke=\"#AD38DB\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M60 74 C50 74 48 62 57 59 C56 47 73 43 79 53 C88 47 100 54 97 64 C107 65 107 74 99 74 Z\" stroke=\"#732A8D\"/><path d=\"M78 60 V86\"/><path d=\"M70 78 L78 88 L86 78\"/><path d=\"M64 98 H92\" stroke=\"currentColor\" stroke-width=\"2\"/></svg></div>\n" +
643
647
  " <div class=\"collection-card__meta\">\n" +
644
648
  " <h3>Digital</h3>\n" +
645
649
  " <p>License-key fulfillment.</p>\n" +
646
650
  " </div>\n" +
647
651
  " </a>\n" +
648
652
  " <a class=\"collection-card\" href=\"/search?q=subscription\">\n" +
649
- " <div class=\"collection-card__art collection-card__art--4\" aria-hidden=\"true\"></div>\n" +
653
+ " <div class=\"collection-card__art collection-card__art--4\" aria-hidden=\"true\"><svg class=\"collection-card__icon\" viewBox=\"0 0 160 120\" fill=\"none\" stroke=\"#AD38DB\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M56 50 A26 26 0 0 1 104 56\"/><path d=\"M104 70 A26 26 0 0 1 56 64\"/><path d=\"M104 42 L106 57 L91 55\" stroke=\"#AD38DB\"/><path d=\"M56 78 L54 63 L69 65\" stroke=\"#AD38DB\"/><circle cx=\"80\" cy=\"60\" r=\"6\" stroke=\"#732A8D\"/><circle cx=\"80\" cy=\"60\" r=\"1.6\" fill=\"currentColor\" stroke=\"none\"/></svg></div>\n" +
650
654
  " <div class=\"collection-card__meta\">\n" +
651
655
  " <h3>Subscriptions</h3>\n" +
652
656
  " <p>Stripe-backed recurring.</p>\n" +
653
657
  " </div>\n" +
654
658
  " </a>\n" +
655
659
  " <a class=\"collection-card\" href=\"/search?q=bundle\">\n" +
656
- " <div class=\"collection-card__art collection-card__art--5\" aria-hidden=\"true\"></div>\n" +
660
+ " <div class=\"collection-card__art collection-card__art--5\" aria-hidden=\"true\"><svg class=\"collection-card__icon\" viewBox=\"0 0 160 120\" fill=\"none\" stroke=\"#AD38DB\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M86 36 L106 44 L106 64 L86 72 L66 64 L66 44 Z\" stroke=\"#732A8D\"/><path d=\"M66 44 L86 52 L106 44 M86 52 V72\" stroke=\"#732A8D\"/><path d=\"M70 56 L90 64 L90 88 L70 96 L50 88 L50 64 Z\"/><path d=\"M50 64 L70 72 L90 64 M70 72 V96\"/><path d=\"M70 72 V96\" stroke=\"currentColor\" stroke-width=\"2\"/></svg></div>\n" +
657
661
  " <div class=\"collection-card__meta\">\n" +
658
662
  " <h3>Bundles</h3>\n" +
659
663
  " <p>Composite SKUs, atomic stock.</p>\n" +
660
664
  " </div>\n" +
661
665
  " </a>\n" +
662
666
  " <a class=\"collection-card\" href=\"/search?q=gift\">\n" +
663
- " <div class=\"collection-card__art collection-card__art--6\" aria-hidden=\"true\"></div>\n" +
667
+ " <div class=\"collection-card__art collection-card__art--6\" aria-hidden=\"true\"><svg class=\"collection-card__icon\" viewBox=\"0 0 160 120\" fill=\"none\" stroke=\"#AD38DB\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><rect x=\"48\" y=\"48\" width=\"64\" height=\"42\" rx=\"6\"/><path d=\"M80 48 V90\" stroke=\"#732A8D\"/><path d=\"M80 48 C71 36 56 39 62 49 C57 52 61 57 71 53 C76 51 80 50 80 48 Z\"/><path d=\"M80 48 C89 36 104 39 98 49 C103 52 99 57 89 53 C84 51 80 50 80 48 Z\"/><circle cx=\"80\" cy=\"48\" r=\"2.4\" fill=\"#AD38DB\" stroke=\"none\"/><rect x=\"56\" y=\"70\" width=\"11\" height=\"8\" rx=\"1.6\" stroke=\"currentColor\" stroke-width=\"1.8\"/></svg></div>\n" +
664
668
  " <div class=\"collection-card__meta\">\n" +
665
669
  " <h3>Gift cards</h3>\n" +
666
670
  " <p>PQC-signed redemption codes.</p>\n" +
@@ -847,7 +851,7 @@ var SEARCH_HEADER =
847
851
  var SEARCH_EMPTY =
848
852
  "<section class=\"search-empty\">\n" +
849
853
  " <div class=\"search-empty__inner\">\n" +
850
- " <p class=\"search-empty__icon\" aria-hidden=\"true\">⌕</p>\n" +
854
+ " <p class=\"search-empty__icon\" aria-hidden=\"true\"><svg class=\"empty-illu\" viewBox=\"0 0 200 132\" fill=\"none\" stroke=\"#AD38DB\" stroke-width=\"2.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><circle cx=\"90\" cy=\"58\" r=\"30\"/><path d=\"M112 80 L132 100\"/><path d=\"M80 52 L86 58 L80 64\" stroke=\"currentColor\" stroke-width=\"2.4\"/><path d=\"M92 64 H102\" stroke=\"currentColor\" stroke-width=\"2.4\"/><circle cx=\"46\" cy=\"34\" r=\"2\" fill=\"#732A8D\" stroke=\"none\"/><circle cx=\"146\" cy=\"44\" r=\"2\" fill=\"#732A8D\" stroke=\"none\"/></svg></p>\n" +
851
855
  " <h2>{{heading}}</h2>\n" +
852
856
  " <p>{{copy}}</p>\n" +
853
857
  " {{clear_link}}\n" +
@@ -1229,9 +1233,8 @@ function _buildPdpGallery(product, media, assetPrefix) {
1229
1233
  var prefix = assetPrefix || "/assets/";
1230
1234
  function _escAttr(s) { return b.template.escapeHtml(s); }
1231
1235
  if (!media || media.length === 0) {
1232
- var initial = (product.title || "?").trim().charAt(0).toUpperCase() || "?";
1233
- return "<figure class=\"pdp__media\" aria-hidden=\"true\">" +
1234
- "<span class=\"pdp__media-mark\">" + _escAttr(initial) + "</span>" +
1236
+ return "<figure class=\"pdp__media pdp__media--placeholder\" aria-hidden=\"true\">" +
1237
+ "<svg class=\"media-ph__svg\" viewBox=\"0 0 240 240\" aria-hidden=\"true\"><rect width=\"240\" height=\"240\" fill=\"none\"/><g stroke=\"currentColor\" stroke-opacity=\"0.10\" stroke-width=\"1\"><path d=\"M0 40 H240 M0 80 H240 M0 120 H240 M0 160 H240 M0 200 H240 M40 0 V240 M80 0 V240 M120 0 V240 M160 0 V240 M200 0 V240\"/></g><g fill=\"none\" stroke=\"#AD38DB\" stroke-width=\"3\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M120 64 L162 80 L162 122 C162 152 144 168 120 178 C96 168 78 152 78 122 L78 80 Z\" stroke=\"#732A8D\" stroke-width=\"2.4\"/><path d=\"M120 92 L146 105 L146 134 L120 147 L94 134 L94 105 Z\"/><path d=\"M94 105 L120 118 L146 105 M120 118 V147\" stroke=\"#732A8D\" stroke-width=\"2.4\"/><path d=\"M107 101 L112 105 L107 109\" stroke=\"currentColor\" stroke-width=\"2.4\"/><path d=\"M116 110 H128\" stroke=\"currentColor\" stroke-width=\"2.4\"/></g><text x=\"120\" y=\"208\" text-anchor=\"middle\" font-family=\"ui-monospace,Menlo,Consolas,monospace\" font-size=\"12\" letter-spacing=\"2\" fill=\"#6b6b78\">no image yet</text></svg>" +
1235
1238
  "</figure>" +
1236
1239
  "<ul class=\"pdp__thumbs\" aria-hidden=\"true\">" +
1237
1240
  "<li class=\"is-active\"></li><li></li><li></li><li></li>" +
@@ -1715,7 +1718,7 @@ function renderWishlist(opts) {
1715
1718
  var inner = rowsHtml
1716
1719
  ? "<ul class=\"wishlist-list\">" + rowsHtml + "</ul>"
1717
1720
  : "<div class=\"account-empty\">" +
1718
- "<p class=\"account-empty__icon\" aria-hidden=\"true\">♡</p>" +
1721
+ "<p class=\"account-empty__icon\" aria-hidden=\"true\"><svg class=\"empty-illu\" viewBox=\"0 0 200 132\" fill=\"none\" stroke=\"#AD38DB\" stroke-width=\"2.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M100 104 C70 82 60 64 60 50 C60 38 70 32 80 34 C88 35 96 42 100 50 C104 42 112 35 120 34 C130 32 140 38 140 50 C140 64 130 82 100 104 Z\"/><path d=\"M100 90 C82 76 76 64 76 54\" stroke=\"currentColor\" stroke-opacity=\"0.45\" stroke-width=\"1.8\" stroke-dasharray=\"2 4\"/><path d=\"M132 86 V98 M126 92 H138\" stroke=\"#732A8D\" stroke-width=\"2\"/></svg></p>" +
1719
1722
  "<p class=\"account-empty__lede\">You haven't saved anything yet. Browse the shop and tap <strong>Save to wishlist</strong> on products you want to keep an eye on.</p>" +
1720
1723
  "<a class=\"btn-secondary\" href=\"/\">Browse the shop →</a>" +
1721
1724
  "</div>";
@@ -1964,7 +1967,7 @@ function renderSaved(opts) {
1964
1967
  var inner = rowsHtml
1965
1968
  ? "<ul class=\"saved-list\">" + rowsHtml + "</ul>"
1966
1969
  : "<div class=\"account-empty\">" +
1967
- "<p class=\"account-empty__icon\" aria-hidden=\"true\">🔖</p>" +
1970
+ "<p class=\"account-empty__icon\" aria-hidden=\"true\"><svg class=\"empty-illu\" viewBox=\"0 0 200 132\" fill=\"none\" stroke=\"#AD38DB\" stroke-width=\"2.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M78 32 H122 V104 L100 88 L78 104 Z\"/><path d=\"M90 58 H110\" stroke=\"currentColor\" stroke-opacity=\"0.45\" stroke-width=\"1.8\" stroke-dasharray=\"2 4\"/><circle cx=\"138\" cy=\"46\" r=\"12\" stroke=\"#732A8D\" stroke-width=\"2\"/><path d=\"M138 40 V46 L143 49\" stroke=\"#732A8D\" stroke-width=\"2\"/></svg></p>" +
1968
1971
  "<p class=\"account-empty__lede\">Nothing saved for later. Use <strong>Save for later</strong> on a cart item to move it here without losing it.</p>" +
1969
1972
  "<a class=\"btn-secondary\" href=\"/cart\">View your cart →</a>" +
1970
1973
  "</div>";
@@ -2070,7 +2073,7 @@ function renderAddresses(opts) {
2070
2073
  var listHtml = rowsHtml
2071
2074
  ? "<ul class=\"address-list\">" + rowsHtml + "</ul>"
2072
2075
  : "<div class=\"account-empty\">" +
2073
- "<p class=\"account-empty__icon\" aria-hidden=\"true\">🏠</p>" +
2076
+ "<p class=\"account-empty__icon\" aria-hidden=\"true\"><svg class=\"empty-illu\" viewBox=\"0 0 200 132\" fill=\"none\" stroke=\"#AD38DB\" stroke-width=\"2.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M100 110 C84 90 74 76 74 60 A26 26 0 0 1 126 60 C126 76 116 90 100 110 Z\"/><path d=\"M88 64 L100 52 L112 64 M91 61 V74 H109 V61\" stroke=\"#732A8D\" stroke-width=\"2.2\"/><path d=\"M97 74 V67 H103 V74\" stroke=\"currentColor\" stroke-width=\"2\"/></svg></p>" +
2074
2077
  "<p class=\"account-empty__lede\">No saved addresses yet. Add one below to speed up checkout.</p>" +
2075
2078
  "</div>";
2076
2079
  var notice = opts.notice
@@ -2464,7 +2467,7 @@ function renderReturns(opts) {
2464
2467
  var inner = rowsHtml
2465
2468
  ? "<ul class=\"return-list\">" + rowsHtml + "</ul>"
2466
2469
  : "<div class=\"account-empty\">" +
2467
- "<p class=\"account-empty__icon\" aria-hidden=\"true\">↩</p>" +
2470
+ "<p class=\"account-empty__icon\" aria-hidden=\"true\"><svg class=\"empty-illu\" viewBox=\"0 0 200 132\" fill=\"none\" stroke=\"#AD38DB\" stroke-width=\"2.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M66 64 L100 50 L134 64 L100 78 Z\"/><path d=\"M66 64 V92 L100 106 L134 92 V64 M100 78 V106\"/><path d=\"M100 36 A20 20 0 0 1 120 56\" stroke=\"#732A8D\" stroke-width=\"2.4\"/><path d=\"M100 28 L100 40 L110 38\" stroke=\"#732A8D\" stroke-width=\"2.4\"/><path d=\"M88 70 H112\" stroke=\"currentColor\" stroke-opacity=\"0.45\" stroke-width=\"1.8\" stroke-dasharray=\"2 4\"/></svg></p>" +
2468
2471
  "<p class=\"account-empty__lede\">No returns yet. Start one from an order in your account.</p>" +
2469
2472
  "<a class=\"btn-secondary\" href=\"/account/orders\">View your orders →</a>" +
2470
2473
  "</div>";
@@ -2891,7 +2894,7 @@ function renderAccountSubscriptions(opts) {
2891
2894
  var inner = rowsHtml
2892
2895
  ? note + "<ul class=\"subscription-list\">" + rowsHtml + "</ul>"
2893
2896
  : "<div class=\"account-empty\">" +
2894
- "<p class=\"account-empty__icon\" aria-hidden=\"true\">↻</p>" +
2897
+ "<p class=\"account-empty__icon\" aria-hidden=\"true\"><svg class=\"empty-illu\" viewBox=\"0 0 200 132\" fill=\"none\" stroke=\"#AD38DB\" stroke-width=\"2.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M74 30 H126 V104 L118 98 L110 104 L100 98 L90 104 L82 98 L74 104 Z\"/><path d=\"M86 48 H114 M86 62 H114\" stroke=\"#732A8D\" stroke-width=\"2.2\"/><path d=\"M86 76 H106\" stroke=\"currentColor\" stroke-opacity=\"0.45\" stroke-width=\"1.8\" stroke-dasharray=\"2 4\"/></svg></p>" +
2895
2898
  "<p class=\"account-empty__lede\">You have no active subscriptions.</p>" +
2896
2899
  "<a class=\"btn-secondary\" href=\"/\">Browse the shop →</a>" +
2897
2900
  "</div>";
@@ -3664,7 +3667,7 @@ var CART_EMPTY_PAGE =
3664
3667
  " </nav>\n" +
3665
3668
  " <div class=\"cart-empty\">\n" +
3666
3669
  " <div class=\"cart-empty__card\">\n" +
3667
- " <p class=\"cart-empty__icon\" aria-hidden=\"true\">🛒</p>\n" +
3670
+ " <p class=\"cart-empty__icon\" aria-hidden=\"true\"><svg class=\"empty-illu\" viewBox=\"0 0 200 132\" fill=\"none\" stroke=\"#AD38DB\" stroke-width=\"2.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M52 50 H66 L74 90 H128 L138 58 H72\"/><path d=\"M46 44 H56\" stroke=\"#732A8D\"/><circle cx=\"82\" cy=\"106\" r=\"7\"/><circle cx=\"120\" cy=\"106\" r=\"7\"/><path d=\"M84 72 H122\" stroke=\"currentColor\" stroke-opacity=\"0.45\" stroke-width=\"1.8\" stroke-dasharray=\"2 4\"/><path d=\"M150 38 L150 50 M144 44 L156 44\" stroke=\"#732A8D\" stroke-width=\"2\"/></svg></p>\n" +
3668
3671
  " <p class=\"eyebrow cart-empty__eyebrow\">Cart</p>\n" +
3669
3672
  " <h1 class=\"cart-empty__title\">Your cart is empty</h1>\n" +
3670
3673
  " <p class=\"cart-empty__lede\">Browse the catalog and the products you add show up here. Items hold their price at add-time, not at checkout.</p>\n" +
@@ -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.12",
7
- "tag": "v0.13.12",
6
+ "version": "0.13.13",
7
+ "tag": "v0.13.13",
8
8
  "license": "Apache-2.0",
9
9
  "author": "blamejs contributors",
10
10
  "source": "https://github.com/blamejs/blamejs",
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.13.x
10
10
 
11
+ - v0.13.13 (2026-05-27) — **Archive extraction-path verification now refuses Windows reserved names, NTFS data streams, and trailing-dot/space per segment.** b.guardFilename.verifyExtractionPath (the per-entry gate b.archive.read.zip.extract / b.safeArchive run on every extracted file) checked traversal, absolute paths, drive-letter and UNC prefixes, null bytes, PATH_MAX overflow, and realpath containment — but not the per-segment Windows write-target hazards the disk validate / sanitize paths already reject. An archive entry named CON, NUL.txt, subdir/LPT1, file.txt:hidden, or secret.txt. stayed inside the extraction root, so the containment and realpath checks passed it, yet on Windows it would resolve to a device, write a hidden NTFS stream, or (after Windows strips the trailing dot/space) overwrite a sibling file. These are now refused: any path segment that collides with a Windows reserved device name, uses NTFS alternate-data-stream syntax (name:stream), or carries a trailing dot or leading/trailing whitespace. The checks are platform-unconditional — a verifier running on Linux still refuses names that are only dangerous on the Windows host that ultimately extracts the archive — with a per-check opt-out (reservedNamePolicy / adsPolicy / leadingTrailingPolicy: "allow") for Linux-only targets. **Security:** *`verifyExtractionPath` refuses per-segment Windows extraction hazards (reserved names / NTFS ADS / trailing dot-space)* — Closes a within-root write-target-redirection gap: an extracted entry could stay inside the destination yet, on Windows, resolve to a device (`CON` / `NUL` / `COM1` / `LPT1`), write a hidden alternate data stream (`file.txt:payload`), or overwrite a sibling after Windows strips a trailing dot/space (`config.`). The verification gate now rejects all three per path segment. Refusal is platform-unconditional (the verifier may run on a different OS than the extractor); set `reservedNamePolicy` / `adsPolicy` / `leadingTrailingPolicy` to `"allow"` to opt a check out on a Linux-only target. Single-entry, name-only residuals — 8.3 short-name aliasing, case-insensitive cross-entry collisions, and archive symlink/hardlink entry-target validation — remain the extract orchestrator's responsibility (it owns the case-folded seen-set and the link-target gate).
12
+
11
13
  - v0.13.12 (2026-05-27) — **Inbound MX listener now runs the connection-level gate cascade it documented — HELO identity, DNS blocklist, and greylisting.** b.mail.server.mx.create documented helo / rbl / greylist gate options, but the listener never invoked them — an operator who wired them got silent acceptance of mail those gates would have rejected. They are now wired into the live SMTP state machine: the HELO-identity gate evaluates at HELO/EHLO and refuses a spoofed or malformed identity with 550; the DNS-blocklist gate evaluates the connecting IP once per connection and refuses a listed source with 554; the greylisting gate defers a first-seen (ip, sender, recipient) tuple with a 450 tempfail so legitimate senders retry and pass. Each gate is skipped when the operator doesn't wire it. Because these gates do DNS and store lookups, the per-connection command pump was reworked to process commands asynchronously and strictly in arrival order, so pipelined commands (RFC 2920) cannot overtake a gate still resolving and the existing SMTP-smuggling and STARTTLS-stripping defenses are unchanged. The message-authentication gate (SPF/DKIM/DMARC alignment via b.guardEnvelope) needs the inbound SPF + DKIM verification results as inputs; that inbound-auth pipeline lands as a follow-up, and the documentation no longer implies that gate is active today. **Added:** *HELO-identity / RBL / greylist gates wired into `b.mail.server.mx`* — When wired, `opts.helo` (FCrDNS / HELO-shape / self-name checks) refuses a bad HELO identity at HELO/EHLO with 550; `opts.rbl` refuses a connecting IP found on a DNS blocklist with 554 (evaluated once per connection); `opts.greylist` defers a first-seen (ip, sender, recipient) tuple with 450 4.7.1. Their verdicts surface on the `rcpt_to` event (`rblListed`, `greylist`) and the `helo` event (`heloVerdict`), with dedicated `helo_gate_refused` / `rbl_refused` / `greylist_deferred` audit events. A gate the operator doesn't supply is skipped, never synthesized. **Changed:** *MX command pump processes commands asynchronously and in arrival order* — Gate evaluation involves DNS and store lookups, so the per-connection command pump now awaits each command before the next. Pipelined commands are serialized so a gate resolving cannot let a later command answer ahead of an earlier one; reply ordering, the bare-LF SMTP-smuggling refusal, and the STARTTLS-stripping defense are unchanged. No change to the listener's external behaviour when no gates are wired. **Deprecated:** *SPF/DKIM/DMARC-alignment gate documentation corrected to match what is active* — The `envelope` (SPF/DKIM/DMARC alignment) and `dmarc` gate options were documented as wireable but require inbound SPF + DKIM verification results the listener does not yet produce. They are removed from the documented option set until the inbound-authentication pipeline (composing `b.mail.spf` + `b.mail.dmarc` + DKIM verification) lands; run those checks on the delivered message via the agent handoff in the meantime.
12
14
 
13
15
  - v0.13.11 (2026-05-27) — **Test-suite reliability: replaced fixed-delay waits in the rate-limiter and scheduler suites with condition polling.** No runtime behaviour changes. The rate-limiter, scheduler, and websocket-channel test suites waited for asynchronous work to settle by draining a fixed number of event-loop ticks before asserting. Under heavily parallel CI that budget was occasionally too short, so an assertion read state before the async work (a cluster-backend counter update, a scheduler tick-claim) had landed — an intermittent failure unrelated to the code under test. Those waits now poll the observable condition (helpers.waitUntil) and exit as soon as it holds, with a generous upper bound, so they pass quickly on fast machines and reliably under load. A build gate is added so the fixed-tick-drain shape cannot be reintroduced. **Fixed:** *Flaky fixed-budget waits in the rate-limiter / scheduler / sandbox test suites made contention-tolerant* — The rate-limit-cluster and scheduler-exactly-once suites drained a fixed count of event-loop ticks before asserting on asynchronously-updated state; under contended CI the budget could expire before the work settled, producing intermittent failures. They now wait on the actual observable condition (a written response, a settled counter). The sandbox suite's success-path cases gave the worker a 5 s execution budget that cold worker-thread startup under heavily parallel Windows CI could just exceed; those are raised to the framework's 10 s ceiling. Affects test code only — no change to shipped framework behaviour. The unused tick-drain helper in the websocket-channel suite was removed. **Detectors:** *Build gate rejects the fixed-tick-drain wait shape in tests* — A new test-suite lint rule flags the counted microtask/tick-drain idiom (reassigning a promise to its own `.then()` in a loop to wait a fixed number of ticks), the sibling of the existing fixed-`setTimeout`-sleep rule. A single event-loop yield is unaffected; only the drain-as-wait shape is rejected, directing the wait to condition polling instead.
@@ -335,7 +335,7 @@ This is the minimum-viable security posture for a production deployment. The fra
335
335
  - [ ] For an inbound `b.mail.server.mx` listener, wire the connection-level gate cascade so the anti-abuse controls actually run: `helo: b.mail.helo` (FCrDNS / HELO-shape / self-name spoofing → 550), `rbl: b.mail.rbl.create({ providers: [...] })` (connecting-IP DNS blocklist → 554, evaluated once per connection), and `greylist: b.mail.greylist.create({ store })` (first-seen (ip, sender, recipient) → 450 tempfail). A gate you don't wire is skipped, not synthesized — so an unwired RBL is no protection, not a default-allow surprise. SPF/DKIM/DMARC alignment is performed on the delivered message via the agent handoff until the inbound-authentication pipeline lands
336
336
  - [ ] For routes that accept markdown (rich-text editors, comment systems, README rendering, documentation submission, GitHub-style wikis, mail-rendered markdown, document-import flows — ANY operator-supplied markdown the server renders): `b.guardMarkdown.gate({ profile: "strict" })` is wired by default into `b.fileUpload` + `b.staticServe` as of v0.7.16. For inbound markdown bodies that don't go through those primitives, wire `b.guardMarkdown.validate(body, { profile: "strict" })` BEFORE passing the source to any markdown renderer (marked / markdown-it / commonmark / remark / parsedown — all of them) — strict refuses dangerous URL schemes in inline links + images + autolinks + reference-link definitions (defends CVE-2025-9540 Markup Markdown class, CVE-2025-24981 MDC class, NuGetGallery GHSA-gwjh-c548-f787, Joplin GHSA-hff8-hjwv-j9q7), whitespace-tolerant dangerous-tag matching (`<script\n>` / `<script\t>` — defends CVE-2026-30838 CommonMark DisallowedRawHtml bypass class), HTML-entity scheme bypass (`&#x6A;avascript:` / `&#106;avascript:` decoded BEFORE scheme matching), reference-link smuggling (`[label]: javascript:...`), front-matter YAML/TOML blocks, HTML comments, code-fence language injection (language tag containing `<>"' `` blocks attribute breakout), catastrophic emphasis runs (CVE-2025-6493 CodeMirror Markdown class, CVE-2025-7969 markdown-it class), inline DOCTYPE, bidi/null/control chars, total-bytes + line + link + image + autolink + ref-def + list-depth + blockquote-depth caps. **Layer with `b.guardHtml`**: source-level guardMarkdown then render then output-level guardHtml together close the residual bypass surface that either alone misses (markdown engines surprise; sanitizers also surprise — defense in depth)
337
337
  - [ ] For routes that accept XML (SOAP endpoints, sitemap submissions, RSS / Atom feeds, OAI-PMH harvesters, SAML / WS-Federation receivers, document-import flows — ANY operator-supplied XML the server parses): `b.guardXml.gate({ profile: "strict" })` is wired by default into `b.fileUpload` + `b.staticServe` as of v0.7.15. For inbound XML bodies that don't go through those primitives, wire `b.guardXml.validate(body, { profile: "strict" })` before passing the document to any XML parser — strict refuses DOCTYPE declarations unconditionally (XXE + billion-laughs vector — defends CVE-2026-24400 AssertJ class, CVE-2024-8176 libexpat recursive-entity stack-overflow class), `<!ENTITY>` declarations including parameter entities (out-of-band exfiltration vector), external entity references (SYSTEM / PUBLIC with file:// / http:// / ftp:// schemes — local file read + SSRF), `<xi:include>` remote inclusion (CVE-2024-25062 libxml2 use-after-free class), `xsi:schemaLocation` operator-controlled schema fetch, processing instructions (`<?xml-stylesheet ?>` CSS-injection vector), CDATA sections (often used to hide payloads from naive scanners), XML signature wrapping (xmldsig surface), bidi/null/control chars in element text + attribute values, and applies depth + element + per-attribute-value caps. DOCTYPE remains refused at every profile level (strict / balanced / permissive) because billion-laughs is universal. Operators integrating with legacy SOAP that requires DTDs must instead route through a separately-firewalled XML processor with explicit allowlist — the gate has no knob to relax DOCTYPE
338
- - [ ] **For ZIP-shaped uploads specifically**, reach for `b.safeArchive.extract({ source, destination, guardProfile: "strict" })` — the one-liner composes `b.archive.read.zip`'s random-access reader (LFH/CD skew defense + CD-walk validation), `b.guardArchive.zipBombPolicy` defaults (per-entry + per-archive + ratio caps), `b.guardArchive.entryTypePolicy` defaults (symlink / hardlink / device / fifo / socket entries refused), and `b.guardFilename.verifyExtractionPath`'s dual-check (string-normalize + `fs.realpath`-agreement; refuses pre-resolve names exceeding PATH_MAX=4096 to defend the CVE-2025-4517 TOCTOU class). The fs-coupled realpath check is the depth above `b.guardArchive.checkExtractionPath`'s portable string-only gate; operators with their own extract loop call both. Default refuses ZIP encrypted entries (v0.12.10/11 add the encryption read paths). For tar / gzip / 7z / rar / zstd, the read-side primitives land in v0.12.8 / v0.12.9; until then use the legacy guard-only path below
338
+ - [ ] **For ZIP-shaped uploads specifically**, reach for `b.safeArchive.extract({ source, destination, guardProfile: "strict" })` — the one-liner composes `b.archive.read.zip`'s random-access reader (LFH/CD skew defense + CD-walk validation), `b.guardArchive.zipBombPolicy` defaults (per-entry + per-archive + ratio caps), `b.guardArchive.entryTypePolicy` defaults (symlink / hardlink / device / fifo / socket entries refused), and `b.guardFilename.verifyExtractionPath`'s dual-check (string-normalize + `fs.realpath`-agreement; refuses pre-resolve names exceeding PATH_MAX=4096 to defend the CVE-2025-4517 TOCTOU class; and refuses per-segment Windows write-target hazards — reserved device names like `CON`/`NUL`, NTFS alternate-data-stream `name:stream` syntax, and trailing-dot/whitespace that Windows strips into a sibling overwrite — platform-unconditionally, since the extracting host may differ from the verifying host). The fs-coupled realpath check is the depth above `b.guardArchive.checkExtractionPath`'s portable string-only gate; operators with their own extract loop call both. Default refuses ZIP encrypted entries (v0.12.10/11 add the encryption read paths). For tar / gzip / 7z / rar / zstd, the read-side primitives land in v0.12.8 / v0.12.9; until then use the legacy guard-only path below
339
339
  - [ ] For routes that accept archives (zip / tar / gzip / 7z / rar / zstd / etc. — ANY upload that downstream code will extract): use the operator's archive library to enumerate entries, then validate via `b.guardArchive.validateEntries(entries, { profile: "strict" })` BEFORE extracting any file. Strict profile defends against zip-slip path traversal (CVE-2025-3445 / 32779 / 62156 / 66945 / 45582 / 11002 class), symlink + hardlink escape (CVE-2026-26960 class), per-entry + aggregate compression-ratio bombs (zip-bomb defense), total-size + entry-count caps, nested-archive recursion DoS, duplicate entry names (silent-overwrite vector), case-insensitive collisions on Windows / macOS, and per-entry filename safety (composes `b.guardFilename` for path traversal / null-byte / Windows reserved names / NTFS ADS / RTLO bidi / overlong UTF-8 / shell-exec / double-extension detection). Additionally call `b.guardArchive.checkExtractionPath(entryName, extractionRoot)` per entry at extract time AND `path.resolve(extractionRoot, entryName).startsWith(path.resolve(extractionRoot))` after path-resolve to catch any traversal that survived metadata validation
340
340
  - [ ] For ANY file-upload route — wire `b.guardFilename.gate({ profile: "strict" })` to validate the filename string before it touches the filesystem. Strict profile rejects path traversal (raw + percent-encoded + UTF-8 overlong), null-byte truncation (defends extension-allowlist bypass), Windows reserved device names (CON / PRN / AUX / ... — even with extensions), NTFS alternate data streams, leading/trailing whitespace + trailing dots (Windows silently strips), Unicode bidi / RTLO file-name spoofing (CVE-2021-42574 in filename context — `Photo01By‮gpj.SCR` displays as `RCS.jpg` while OS opens `.SCR`), reserved characters, UNC paths, shell-shortcut + executable extensions (.exe / .bat / .vbs / .lnk / .scr / .dll / .so / etc.), and double-extension bypass (`invoice.pdf.exe`). Operators with non-ASCII filename requirements use `profile: "balanced"`. Operators with multi-component path-shape needs use `profile: "permissive"` and explicitly opt in to `pathSeparatorsPolicy: "allow"`
341
341
  - [ ] For routes that accept SVG (avatar uploads, illustration / icon assets, mail attachments, file-upload widgets that allow image/svg+xml): wire `b.guardSvg.gate({ profile: "strict" })` — strict profile rejects every dangerous tag (script / foreignObject / animation family), denies cross-origin `<use>` references (defends server-side rasterization SSRF), refuses every DOCTYPE (defends billion-laughs entity expansion + XXE per CVE-2026-29074 class), refuses SVGZ payloads (operator must ungzip first), and enforces the SMIL animation attributeName allowlist (defends the recent CVE class where `<animate attributeName="href" to="javascript:..."/>` retroactively hijacks an element's href). For uploaded SVGs that need to be rendered, additionally serve under a strict CSP and consider rasterizing server-side to PNG before display
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 1,
3
- "frameworkVersion": "0.13.12",
4
- "createdAt": "2026-05-27T10:09:34.345Z",
3
+ "frameworkVersion": "0.13.13",
4
+ "createdAt": "2026-05-27T11:10:37.499Z",
5
5
  "exports": {
6
6
  "a2a": {
7
7
  "type": "object",
@@ -91,6 +91,20 @@ var WIN_RESERVED_NAMES = Object.freeze([
91
91
  "CLOCK$", "CONFIG$",
92
92
  ]);
93
93
 
94
+ // Windows folds the superscript digits U+00B9 / U+00B2 / U+00B3 to
95
+ // 1 / 2 / 3 when matching COM/LPT device names, so a superscript-digit
96
+ // form resolves to the same device. Built from numeric codepoints so
97
+ // the source stays pure-ASCII (guard-family rule).
98
+ var _SUPERSCRIPT_DIGIT_MAP = (function () {
99
+ var m = {};
100
+ m[String.fromCharCode(0xB9)] = "1";
101
+ m[String.fromCharCode(0xB2)] = "2";
102
+ m[String.fromCharCode(0xB3)] = "3";
103
+ return m;
104
+ })();
105
+ var _SUPERSCRIPT_DIGIT_RE = new RegExp("[" + String.fromCharCode(0xB9, 0xB2, 0xB3) + "]", "g"); // allow:dynamic-regex — superscript-digit codepoints from a numeric table
106
+
107
+
94
108
  // Path-traversal indicators (anchored matches on raw and percent-decoded
95
109
  // forms).
96
110
  var PATH_TRAVERSAL_RE = /(^|[/\\])\.\.($|[/\\])/;
@@ -243,7 +257,16 @@ function _normalizeNFC(s) {
243
257
  function _isWinReserved(name) {
244
258
  // Reserved-name check applies to the base (without extension) AND to
245
259
  // the entire leaf — both `CON` and `CON.txt` collide with the device.
246
- var upper = name.toUpperCase();
260
+ // Windows normalizes the superscript digits U+00B9 / U+00B2 / U+00B3
261
+ // to 1 / 2 / 3 when matching COM/LPT device names, so those superscript
262
+ // forms resolve to the same devices as COM1 / LPT3; fold them to ASCII
263
+ // before comparison so the spoofed forms are caught too. (Source stays
264
+ // pure-ASCII per the guard-family rule — the codepoints are escaped.)
265
+ // Fold COM/LPT superscript-digit spoofs to ASCII before matching
266
+ // (Windows treats them as the device). See _SUPERSCRIPT_DIGIT_* below.
267
+ var upper = name.toUpperCase().replace(_SUPERSCRIPT_DIGIT_RE, function (ch) {
268
+ return _SUPERSCRIPT_DIGIT_MAP[ch] || ch;
269
+ });
247
270
  for (var i = 0; i < WIN_RESERVED_NAMES.length; i += 1) {
248
271
  var r = WIN_RESERVED_NAMES[i];
249
272
  if (upper === r) return true;
@@ -952,6 +975,29 @@ var PATH_MAX_BYTES = 4096;
952
975
  * resolved absolute path on success; throws `GuardFilenameError` on
953
976
  * any refusal.
954
977
  *
978
+ * Per-segment Windows-extraction hazards are refused too — these are
979
+ * within-root write-target redirections / collisions that the
980
+ * containment + realpath checks structurally cannot see, so they need
981
+ * a name-level check the disk `validate` / `sanitize` paths already
982
+ * carry: a Windows reserved device name (`CON` / `NUL` / `COM1` / …,
983
+ * which resolves to the device), NTFS alternate-data-stream syntax
984
+ * (`name:stream`, which writes a hidden stream of the base file), and a
985
+ * trailing dot / leading-or-trailing whitespace (`secret.txt.`, which
986
+ * Windows strips so the entry overwrites an existing sibling). The
987
+ * checks are platform-unconditional — the verifier may run on Linux
988
+ * while extraction happens on Windows — and each has an opt-out for
989
+ * Linux-only targets (`reservedNamePolicy` / `adsPolicy` /
990
+ * `leadingTrailingPolicy: "allow"`), mirroring `validate`.
991
+ *
992
+ * Out of this primitive's scope (single-entry, name-only): 8.3 short-name
993
+ * aliasing (`PROGRA~1`), case-insensitive cross-entry collision
994
+ * (`Readme.txt` vs `README.TXT` on a case-preserving FS), and archive
995
+ * symlink/hardlink ENTRY-target validation. The first two are cross-entry
996
+ * properties and the third needs the entry's declared link target, which
997
+ * this function never sees — they belong to the extract orchestrator
998
+ * (`b.archive.read.zip.extract` / `b.safeArchive`), which owns the
999
+ * case-folded seen-set and the link-target gate.
1000
+ *
955
1001
  * Companion to `b.guardArchive.checkExtractionPath` (the string-only
956
1002
  * portable gate the guard-archive primitive keeps fs-free for use as
957
1003
  * a posture cascade member). `verifyExtractionPath` deliberately
@@ -964,8 +1010,13 @@ var PATH_MAX_BYTES = 4096;
964
1010
  * Operators rolling their own extract loop call it per entry.
965
1011
  *
966
1012
  * @opts
967
- * followSymlinks: boolean, // default false — symlink in the
1013
+ * followSymlinks: boolean, // default false — symlink in the
968
1014
  * // resolved path refuses unless set
1015
+ * reservedNamePolicy: string, // "allow" opts out of the Windows
1016
+ * // reserved-device-name segment check
1017
+ * adsPolicy: string, // "allow" opts out of the NTFS-ADS check
1018
+ * leadingTrailingPolicy: string, // "allow" opts out of the trailing-dot /
1019
+ * // leading-or-trailing-whitespace check
969
1020
  *
970
1021
  * @example
971
1022
  * var resolved = b.guardFilename.verifyExtractionPath(
@@ -1023,19 +1074,53 @@ function verifyExtractionPath(entryName, extractionRoot, opts) {
1023
1074
  throw new GuardFilenameError("filename.extraction-unc",
1024
1075
  "verifyExtractionPath: entryName starts with a UNC prefix");
1025
1076
  }
1026
- // `..` segment refuses — walk path components.
1077
+ // `..` segment refuses — walk path components. The same walk also
1078
+ // refuses per-segment Windows-extraction hazards the disk `validate`
1079
+ // / `sanitize` paths already catch but that string-containment +
1080
+ // realpath agreement cannot see, because they're WITHIN-root
1081
+ // collisions / write-target redirections rather than boundary
1082
+ // escapes. These checks are platform-UNCONDITIONAL: the verifier may
1083
+ // run on Linux while the archive is extracted on Windows, so a name
1084
+ // that's only dangerous on Windows must still be refused here.
1085
+ // Operators on a Linux-only target opt out per check, mirroring
1086
+ // `validate`'s policy vocabulary.
1027
1087
  var segs = normalized.split("/");
1028
1088
  for (var si = 0; si < segs.length; si += 1) {
1029
- if (segs[si] === ".." || segs[si] === "..\\" || segs[si] === "..%2f" || segs[si] === "..%5c") {
1089
+ var seg = segs[si];
1090
+ if (seg === ".." || seg === "..\\" || seg === "..%2f" || seg === "..%5c") {
1030
1091
  throw new GuardFilenameError("filename.extraction-traversal",
1031
1092
  "verifyExtractionPath: entryName contains .. segment");
1032
1093
  }
1033
1094
  // URL-encoded variants — explicit refusal so operators don't
1034
1095
  // need to percent-decode before passing the entry name in.
1035
- if (/%2e%2e/i.test(segs[si]) || /%c0%ae/i.test(segs[si])) {
1096
+ if (/%2e%2e/i.test(seg) || /%c0%ae/i.test(seg)) {
1036
1097
  throw new GuardFilenameError("filename.extraction-traversal-encoded",
1037
1098
  "verifyExtractionPath: entryName contains encoded .. segment");
1038
1099
  }
1100
+ if (seg === "" || seg === ".") continue; // separators / current-dir — nothing to name-check
1101
+ // Windows reserved device name (CON / NUL / COM1 / LPT1 / …): on
1102
+ // Windows the segment resolves to the device, redirecting the write.
1103
+ if (opts.reservedNamePolicy !== "allow" && _isWinReserved(seg)) {
1104
+ throw new GuardFilenameError("filename.extraction-reserved-name",
1105
+ "verifyExtractionPath: entryName segment " + JSON.stringify(seg) +
1106
+ " collides with a Windows reserved device name");
1107
+ }
1108
+ // NTFS alternate data stream (name:stream): on Windows the write
1109
+ // lands on a hidden stream of the base file, not a normal file.
1110
+ if (opts.adsPolicy !== "allow" && /:[^:\\/]+$/.test(seg)) {
1111
+ throw new GuardFilenameError("filename.extraction-ntfs-ads",
1112
+ "verifyExtractionPath: entryName segment " + JSON.stringify(seg) +
1113
+ " uses NTFS alternate-data-stream syntax (name:stream)");
1114
+ }
1115
+ // Trailing dot / leading-or-trailing whitespace: Windows silently
1116
+ // strips these, so `secret.txt.` or `secret.txt ` collides with an
1117
+ // existing sibling — an in-root overwrite the containment check
1118
+ // cannot see.
1119
+ if (opts.leadingTrailingPolicy !== "allow" && /^\s|\s$|\.$/.test(seg)) {
1120
+ throw new GuardFilenameError("filename.extraction-leading-trailing",
1121
+ "verifyExtractionPath: entryName segment " + JSON.stringify(seg) +
1122
+ " has leading/trailing whitespace or a trailing dot (Windows strips it)");
1123
+ }
1039
1124
  }
1040
1125
  // Resolve the destination path against the root via path.resolve
1041
1126
  // (string-level computation; no fs hits).
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.13.12",
3
+ "version": "0.13.13",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
@@ -0,0 +1,18 @@
1
+ {
2
+ "$schema": "../scripts/release-notes-schema.json",
3
+ "version": "0.13.13",
4
+ "date": "2026-05-27",
5
+ "headline": "Archive extraction-path verification now refuses Windows reserved names, NTFS data streams, and trailing-dot/space per segment",
6
+ "summary": "b.guardFilename.verifyExtractionPath (the per-entry gate b.archive.read.zip.extract / b.safeArchive run on every extracted file) checked traversal, absolute paths, drive-letter and UNC prefixes, null bytes, PATH_MAX overflow, and realpath containment — but not the per-segment Windows write-target hazards the disk validate / sanitize paths already reject. An archive entry named CON, NUL.txt, subdir/LPT1, file.txt:hidden, or secret.txt. stayed inside the extraction root, so the containment and realpath checks passed it, yet on Windows it would resolve to a device, write a hidden NTFS stream, or (after Windows strips the trailing dot/space) overwrite a sibling file. These are now refused: any path segment that collides with a Windows reserved device name, uses NTFS alternate-data-stream syntax (name:stream), or carries a trailing dot or leading/trailing whitespace. The checks are platform-unconditional — a verifier running on Linux still refuses names that are only dangerous on the Windows host that ultimately extracts the archive — with a per-check opt-out (reservedNamePolicy / adsPolicy / leadingTrailingPolicy: \"allow\") for Linux-only targets.",
7
+ "sections": [
8
+ {
9
+ "heading": "Security",
10
+ "items": [
11
+ {
12
+ "title": "`verifyExtractionPath` refuses per-segment Windows extraction hazards (reserved names / NTFS ADS / trailing dot-space)",
13
+ "body": "Closes a within-root write-target-redirection gap: an extracted entry could stay inside the destination yet, on Windows, resolve to a device (`CON` / `NUL` / `COM1` / `LPT1`), write a hidden alternate data stream (`file.txt:payload`), or overwrite a sibling after Windows strips a trailing dot/space (`config.`). The verification gate now rejects all three per path segment. Refusal is platform-unconditional (the verifier may run on a different OS than the extractor); set `reservedNamePolicy` / `adsPolicy` / `leadingTrailingPolicy` to `\"allow\"` to opt a check out on a Linux-only target. Single-entry, name-only residuals — 8.3 short-name aliasing, case-insensitive cross-entry collisions, and archive symlink/hardlink entry-target validation — remain the extract orchestrator's responsibility (it owns the case-folded seen-set and the link-target gate)."
14
+ }
15
+ ]
16
+ }
17
+ ]
18
+ }
@@ -125,6 +125,49 @@ function testVerifyExtractionPathRefusals() {
125
125
  check("verifyExtractionPath: 4 refusals", refusals === 4);
126
126
  }
127
127
 
128
+ // Per-segment Windows-extraction hazards — refused even though they stay
129
+ // inside the extraction root (within-root write-target redirection /
130
+ // collision that the containment + realpath checks can't see). Platform-
131
+ // unconditional, with per-check opt-outs for Linux-only targets.
132
+ function testVerifyExtractionPathWindowsHazards() {
133
+ function expectCode(name, code, opts) {
134
+ var e = null;
135
+ try { b.guardFilename.verifyExtractionPath(name, "/tmp", opts); }
136
+ catch (err) { e = err; }
137
+ check("verifyExtractionPath refuses " + JSON.stringify(name) + " (" + code + ")",
138
+ e && (e.code || "").indexOf(code) !== -1);
139
+ }
140
+ // Windows reserved device names (bare + with extension + nested segment).
141
+ expectCode("CON", "filename.extraction-reserved-name");
142
+ expectCode("aux.txt", "filename.extraction-reserved-name");
143
+ expectCode("subdir/NUL", "filename.extraction-reserved-name");
144
+ expectCode("logs/COM1.log", "filename.extraction-reserved-name");
145
+ // Superscript-digit COM/LPT spoof (U+00B9/B2/B3 — Windows folds to 1/2/3).
146
+ // Built from codepoints so the test source stays pure-ASCII.
147
+ expectCode("COM" + String.fromCharCode(0xB9), "filename.extraction-reserved-name");
148
+ expectCode("sub/LPT" + String.fromCharCode(0xB3), "filename.extraction-reserved-name");
149
+ // NTFS alternate data streams.
150
+ expectCode("file.txt:evil.exe", "filename.extraction-ntfs-ads");
151
+ expectCode("dir/data.bin:$DATA", "filename.extraction-ntfs-ads");
152
+ // Trailing dot / leading-or-trailing whitespace (Windows strips → collision).
153
+ expectCode("secret.txt.", "filename.extraction-leading-trailing");
154
+ expectCode("name with trailing space ", "filename.extraction-leading-trailing");
155
+
156
+ // Opt-outs accept the name (then pass the realpath leg into a real root).
157
+ function expectAccept(name, opts) {
158
+ var dest = fs.mkdtempSync(path.join(os.tmpdir(), "blamejs-vx-"));
159
+ var ok = false, code = null;
160
+ try { ok = b.guardFilename.verifyExtractionPath(name, dest, opts).indexOf(dest) === 0; }
161
+ catch (e) { code = e && e.code; }
162
+ finally { fs.rmSync(dest, { recursive: true, force: true }); }
163
+ check("verifyExtractionPath accepts " + JSON.stringify(name) + " with opt-out" +
164
+ (code ? " (threw " + code + ")" : ""), ok === true);
165
+ }
166
+ expectAccept("CON", { reservedNamePolicy: "allow" });
167
+ expectAccept("file.txt:evil.exe", { adsPolicy: "allow" });
168
+ expectAccept("secret.txt.", { leadingTrailingPolicy: "allow" });
169
+ }
170
+
128
171
  async function testExtractRefusesOverwrite() {
129
172
  // Codex P1 on v0.12.7 PR #158 — the catch-block cleanup deleted
130
173
  // PRE-EXISTING destination files on abort because the rename-onto-
@@ -313,6 +356,7 @@ async function run() {
313
356
  testEntryTypePolicy();
314
357
  testVerifyExtractionPathHappy();
315
358
  testVerifyExtractionPathRefusals();
359
+ testVerifyExtractionPathWindowsHazards();
316
360
  await testExtractRefusesOverwrite();
317
361
  await testSafeArchiveRefusesTrustedStreamSource();
318
362
  await testGuardArchiveInspect();
@@ -2261,6 +2261,10 @@ async function testNoDuplicateCodeBlocks() {
2261
2261
  files: ["lib/api-key.js:issue", "lib/db-query.js:<top>", "lib/session.js:create"],
2262
2262
  reason: "Generic JS array helper / lambda shape — Object.keys(...).map(fn) + similar functional idioms appearing in any code that walks a column-or-key list.",
2263
2263
  },
2264
+ {
2265
+ files: ["lib/guard-filename.js:verifyExtractionPath", "lib/hal.js:resource", "lib/vault-aad.js:_canonicalize"],
2266
+ reason: "v0.13.13 — coincidental token shingle of the generic split-then-walk-segments idiom (`x.split(sep); for (...) { var seg = ...; if (...) throw/continue }`). guard-filename verifyExtractionPath walks path components refusing per-segment Windows-extraction hazards (reserved names / NTFS-ADS / trailing-dot); hal.js:resource builds a HAL resource by walking link/embedded keys; vault-aad.js:_canonicalize canonicalizes AAD key-value segments. Three unrelated domains (path safety / hypermedia link assembly / crypto AAD canonicalization) — no shared behaviour to extract; the only commonality is the universal split-and-loop control-flow shape.",
2267
+ },
2264
2268
  {
2265
2269
  mode: "family-subset",
2266
2270
  files: [
@@ -45,14 +45,23 @@ function _settle(res, okGetter) {
45
45
  }
46
46
 
47
47
  async function testClusterBackendBasicLimit() {
48
- // limit=3 / windowMs=10s → first 3 requests pass, 4th blocked.
48
+ // limit=3 → first 3 requests pass, 4th blocked.
49
+ // windowMs is a deliberately-large 1h: the fixed-window counter resets
50
+ // at each `floor(now/windowMs)*windowMs` boundary, so a small window
51
+ // (e.g. 10s) let the test's sequential awaited calls straddle a window
52
+ // boundary under SMOKE_PARALLEL=64 CPU contention — the counter reset
53
+ // mid-test and a "blocked" assertion saw a fresh count (the recurring
54
+ // rate-limit-cluster flake). A 1h window makes wall-clock progress
55
+ // during the sub-second test negligible vs the window, so all calls
56
+ // land in one window. (The rollover test below keeps a small window
57
+ // on purpose — it needs an aged row to fall into a prior window.)
49
58
  var tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "blamejs-rl-"));
50
59
  try {
51
60
  await setupTestDb(tmpDir);
52
61
  var mw = b.middleware.rateLimit({
53
62
  backend: "cluster",
54
63
  limit: 3,
55
- windowMs: 10000,
64
+ windowMs: 3600000,
56
65
  });
57
66
 
58
67
  async function fire() {
@@ -90,7 +99,7 @@ async function testClusterBackendIndependentKeys() {
90
99
  var mw = b.middleware.rateLimit({
91
100
  backend: "cluster",
92
101
  limit: 2,
93
- windowMs: 10000,
102
+ windowMs: 3600000,
94
103
  keyFn: function (req) { return req.headers["x-key"] || "default"; },
95
104
  });
96
105
 
@@ -127,7 +136,7 @@ async function testClusterBackendWindowRollover() {
127
136
  var mw = b.middleware.rateLimit({
128
137
  backend: "cluster",
129
138
  limit: 2,
130
- windowMs: 10000,
139
+ windowMs: 3600000, // 1h — same straddle-immunity as the other tests
131
140
  });
132
141
 
133
142
  async function fire() {
@@ -143,10 +152,12 @@ async function testClusterBackendWindowRollover() {
143
152
  await fire(); await fire();
144
153
  check("rollover: 3rd in same window blocked", !(await fire()));
145
154
 
146
- // Fast-forward: rewrite the row so its windowStart is well in the past.
155
+ // Fast-forward: rewrite the row so its windowStart is well in the
156
+ // past — > one window (1h) back so the next take sees it as a prior
157
+ // window and rolls the count over.
147
158
  b.db.prepare(
148
159
  "UPDATE _blamejs_rate_limit_counters SET windowStart = ?, count = ?"
149
- ).run(Date.now() - 60000, 99);
160
+ ).run(Date.now() - 7200000, 99);
150
161
 
151
162
  // The next take's INSERT...ON CONFLICT sees an older windowStart
152
163
  // and rolls count back to 1 → request passes.
@@ -169,7 +180,7 @@ async function testClusterBackendAuditEmit() {
169
180
  var mw = b.middleware.rateLimit({
170
181
  backend: "cluster",
171
182
  limit: 1,
172
- windowMs: 10000,
183
+ windowMs: 3600000,
173
184
  });
174
185
 
175
186
  async function fire() {
@@ -258,7 +269,7 @@ async function testMemoryFixedWindowBasicLimit() {
258
269
  backend: "memory",
259
270
  algorithm: "fixed-window",
260
271
  max: 3,
261
- windowMs: 10000,
272
+ windowMs: 3600000,
262
273
  });
263
274
 
264
275
  async function fire() {
@@ -284,7 +295,7 @@ async function testMemoryFixedWindowIndependentKeys() {
284
295
  backend: "memory",
285
296
  algorithm: "fixed-window",
286
297
  max: 2,
287
- windowMs: 10000,
298
+ windowMs: 3600000,
288
299
  keyFn: function (req) { return req.headers["x-key"] || "default"; },
289
300
  });
290
301
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {