@blamejs/blamejs-shop 0.1.32 → 0.1.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2 -0
- package/README.md +2 -0
- package/lib/admin.js +18 -10
- package/lib/asset-manifest.json +17 -5
- package/lib/storefront.js +326 -44
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +8 -0
- package/lib/vendor/blamejs/README.md +3 -1
- package/lib/vendor/blamejs/api-snapshot.json +99 -2
- package/lib/vendor/blamejs/index.js +3 -0
- package/lib/vendor/blamejs/lib/crypto-oprf.js +110 -0
- package/lib/vendor/blamejs/lib/network-tsig.js +404 -0
- package/lib/vendor/blamejs/lib/network.js +1 -0
- package/lib/vendor/blamejs/lib/vendor/MANIFEST.json +44 -9
- package/lib/vendor/blamejs/lib/vendor/noble-curves.cjs +19 -0
- package/lib/vendor/blamejs/lib/worm.js +246 -0
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.12.x.json +1844 -0
- package/lib/vendor/blamejs/release-notes/v0.13.0.json +22 -0
- package/lib/vendor/blamejs/release-notes/v0.13.1.json +18 -0
- package/lib/vendor/blamejs/scripts/vendor-update.sh +11 -1
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +3 -1
- package/lib/vendor/blamejs/test/layer-0-primitives/crypto-oprf.test.js +101 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/network-tsig.test.js +149 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/sandbox.test.js +2 -2
- package/lib/vendor/blamejs/test/layer-0-primitives/testing.test.js +3 -3
- package/lib/vendor/blamejs/test/layer-0-primitives/worm.test.js +126 -0
- package/package.json +2 -2
- package/lib/vendor/blamejs/release-notes/v0.12.0.json +0 -64
- package/lib/vendor/blamejs/release-notes/v0.12.1.json +0 -32
- package/lib/vendor/blamejs/release-notes/v0.12.10.json +0 -65
- package/lib/vendor/blamejs/release-notes/v0.12.11.json +0 -39
- package/lib/vendor/blamejs/release-notes/v0.12.12.json +0 -48
- package/lib/vendor/blamejs/release-notes/v0.12.13.json +0 -31
- package/lib/vendor/blamejs/release-notes/v0.12.14.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.15.json +0 -27
- package/lib/vendor/blamejs/release-notes/v0.12.16.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.17.json +0 -22
- package/lib/vendor/blamejs/release-notes/v0.12.18.json +0 -22
- package/lib/vendor/blamejs/release-notes/v0.12.19.json +0 -22
- package/lib/vendor/blamejs/release-notes/v0.12.2.json +0 -45
- package/lib/vendor/blamejs/release-notes/v0.12.20.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.21.json +0 -27
- package/lib/vendor/blamejs/release-notes/v0.12.22.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.23.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.24.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.25.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.26.json +0 -30
- package/lib/vendor/blamejs/release-notes/v0.12.27.json +0 -26
- package/lib/vendor/blamejs/release-notes/v0.12.28.json +0 -26
- package/lib/vendor/blamejs/release-notes/v0.12.29.json +0 -31
- package/lib/vendor/blamejs/release-notes/v0.12.3.json +0 -23
- package/lib/vendor/blamejs/release-notes/v0.12.30.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.31.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.32.json +0 -27
- package/lib/vendor/blamejs/release-notes/v0.12.33.json +0 -31
- package/lib/vendor/blamejs/release-notes/v0.12.34.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.35.json +0 -22
- package/lib/vendor/blamejs/release-notes/v0.12.36.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.37.json +0 -27
- package/lib/vendor/blamejs/release-notes/v0.12.38.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.39.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.4.json +0 -19
- package/lib/vendor/blamejs/release-notes/v0.12.40.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.41.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.42.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.43.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.44.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.45.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.46.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.47.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.48.json +0 -22
- package/lib/vendor/blamejs/release-notes/v0.12.49.json +0 -31
- package/lib/vendor/blamejs/release-notes/v0.12.5.json +0 -40
- package/lib/vendor/blamejs/release-notes/v0.12.50.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.51.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.52.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.53.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.54.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.55.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.56.json +0 -27
- package/lib/vendor/blamejs/release-notes/v0.12.57.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.58.json +0 -22
- package/lib/vendor/blamejs/release-notes/v0.12.6.json +0 -45
- package/lib/vendor/blamejs/release-notes/v0.12.60.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.61.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.62.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.63.json +0 -27
- package/lib/vendor/blamejs/release-notes/v0.12.64.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.65.json +0 -27
- package/lib/vendor/blamejs/release-notes/v0.12.66.json +0 -18
- package/lib/vendor/blamejs/release-notes/v0.12.68.json +0 -27
- package/lib/vendor/blamejs/release-notes/v0.12.69.json +0 -27
- package/lib/vendor/blamejs/release-notes/v0.12.7.json +0 -86
- package/lib/vendor/blamejs/release-notes/v0.12.8.json +0 -81
- package/lib/vendor/blamejs/release-notes/v0.12.9.json +0 -61
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.1.x
|
|
10
10
|
|
|
11
|
+
- v0.1.33 (2026-05-26) — **Search filters and synonyms — narrow results by facet, and match what shoppers mean, not just what they typed.** Search gains filters and smarter matching. A search result page now shows facet groups — collection, price range, and in-stock — as filter controls; choosing one narrows the results, the counts beside each option reflect the current result set, and choices combine across groups. Active filters show with a one-click clear and carry through pagination rather than dropping on page two. All filtering is server-side from query parameters, so it works with no client JavaScript. Separately, the query is expanded through an operator-curated synonym and typo vocabulary before the catalog is searched, so a search for "tee" matches "t-shirt" and a near-miss spelling still finds the product, with a "Showing results for" note when the query was corrected. Filters and synonym matching work identically whether the page is served from the edge or the container. This release also moves the shipped theme's stylesheets and island scripts to content-fingerprinted filenames so an asset and the page that references it can never fall out of sync. **Added:** *Faceted search filters* — A search result page shows facet groups — collection, price range, and in-stock availability — as server-rendered filter controls. Selecting a facet narrows the results and is reflected in the URL (for example `/search?q=shirt&collection=summer&in_stock=1`), the count beside each option reflects how many results it would yield against the current query, and facets combine across groups. Active filters render as removable chips with a clear-all path, and the active filters carry through result pagination so page two keeps the filter set. All filtering is server-side from the query string — no client JavaScript — and unknown facet keys, out-of-range price values, and garbage filter values are ignored rather than erroring. With filters applied and no matches, the page shows an empty state with a clear-filters link. · *Synonym and typo-tolerant query matching* — Before the catalog is searched, the query is expanded through an operator-curated vocabulary: synonym groups (so "tee" matches "t-shirt"), common typo corrections, and stopword removal. When the query is corrected or expanded, the page shows a "Showing results for" note. A query that still matches nothing falls back to the raw terms so search never silently empties on an unknown word. **Changed:** *Search filters and synonyms render identically at the edge and the container* — The faceting and synonym logic runs in both the edge worker and the container search path, producing the same results and the same filter chrome whichever serves the page. The edge path reads the catalog and the facet/synonym vocabulary directly from the database, missing-table-resilient, so a shop without the optional facet configuration still searches normally. · *Theme stylesheets and scripts use content-fingerprinted filenames* — The shipped default theme's stylesheets and island scripts are now referenced by a content-hashed filename (`main.<hash>.css`) rather than a `?v=` query, emitted identically by the edge and container renderers and served from object storage under the fingerprinted key. Each URL maps to exactly one byte-content, so an asset and the page that references it can never disagree on a Subresource Integrity digest, and previously-served pages keep working across an asset change. The edge stylesheet link now also carries an integrity attribute, matching the container.
|
|
12
|
+
|
|
11
13
|
- v0.1.32 (2026-05-26) — **Bundles and quantity discounts — sell products together at a set price, and reward buying more.** Two merchandising surfaces go live on the product page and in the cart, both priced authoritatively on the server. A product that belongs to a bundle shows a "Bundle & save" offer with its member products and the bundle price against the sum of the parts; one action adds the whole bundle to the cart at that price, all-or-nothing, and the discount is split across the member lines so the cart subtotal equals the quoted bundle price. Separately, a product with quantity breaks shows its price tiers ("1–2 / 3–5 / 6+") on the page, and the cart applies the right unit price for each line's quantity — recomputed on every cart render and again at checkout, so the line, cart, and order totals all reflect the break. The client never sends a price: it sends a bundle code or a quantity, and the server prices both from the live catalog. A bundle whose member is archived or out of stock is shown unavailable rather than adding a broken line, and a missing offer simply renders nothing. **Added:** *Bundles on the product page* — A product that is part of one or more bundles shows a "Bundle & save" offer listing the bundle's member products, the bundle price, and the saving against buying the parts separately. `POST /cart/bundle` adds the whole bundle to the cart in one action: the bundle price is recomputed from the live catalog and allocated across the member lines (proportional to each member's list price, with the rounding remainder on the last line) so the cart subtotal equals the quoted price. The add is atomic — if any member is archived or out of stock the bundle is shown unavailable and the action adds nothing rather than a partial set. A product in no bundle shows no offer; an unpriceable bundle (a missing member price or a currency mismatch) is shown unavailable, never a server error. The offer renders identically from the edge worker and the container. · *Quantity discounts on the product page and in the cart* — A product with quantity breaks shows its price tiers on the page (for example 1–2, 3–5, 6+ at descending unit prices). In the cart, each line is priced at the unit price for its quantity — applied server-side on every cart render so changing the quantity re-prices the line, and applied again at checkout so the per-line price and the order total written onto the order reflect the break, not the list price. A quantity below the first tier falls back to the base price; a quantity above the top tier takes the top tier. Stacks with the rest of the cart math. The tier table renders identically from the edge worker and the container. **Fixed:** *Themed product template carries the compare control* — The file-backed default product template now includes the Add-to-compare slot, matching the built-in render path so the comparison control appears on the product page whichever template is in use.
|
|
12
14
|
|
|
13
15
|
- v0.1.31 (2026-05-26) — **Product compare — a side-by-side comparison table any shopper can build from the product pages.** Shoppers can now line products up against each other. Every product page gains an "Add to compare" control; a "Compare" link in the footer opens a side-by-side table of the chosen products — image, price, availability, and each product's attributes in aligned rows. No sign-in is required: the comparison basket rides the visitor's existing sealed session cookie, and a signed-in shopper's customer id is carried alongside. The basket holds up to four products (a fifth add is refused with a notice rather than quietly dropping one), the toggle is idempotent, and a product that goes archived or out of stock resolves out of the table with a remove control instead of breaking the page. The control and table render identically from the edge and the container, and the comparison page reuses the catalog grid styling, so no new stylesheet ships. **Added:** *Add to compare on the product page* — Each product page shows an "Add to compare" / "Remove from compare" control reflecting whether the product is already in the basket. `POST /compare/toggle` adds or removes it and redirects back to where the shopper was (the redirect target is validated, so a crafted `return_to` can't bounce the visitor off-site). Comparing needs no account — the basket is keyed on the visitor's existing sealed session cookie; a signed-in shopper's customer id is captured alongside. The basket is capped at four products: a fifth add is refused with a notice and the basket is left unchanged. · *The comparison table* — `GET /compare` renders the selected products side by side — image, title, price, availability, and the per-product attributes resolved through the catalog, in aligned rows. Each column has a remove control, and `POST /compare/clear` empties the whole basket. A product that has since been archived or sold out shows in a gone/out-of-stock state with a remove control rather than failing the render. An empty basket shows a friendly empty state and mints no cookie. A "Compare" link sits in the footer on every page.
|
package/README.md
CHANGED
|
@@ -78,6 +78,8 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
|
|
|
78
78
|
| **`lib/recommendations.js`** | Product-recommendation engine. Operator-curated override pins first (`setOverride` — "when viewing A, show B", kind-scoped + weight-ordered), then a signal-based fallback: co-purchase (products bought in the same orders), category-popular, and in-stock-random filler. `recommendForProduct` / `recommendForCart` / `recommendForCustomer` / `recommendForCategory` each return renderable picks (active + in-stock, source product excluded). The order confirmation page (`/orders/:id`) renders a "Customers also bought" rail from it — best-effort, anchored on the order's items, excluding what was just bought. |
|
|
79
79
|
| **`lib/collections.js`** | Curated + smart product groupings. `GET /collections` lists the shop's active collections; `GET /collections/:slug` renders the grid — manual collections list hand-picked members, smart collections evaluate stored rules against the active catalog and apply the collection's sort strategy. Each product resolves fresh, so archived products drop out. Public, no sign-in; a bad or unknown slug is a 404 (never a 500). Linked from the footer on every page. |
|
|
80
80
|
| **`lib/category-navigation.js`** | Hierarchical category tree surfaced as public browse pages. `GET /categories` lists the active top-level categories as a card grid; `GET /categories/:slug` renders one category — its title and optional description, a breadcrumb chain from the catalog root down to the current category, an optional hero image, and a grid of the category's direct child sub-categories. Each page reads fresh against the active tree, so archived / unpublished categories drop out of every surface. Public, no sign-in; an unknown, archived, or malformed slug is a 404 (never a 500), and a category with no children renders a graceful empty state. Linked from the footer on every page. The tree itself (define / move / reorder / archive, with cycle defense bounded by `MAX_TREE_DEPTH`) is operator-managed through the primitive's write API. |
|
|
81
|
+
| **`lib/search-facets.js`** | Filterable search. A search result page renders facet groups — collection, price range, in-stock — as server-rendered controls; selecting one narrows the results and rides the URL query string (`?q=…&collection=…&in_stock=1`), counts beside each option reflect the current result set, facets combine across groups, and active filters show as removable chips with a clear-all path that survives result pagination. All filtering is server-side from the query string (no client JS); unknown facet keys, out-of-range prices, and garbage values are ignored rather than erroring; an empty filtered result shows a clear-filters state. Runs identically at the edge worker and the container — the edge reads the catalog and facet registry straight from D1, missing-table-resilient. |
|
|
82
|
+
| **`lib/search-synonyms.js`** | Typo-tolerant, synonym-aware query matching. Before the catalog is searched, the query is expanded through an operator-curated vocabulary — synonym groups (so "tee" matches "t-shirt"), common typo corrections, and stopword removal — and the page shows a "Showing results for" note when the query was corrected or expanded. A query that still matches nothing falls back to the raw terms so search never silently empties on an unknown word. Shared rewrite instance; runs identically at the edge and the container. |
|
|
81
83
|
| **`lib/subscriptions.js`** | Stripe-backed recurring billing — `subscription_plans` (interval / amount / trial) + `subscriptions` (mirrors Stripe's object byte-for-byte). `subscriptions.create` POSTs to Stripe via the payment dep, then persists the returned object locally. `handleStripeEvent` replays `customer.subscription.*` events into the local row so the shop has an authoritative view without round-tripping. Customers view + cancel their own subscriptions at `/account/subscriptions` (ownership-checked; cancel mounts when the payment handle is wired). |
|
|
82
84
|
| **`lib/giftcards.js`** | Prepaid bearer gift cards. `issue({ amount_minor, currency })` generates a 16-char code (32-glyph alphabet, no ambiguous letters) via `b.crypto.generateBytes`, stores only its `namespaceHash` digest + a 4-char hint, and returns the plaintext code once. `balance(code)` / `lookup(code)` resolve a code to its live balance (constant-time hash compare); `redeem({ code, order_id, amount_minor })` decrements the balance with an atomic `balance >= amount` SQL guard so concurrent spends can't overdraw. Redeemed at checkout as a credit against the order grand total: the amount due drops by the applied balance (never below zero), the order still records the full total it owed, and the debit is recorded once per order — a card that fully covers the order is marked paid with no Stripe charge. Customers check a balance at `GET /gift-cards`; the page is not a code-existence oracle (unknown / malformed / expired all return the same generic not-found). |
|
|
83
85
|
| **`lib/gift-card-ledger.js`** | Append-only credit / debit / expire history per gift card, with a denormalized `balance_after_minor` snapshot for O(1) balance reads. `credit` / `debit` / `expire` write one row each; `history(id)` paginates a card's transactions; `transactionsForOrder(id)` lists a card's movements for one order. The audit trail behind the admin gift-card ledger console; overdraft is refused at the primitive layer. |
|
package/lib/admin.js
CHANGED
|
@@ -41,22 +41,30 @@ var AUDIT_NAMESPACE = "shop_admin";
|
|
|
41
41
|
// <style> block (or inline style="" attributes) is refused by the strict
|
|
42
42
|
// `style-src 'self'` CSP that governs container-served routes, which is
|
|
43
43
|
// why the console renders unstyled when those are inlined. `'self'`
|
|
44
|
-
// allows this file; the deploy's R2 asset sync uploads it. The
|
|
45
|
-
//
|
|
44
|
+
// allows this file; the deploy's R2 asset sync uploads it. The asset is
|
|
45
|
+
// referenced by its content-fingerprinted name (`admin.<hash>.css`), so
|
|
46
|
+
// the hash is the cache-buster — no `?v=` query — and the URL maps
|
|
47
|
+
// one-to-one onto a byte-content. The sha384 Subresource Integrity digest
|
|
46
48
|
// (b.crypto.sri, W3C SRI 1.0) makes the browser reject a tampered or
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
// rather than hashing the file at render time,
|
|
50
|
-
// container image too — which doesn't ship the
|
|
51
|
-
// absent from the manifest yields
|
|
49
|
+
// mismatched object. Same-origin, so no `crossorigin` is needed. The
|
|
50
|
+
// digest + fingerprinted path come from the build-time manifest
|
|
51
|
+
// (lib/asset-manifest.json) rather than hashing the file at render time,
|
|
52
|
+
// so they're present in the container image too — which doesn't ship the
|
|
53
|
+
// theme asset files. A key absent from the manifest yields the plain path
|
|
54
|
+
// and no integrity attribute.
|
|
52
55
|
var _assetManifest = require("./asset-manifest.json");
|
|
53
|
-
var _ASSET_VERSION = _assetManifest.version;
|
|
54
56
|
function _assetSri(relUnderThemeAssets) {
|
|
55
|
-
|
|
57
|
+
var entry = _assetManifest.assets[relUnderThemeAssets];
|
|
58
|
+
return (entry && entry.integrity) || null;
|
|
59
|
+
}
|
|
60
|
+
function _assetUrl(relUnderThemeAssets) {
|
|
61
|
+
var entry = _assetManifest.assets[relUnderThemeAssets];
|
|
62
|
+
var fp = (entry && entry.fingerprinted) || relUnderThemeAssets;
|
|
63
|
+
return "/assets/themes/default/" + fp;
|
|
56
64
|
}
|
|
57
65
|
function _adminStylesheetLink() {
|
|
58
66
|
var sri = _assetSri("css/admin.css");
|
|
59
|
-
return "<link rel=\"stylesheet\" href=\"
|
|
67
|
+
return "<link rel=\"stylesheet\" href=\"" + _assetUrl("css/admin.css") + "\"" +
|
|
60
68
|
(sri ? " integrity=\"" + sri + "\"" : "") + ">";
|
|
61
69
|
}
|
|
62
70
|
|
package/lib/asset-manifest.json
CHANGED
|
@@ -1,9 +1,21 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.1.
|
|
2
|
+
"version": "0.1.33",
|
|
3
3
|
"assets": {
|
|
4
|
-
"css/admin.css":
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
"css/admin.css": {
|
|
5
|
+
"integrity": "sha384-rvjL1QySBgNJGEUDusbbMCkL3fsjmzcwUvTp9pljo/0Nn/lErxq2AbY8EYvDF9Ch",
|
|
6
|
+
"fingerprinted": "css/admin.4dc78dfc45d0736d.css"
|
|
7
|
+
},
|
|
8
|
+
"css/main.css": {
|
|
9
|
+
"integrity": "sha384-A2m9cYLHIlIrFvTluTOaZz9r8F90yWIKd3SHOlyNwQwtaOBrzlsn/NDR1vso6dSF",
|
|
10
|
+
"fingerprinted": "css/main.91c0dfab0198569c.css"
|
|
11
|
+
},
|
|
12
|
+
"js/passkey-login.js": {
|
|
13
|
+
"integrity": "sha384-65i1wVHIkiS2e0YiOV6oUOF9+tNR7s6QRvpnWaEne43P+B8UvKLTpDgp4MSKNGqX",
|
|
14
|
+
"fingerprinted": "js/passkey-login.f3d6787bdd5e20df.js"
|
|
15
|
+
},
|
|
16
|
+
"js/passkey-register.js": {
|
|
17
|
+
"integrity": "sha384-RaSkJbTpc6ClfPy5QyaY96doIqBqa9CLeIB1CzWnL05pSSJEA9IrIK0S7V7igRxY",
|
|
18
|
+
"fingerprinted": "js/passkey-register.8f6ee49284ab4226.js"
|
|
19
|
+
}
|
|
8
20
|
}
|
|
9
21
|
}
|
package/lib/storefront.js
CHANGED
|
@@ -205,21 +205,31 @@ var LAYOUT =
|
|
|
205
205
|
// each render call to override; absent that, every storefront page
|
|
206
206
|
// references the shipped default theme — so a fresh install renders
|
|
207
207
|
// styled out of the box without any wiring.
|
|
208
|
-
// Asset integrity + version manifest — sha384 digests
|
|
209
|
-
//
|
|
210
|
-
// committed. Read here instead of
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
208
|
+
// Asset integrity + fingerprint + version manifest — sha384 digests and
|
|
209
|
+
// content-fingerprinted paths, baked at build time
|
|
210
|
+
// (scripts/generate-asset-manifest.js) and committed. Read here instead of
|
|
211
|
+
// hashing the files at render time so the integrity attribute and the
|
|
212
|
+
// fingerprinted URL are present in every runtime, including the container
|
|
213
|
+
// image and the edge Worker, neither of which ships the theme asset files
|
|
214
|
+
// to hash live. See lib/asset-manifest.json.
|
|
214
215
|
var _assetManifest = require("./asset-manifest.json");
|
|
215
216
|
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
|
|
217
|
+
// Content-fingerprinted asset URL — `/assets/themes/default/<fingerprinted>`,
|
|
218
|
+
// where the fingerprint embeds a hash of the asset bytes (`main.<hash>.css`).
|
|
219
|
+
// The hash IS the cache-buster, so no `?v=` query is appended: each URL maps
|
|
220
|
+
// one-to-one onto a byte-content. This makes the Worker/R2 deploy order
|
|
221
|
+
// irrelevant — a not-yet-synced asset 404s instead of poisoning SRI, and
|
|
222
|
+
// pages already in flight keep loading the old fingerprinted object that
|
|
223
|
+
// still exists in R2. An asset missing from the manifest (a custom operator
|
|
224
|
+
// theme whose bytes aren't ours to hash) falls back to its plain path.
|
|
225
|
+
function _assetUrl(relUnderThemeAssets) {
|
|
226
|
+
var entry = _assetManifest.assets[relUnderThemeAssets];
|
|
227
|
+
var fp = (entry && entry.fingerprinted) || relUnderThemeAssets;
|
|
228
|
+
return "/assets/themes/default/" + fp;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// The default theme stylesheet ships from R2 at its fingerprinted path.
|
|
232
|
+
var DEFAULT_THEME_CSS_URL = _assetUrl("css/main.css");
|
|
223
233
|
|
|
224
234
|
// Footer copyright year — resolved once at module load. It's a near-static
|
|
225
235
|
// value (changes once a year); a `new Date()` allocation on every page
|
|
@@ -227,12 +237,11 @@ var DEFAULT_THEME_CSS_URL = "/assets/themes/default/css/main.css?v=" + _assetMan
|
|
|
227
237
|
// that a year-boundary staleness window doesn't matter for a copyright line.
|
|
228
238
|
var _COPYRIGHT_YEAR = String(new Date().getUTCFullYear());
|
|
229
239
|
|
|
230
|
-
// Client "island" scripts are served as external assets (same
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
240
|
+
// Client "island" scripts are served as external assets (fingerprinted, same
|
|
241
|
+
// as the CSS), never inline — the storefront's strict `script-src 'self'`
|
|
242
|
+
// CSP blocks inline <script>, so an inline island silently fails in
|
|
243
|
+
// production. `'self'` allows these /assets/ files; the R2 asset sync
|
|
234
244
|
// (npm run sync-assets) uploads them alongside the stylesheets.
|
|
235
|
-
var _ASSET_JS_VERSION = _assetManifest.version;
|
|
236
245
|
|
|
237
246
|
// Subresource Integrity for the static assets — the browser refuses to
|
|
238
247
|
// run/apply a resource whose served bytes don't match (defense against an
|
|
@@ -242,11 +251,12 @@ var _ASSET_JS_VERSION = _assetManifest.version;
|
|
|
242
251
|
// whose bytes aren't ours to hash) yields no attribute. Same-origin, so
|
|
243
252
|
// no `crossorigin` is required for the check to run.
|
|
244
253
|
function _assetSri(relUnderThemeAssets) {
|
|
245
|
-
|
|
254
|
+
var entry = _assetManifest.assets[relUnderThemeAssets];
|
|
255
|
+
return (entry && entry.integrity) || null;
|
|
246
256
|
}
|
|
247
257
|
function _islandScript(name) {
|
|
248
258
|
var sri = _assetSri("js/" + name);
|
|
249
|
-
return "<script src=\"
|
|
259
|
+
return "<script src=\"" + _assetUrl("js/" + name) + "\"" +
|
|
250
260
|
(sri ? " integrity=\"" + sri + "\"" : "") + " defer></script>";
|
|
251
261
|
}
|
|
252
262
|
|
|
@@ -646,13 +656,171 @@ var SEARCH_EMPTY =
|
|
|
646
656
|
" <p class=\"search-empty__icon\" aria-hidden=\"true\">⌕</p>\n" +
|
|
647
657
|
" <h2>{{heading}}</h2>\n" +
|
|
648
658
|
" <p>{{copy}}</p>\n" +
|
|
659
|
+
" {{clear_link}}\n" +
|
|
649
660
|
" <a href=\"/\" class=\"btn-ghost\">Browse the full catalog</a>\n" +
|
|
650
661
|
" </div>\n" +
|
|
651
662
|
"</section>\n";
|
|
652
663
|
|
|
664
|
+
var SEARCH_CORRECTION =
|
|
665
|
+
"<p class=\"search-correction\">Showing results for <strong>{{correction}}</strong>.</p>\n";
|
|
666
|
+
|
|
667
|
+
var FACET_GROUP_HEAD =
|
|
668
|
+
"<fieldset class=\"facet-group\">\n" +
|
|
669
|
+
" <legend class=\"facet-group__title\">{{label}}</legend>\n" +
|
|
670
|
+
" <ul class=\"facet-group__options\">\n";
|
|
671
|
+
|
|
672
|
+
var FACET_OPTION =
|
|
673
|
+
"<li class=\"facet-option\">\n" +
|
|
674
|
+
" <a class=\"facet-option__link{{selected_class}}\" href=\"{{href}}\" rel=\"nofollow\"{{aria_pressed}}>\n" +
|
|
675
|
+
" <span class=\"facet-option__box\" aria-hidden=\"true\">{{box}}</span>\n" +
|
|
676
|
+
" <span class=\"facet-option__label\">{{label}}</span>\n" +
|
|
677
|
+
" <span class=\"facet-option__count\">{{count}}</span>\n" +
|
|
678
|
+
" </a>\n" +
|
|
679
|
+
"</li>\n";
|
|
680
|
+
|
|
681
|
+
var FACET_CHIP =
|
|
682
|
+
"<a class=\"facet-chip\" href=\"{{href}}\" rel=\"nofollow\">\n" +
|
|
683
|
+
" <span class=\"facet-chip__label\">{{label}}</span>\n" +
|
|
684
|
+
" <span class=\"facet-chip__x\" aria-hidden=\"true\">×</span>\n" +
|
|
685
|
+
" <span class=\"skip-link\">Remove filter</span>\n" +
|
|
686
|
+
"</a>\n";
|
|
687
|
+
|
|
688
|
+
// Build a `/search?...` URL from a query + applied-filters map. Mirrors
|
|
689
|
+
// the edge renderer's `_searchUrl` so the container and worker emit
|
|
690
|
+
// byte-identical filter links. `URLSearchParams` percent-encodes every
|
|
691
|
+
// value; the strict `_render` HTML-escapes it again when it lands in an
|
|
692
|
+
// `href` attribute.
|
|
693
|
+
function _searchUrl(q, filters) {
|
|
694
|
+
var sp = new URLSearchParams();
|
|
695
|
+
if (typeof q === "string" && q.length) sp.set("q", q);
|
|
696
|
+
var keys = Object.keys(filters).sort();
|
|
697
|
+
for (var i = 0; i < keys.length; i += 1) {
|
|
698
|
+
var vals = (filters[keys[i]] || []).slice().sort();
|
|
699
|
+
for (var j = 0; j < vals.length; j += 1) sp.append(keys[i], vals[j]);
|
|
700
|
+
}
|
|
701
|
+
var qs = sp.toString();
|
|
702
|
+
return qs.length ? "/search?" + qs : "/search";
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function _toggleFilter(filters, key, value) {
|
|
706
|
+
var next = {};
|
|
707
|
+
var keys = Object.keys(filters);
|
|
708
|
+
for (var i = 0; i < keys.length; i += 1) next[keys[i]] = filters[keys[i]].slice();
|
|
709
|
+
var cur = next[key] || [];
|
|
710
|
+
var at = cur.indexOf(value);
|
|
711
|
+
if (at === -1) cur = cur.concat([value]);
|
|
712
|
+
else cur = cur.slice(0, at).concat(cur.slice(at + 1));
|
|
713
|
+
if (cur.length) next[key] = cur;
|
|
714
|
+
else delete next[key];
|
|
715
|
+
return next;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function _renderSearchFacets(facets, filters, q) {
|
|
719
|
+
var groups = [];
|
|
720
|
+
for (var f = 0; f < facets.length; f += 1) {
|
|
721
|
+
var facet = facets[f];
|
|
722
|
+
var optionsHtml = "";
|
|
723
|
+
var rendered = 0;
|
|
724
|
+
for (var o = 0; o < facet.options.length; o += 1) {
|
|
725
|
+
var opt = facet.options[o];
|
|
726
|
+
if (opt.count === 0 && !opt.selected) continue;
|
|
727
|
+
optionsHtml += _render(FACET_OPTION, {
|
|
728
|
+
href: _searchUrl(q, _toggleFilter(filters, facet.key, opt.value)),
|
|
729
|
+
selected_class: opt.selected ? " is-selected" : "",
|
|
730
|
+
aria_pressed: "RAW_ARIA",
|
|
731
|
+
box: opt.selected ? "✓" : "",
|
|
732
|
+
label: opt.label,
|
|
733
|
+
count: String(opt.count),
|
|
734
|
+
}).replace("RAW_ARIA", opt.selected ? " aria-pressed=\"true\"" : " aria-pressed=\"false\"");
|
|
735
|
+
rendered += 1;
|
|
736
|
+
}
|
|
737
|
+
if (rendered === 0) continue;
|
|
738
|
+
groups.push(_render(FACET_GROUP_HEAD, { label: facet.label }) + optionsHtml + " </ul>\n</fieldset>\n");
|
|
739
|
+
}
|
|
740
|
+
if (!groups.length) return "";
|
|
741
|
+
return "<aside class=\"search-facets\" aria-label=\"Filter results\">\n" +
|
|
742
|
+
"<h2 class=\"search-facets__title\">Filter</h2>\n" +
|
|
743
|
+
groups.join("") +
|
|
744
|
+
"</aside>\n";
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function _renderSearchChips(facets, filters, q) {
|
|
748
|
+
var labelFor = {};
|
|
749
|
+
for (var f = 0; f < facets.length; f += 1) {
|
|
750
|
+
var byVal = {};
|
|
751
|
+
for (var o = 0; o < facets[f].options.length; o += 1) byVal[facets[f].options[o].value] = facets[f].options[o].label;
|
|
752
|
+
labelFor[facets[f].key] = { group: facets[f].label, values: byVal };
|
|
753
|
+
}
|
|
754
|
+
var chips = "";
|
|
755
|
+
var any = false;
|
|
756
|
+
var keys = Object.keys(filters).sort();
|
|
757
|
+
for (var k = 0; k < keys.length; k += 1) {
|
|
758
|
+
var meta = labelFor[keys[k]];
|
|
759
|
+
var vals = filters[keys[k]] || [];
|
|
760
|
+
for (var v = 0; v < vals.length; v += 1) {
|
|
761
|
+
var valLabel = meta && meta.values[vals[v]] != null ? meta.values[vals[v]] : vals[v];
|
|
762
|
+
var groupLabel = meta ? meta.group : keys[k];
|
|
763
|
+
chips += _render(FACET_CHIP, {
|
|
764
|
+
href: _searchUrl(q, _toggleFilter(filters, keys[k], vals[v])),
|
|
765
|
+
label: groupLabel + ": " + valLabel,
|
|
766
|
+
});
|
|
767
|
+
any = true;
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
if (!any) return "";
|
|
771
|
+
var clearAll = _render(
|
|
772
|
+
"<a class=\"facet-chip facet-chip--clear\" href=\"{{href}}\" rel=\"nofollow\">Clear all filters</a>\n",
|
|
773
|
+
{ href: _searchUrl(q, {}) }
|
|
774
|
+
);
|
|
775
|
+
return "<div class=\"search-active-filters\" aria-label=\"Active filters\">\n" + chips + clearAll + "</div>\n";
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// Hard caps mirroring `lib/search-facets.js`'s applied-filter
|
|
779
|
+
// validators so a hostile / stale URL can't blow the in-memory facet
|
|
780
|
+
// walk. Garbage values and unknown facet keys are dropped (not
|
|
781
|
+
// refused): a shopper landing on a link with a removed-facet param
|
|
782
|
+
// still gets a clean results page rather than a 500.
|
|
783
|
+
var SEARCH_MAX_FACET_KEYS = 32;
|
|
784
|
+
var SEARCH_MAX_FACET_VALUES = 64;
|
|
785
|
+
var SEARCH_MAX_VALUE_LEN = 256;
|
|
786
|
+
var SEARCH_CONTROL_BYTE_RE = /[\x00-\x1f\x7f]/;
|
|
787
|
+
|
|
788
|
+
// Parse `?key=value` repeats off a parsed URL into the
|
|
789
|
+
// `{ facetKey: [value, ...] }` shape the searchFacets primitive
|
|
790
|
+
// consumes, keeping only keys that match a loaded facet definition and
|
|
791
|
+
// values that survive the length / control-byte guards. `facetDefs` is
|
|
792
|
+
// the `listFacets()` result.
|
|
793
|
+
function _parseSearchFilters(url, facetDefs) {
|
|
794
|
+
var out = {};
|
|
795
|
+
if (!url || !url.searchParams) return out;
|
|
796
|
+
var known = {};
|
|
797
|
+
for (var i = 0; i < facetDefs.length; i += 1) known[facetDefs[i].key] = true;
|
|
798
|
+
var keyCount = 0;
|
|
799
|
+
url.searchParams.forEach(function (rawVal, rawKey) {
|
|
800
|
+
if (rawKey === "q") return;
|
|
801
|
+
if (!Object.prototype.hasOwnProperty.call(known, rawKey)) return;
|
|
802
|
+
if (typeof rawVal !== "string") return;
|
|
803
|
+
if (!rawVal.length || rawVal.length > SEARCH_MAX_VALUE_LEN) return;
|
|
804
|
+
if (SEARCH_CONTROL_BYTE_RE.test(rawVal)) return;
|
|
805
|
+
if (!Object.prototype.hasOwnProperty.call(out, rawKey)) {
|
|
806
|
+
if (keyCount >= SEARCH_MAX_FACET_KEYS) return;
|
|
807
|
+
out[rawKey] = [];
|
|
808
|
+
keyCount += 1;
|
|
809
|
+
}
|
|
810
|
+
var arr = out[rawKey];
|
|
811
|
+
if (arr.length >= SEARCH_MAX_FACET_VALUES) return;
|
|
812
|
+
if (arr.indexOf(rawVal) !== -1) return;
|
|
813
|
+
arr.push(rawVal);
|
|
814
|
+
});
|
|
815
|
+
return out;
|
|
816
|
+
}
|
|
817
|
+
|
|
653
818
|
function renderSearch(opts) {
|
|
654
819
|
if (!opts || typeof opts.q !== "string") throw new TypeError("storefront.renderSearch: opts.q (string) required");
|
|
655
820
|
var products = Array.isArray(opts.products) ? opts.products : [];
|
|
821
|
+
var facets = Array.isArray(opts.facets) ? opts.facets : [];
|
|
822
|
+
var filters = (opts.filters && typeof opts.filters === "object") ? opts.filters : {};
|
|
823
|
+
var hasFilters = Object.keys(filters).length > 0;
|
|
656
824
|
var qTrim = opts.q.trim();
|
|
657
825
|
var title, summary, emptyHeading, emptyCopy;
|
|
658
826
|
if (qTrim.length === 0) {
|
|
@@ -669,10 +837,24 @@ function renderSearch(opts) {
|
|
|
669
837
|
title = "“" + qTrim + "”";
|
|
670
838
|
summary = "Showing " + products.length + " match" + (products.length === 1 ? "" : "es") + " for your query.";
|
|
671
839
|
}
|
|
840
|
+
|
|
841
|
+
var correctionHtml = "";
|
|
842
|
+
if (qTrim.length > 0 && typeof opts.corrected_query === "string" &&
|
|
843
|
+
opts.corrected_query.length > 0 && opts.corrected_query !== qTrim) {
|
|
844
|
+
correctionHtml = _render(SEARCH_CORRECTION, { correction: opts.corrected_query });
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
var facetsHtml = (qTrim.length > 0) ? _renderSearchFacets(facets, filters, opts.q) : "";
|
|
848
|
+
var chipsHtml = (qTrim.length > 0) ? _renderSearchChips(facets, filters, opts.q) : "";
|
|
849
|
+
|
|
672
850
|
var header = _render(SEARCH_HEADER, { title: title, summary: summary });
|
|
673
|
-
var
|
|
851
|
+
var resultsInner;
|
|
674
852
|
if (products.length === 0) {
|
|
675
|
-
|
|
853
|
+
var clearLink = hasFilters
|
|
854
|
+
? _render("<a href=\"{{href}}\" class=\"btn-ghost\">Clear filters</a>", { href: _searchUrl(opts.q, {}) })
|
|
855
|
+
: "";
|
|
856
|
+
resultsInner = _render(SEARCH_EMPTY, { heading: emptyHeading, copy: emptyCopy, clear_link: "RAW_CLEAR" })
|
|
857
|
+
.replace("RAW_CLEAR", clearLink);
|
|
676
858
|
} else {
|
|
677
859
|
var assetPrefix = opts.asset_prefix || "/assets/";
|
|
678
860
|
var cards = products.map(function (p) {
|
|
@@ -683,7 +865,15 @@ function renderSearch(opts) {
|
|
|
683
865
|
var imageAlt = p.hero_media ? (p.hero_media.alt_text || p.title) : null;
|
|
684
866
|
return _buildProductCard({ title: p.title, price: priceStr, slug: p.slug, image_url: imageUrl, image_alt: imageAlt });
|
|
685
867
|
}).join("\n");
|
|
686
|
-
|
|
868
|
+
resultsInner = "<section class=\"search-grid\"><div class=\"grid\">" + cards + "</div></section>";
|
|
869
|
+
}
|
|
870
|
+
var body;
|
|
871
|
+
if (facetsHtml.length > 0) {
|
|
872
|
+
body = header + correctionHtml + chipsHtml +
|
|
873
|
+
"<div class=\"search-layout\">" + facetsHtml +
|
|
874
|
+
"<div class=\"search-layout__results\">" + resultsInner + "</div></div>";
|
|
875
|
+
} else {
|
|
876
|
+
body = header + correctionHtml + chipsHtml + resultsInner;
|
|
687
877
|
}
|
|
688
878
|
return _wrap({
|
|
689
879
|
title: "Search",
|
|
@@ -3948,6 +4138,68 @@ function mount(router, deps) {
|
|
|
3948
4138
|
_send(res, 200, html);
|
|
3949
4139
|
});
|
|
3950
4140
|
|
|
4141
|
+
// Decorate one product row with the display columns the search
|
|
4142
|
+
// cards need (first variant's price, first media) AND the facet
|
|
4143
|
+
// fields the searchFacets primitive computes against: `collection`
|
|
4144
|
+
// (array of collection slugs the product belongs to), `price_minor`
|
|
4145
|
+
// (the starting price), and `in_stock` (any variant with available
|
|
4146
|
+
// stock). Composes the wired catalog / collections primitives; a
|
|
4147
|
+
// collection facet only populates when `deps.collections` is present.
|
|
4148
|
+
async function _decorateForSearch(p) {
|
|
4149
|
+
var variants = await deps.catalog.variants.listForProduct(p.id);
|
|
4150
|
+
var startingPrice = null;
|
|
4151
|
+
var inStock = false;
|
|
4152
|
+
for (var vi = 0; vi < variants.length; vi += 1) {
|
|
4153
|
+
if (vi === 0) {
|
|
4154
|
+
var price = await deps.catalog.prices.current(variants[0].id, "USD");
|
|
4155
|
+
if (price) startingPrice = price;
|
|
4156
|
+
}
|
|
4157
|
+
var inv = await deps.catalog.inventory.get(variants[vi].sku);
|
|
4158
|
+
if (inv && (Number(inv.stock_on_hand) - Number(inv.stock_held)) > 0) inStock = true;
|
|
4159
|
+
}
|
|
4160
|
+
var media = await deps.catalog.media.listForProduct(p.id);
|
|
4161
|
+
var heroMedia = media.length ? media[0] : null;
|
|
4162
|
+
var collectionSlugs = [];
|
|
4163
|
+
if (deps.collections && typeof deps.collections.collectionsForProduct === "function") {
|
|
4164
|
+
var cols = await deps.collections.collectionsForProduct(p.id);
|
|
4165
|
+
for (var ci = 0; ci < cols.length; ci += 1) {
|
|
4166
|
+
if (cols[ci] && cols[ci].slug) collectionSlugs.push(cols[ci].slug);
|
|
4167
|
+
}
|
|
4168
|
+
}
|
|
4169
|
+
return Object.assign({}, p, {
|
|
4170
|
+
starting_price_minor: startingPrice ? startingPrice.amount_minor : null,
|
|
4171
|
+
starting_price_currency: startingPrice ? startingPrice.currency : "USD",
|
|
4172
|
+
hero_media: heroMedia,
|
|
4173
|
+
collection: collectionSlugs,
|
|
4174
|
+
price_minor: startingPrice ? startingPrice.amount_minor : null,
|
|
4175
|
+
in_stock: inStock,
|
|
4176
|
+
});
|
|
4177
|
+
}
|
|
4178
|
+
|
|
4179
|
+
// Pull the facetable universe for a set of search terms: every
|
|
4180
|
+
// active product matching ANY term (canonical query + synonym
|
|
4181
|
+
// expansions) on title / description, decorated once. The
|
|
4182
|
+
// searchFacets primitive consumes this through a `catalog.list`
|
|
4183
|
+
// adapter and walks the rows in-memory for counts; the route reuses
|
|
4184
|
+
// the same rows for the narrowed result grid (one decoration pass,
|
|
4185
|
+
// no double round trip).
|
|
4186
|
+
async function _facetableUniverse(terms) {
|
|
4187
|
+
var byId = {};
|
|
4188
|
+
var rows = [];
|
|
4189
|
+
for (var ti = 0; ti < terms.length; ti += 1) {
|
|
4190
|
+
var term = typeof terms[ti] === "string" ? terms[ti].trim() : "";
|
|
4191
|
+
if (!term.length) continue;
|
|
4192
|
+
var page = await deps.catalog.products.search({ q: term, status: "active", limit: 100 });
|
|
4193
|
+
for (var i = 0; i < page.rows.length; i += 1) {
|
|
4194
|
+
var p = page.rows[i];
|
|
4195
|
+
if (byId[p.id]) continue;
|
|
4196
|
+
byId[p.id] = true;
|
|
4197
|
+
rows.push(await _decorateForSearch(p));
|
|
4198
|
+
}
|
|
4199
|
+
}
|
|
4200
|
+
return rows;
|
|
4201
|
+
}
|
|
4202
|
+
|
|
3951
4203
|
router.get("/search", async function (req, res) {
|
|
3952
4204
|
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
3953
4205
|
var qRaw = url && url.searchParams.get("q");
|
|
@@ -3956,26 +4208,53 @@ function mount(router, deps) {
|
|
|
3956
4208
|
// primitive — defends against a 10 MiB `?q=...` mass that would
|
|
3957
4209
|
// otherwise round-trip through the LIKE escape function.
|
|
3958
4210
|
if (q.length > 200) q = q.slice(0, 200);
|
|
3959
|
-
|
|
4211
|
+
|
|
4212
|
+
var products = [];
|
|
4213
|
+
var facetGroups = [];
|
|
4214
|
+
var filters = {};
|
|
4215
|
+
var correctedQ = "";
|
|
4216
|
+
|
|
3960
4217
|
if (q.trim().length > 0) {
|
|
3961
|
-
|
|
3962
|
-
|
|
3963
|
-
|
|
3964
|
-
|
|
3965
|
-
|
|
3966
|
-
|
|
3967
|
-
|
|
3968
|
-
|
|
3969
|
-
|
|
3970
|
-
var
|
|
3971
|
-
|
|
3972
|
-
|
|
3973
|
-
|
|
3974
|
-
|
|
3975
|
-
|
|
3976
|
-
|
|
4218
|
+
// Synonym + typo rewrite expands the typed query into the
|
|
4219
|
+
// canonical term plus operator-curated expansions BEFORE the
|
|
4220
|
+
// product query runs. Without the dep, the raw query is the
|
|
4221
|
+
// only term.
|
|
4222
|
+
var terms = [];
|
|
4223
|
+
if (deps.searchSynonyms) {
|
|
4224
|
+
var rewrite = await deps.searchSynonyms.rewrite(q);
|
|
4225
|
+
correctedQ = rewrite.canonical;
|
|
4226
|
+
if (rewrite.canonical.length) terms.push(rewrite.canonical);
|
|
4227
|
+
for (var e = 0; e < rewrite.expansions.length; e += 1) terms.push(rewrite.expansions[e]);
|
|
4228
|
+
}
|
|
4229
|
+
if (!terms.length) terms.push(q.trim());
|
|
4230
|
+
|
|
4231
|
+
if (deps.searchFacets) {
|
|
4232
|
+
// The universe is decorated once; the searchFacets catalog
|
|
4233
|
+
// adapter and the narrowed result grid share it. The facet
|
|
4234
|
+
// instance is created per-request bound to this request's
|
|
4235
|
+
// universe so concurrent searches never share a catalog
|
|
4236
|
+
// snapshot. `deps.searchFacets(catalog)` is the factory wired
|
|
4237
|
+
// in server.js (the searchFacets primitive's `create`, with
|
|
4238
|
+
// the DB query handle pre-bound).
|
|
4239
|
+
var universe = await _facetableUniverse(terms);
|
|
4240
|
+
var facetCatalog = {
|
|
4241
|
+
// The primitive narrows in-memory and drops the focal facet
|
|
4242
|
+
// per option, so the adapter ignores applied_filters and
|
|
4243
|
+
// returns the full matched universe every call.
|
|
4244
|
+
list: function () { return Promise.resolve({ rows: universe }); },
|
|
4245
|
+
};
|
|
4246
|
+
var sfInstance = deps.searchFacets(facetCatalog);
|
|
4247
|
+
var facetDefs = await sfInstance.listFacets({});
|
|
4248
|
+
filters = _parseSearchFilters(url, facetDefs);
|
|
4249
|
+
facetGroups = await sfInstance.getFacets({ query: q, applied_filters: filters });
|
|
4250
|
+
var preview = await sfInstance.previewQuery({ query: q, filters: filters, sample: 24 });
|
|
4251
|
+
products = preview.sample;
|
|
4252
|
+
} else {
|
|
4253
|
+
// No facet dep — flat search over the expanded terms.
|
|
4254
|
+
products = (await _facetableUniverse(terms)).slice(0, 24);
|
|
3977
4255
|
}
|
|
3978
4256
|
}
|
|
4257
|
+
|
|
3979
4258
|
var sid = _readSidCookie(req);
|
|
3980
4259
|
var cartCount = 0;
|
|
3981
4260
|
if (sid) {
|
|
@@ -3986,10 +4265,13 @@ function mount(router, deps) {
|
|
|
3986
4265
|
}
|
|
3987
4266
|
}
|
|
3988
4267
|
_send(res, 200, renderSearch({
|
|
3989
|
-
q:
|
|
3990
|
-
products:
|
|
3991
|
-
|
|
3992
|
-
|
|
4268
|
+
q: q,
|
|
4269
|
+
products: products,
|
|
4270
|
+
facets: facetGroups,
|
|
4271
|
+
filters: filters,
|
|
4272
|
+
corrected_query: correctedQ,
|
|
4273
|
+
shop_name: shopName,
|
|
4274
|
+
cart_count: cartCount,
|
|
3993
4275
|
}));
|
|
3994
4276
|
});
|
|
3995
4277
|
|
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.
|
|
7
|
-
"tag": "v0.
|
|
6
|
+
"version": "0.13.1",
|
|
7
|
+
"tag": "v0.13.1",
|
|
8
8
|
"license": "Apache-2.0",
|
|
9
9
|
"author": "blamejs contributors",
|
|
10
10
|
"source": "https://github.com/blamejs/blamejs",
|
|
@@ -6,8 +6,16 @@ Pre-1.0 the surface is intentionally evolving — every release may
|
|
|
6
6
|
change something operators depend on. Read each entry before
|
|
7
7
|
upgrading across more than a few patches at a time.
|
|
8
8
|
|
|
9
|
+
## v0.13.x
|
|
10
|
+
|
|
11
|
+
- v0.13.1 (2026-05-26) — **`b.worm` — write-once-read-many retention.** Store records that cannot be altered or deleted before a retention period elapses — the immutable-storage discipline regulators require (SEC 17a-4(f), CFTC 1.31, FINRA 4511). b.worm.create(opts) returns a WORM store that enforces, on every mutating call, that a record is not overwritten or deleted while it is within its retainUntil window or under a legal hold. Two modes mirror cloud Object-Lock: compliance (the default — no one, including the operator, can delete before expiry) and governance (a privileged caller may override with an audited reason). Retention can only be extended, never shortened; every record carries a SHA3-512 digest that get verifies, so tampering with the underlying bytes is detected on read; every allow/refuse decision is audited. Storage is pluggable via a synchronous store adapter, so the policy layer sits over a sealed DB table, a filesystem, or any non-S3 backend — the store-agnostic, application-level companion to b.objectStore's S3 Object Lock, with content-integrity verification that native Object Lock does not provide. **Added:** *`b.worm.create` — write-once-read-many retention* — Returns a store with `put` / `get` / `delete` / `extendRetention` / `placeLegalHold` / `releaseLegalHold` / `list`. `put` is write-once (an overwrite of a retained or held record is refused); `delete` is gated by the retention window, legal holds, and the mode (`compliance` refuses any early delete; `governance` allows a privileged override with a required, audited reason); `extendRetention` is extend-only; `get` verifies the stored SHA3-512 digest and throws `worm/tampered` on a mismatch. Storage is a pluggable synchronous adapter (`get` / `set` / `delete` / `has` / `keys`), defaulting to in-memory for tests. Use it for SEC 17a-4 / CFTC / FINRA immutable records on backends without native Object Lock; `b.objectStore` remains the path for S3 Object Lock.
|
|
12
|
+
|
|
13
|
+
- v0.13.0 (2026-05-26) — **`b.crypto.oprf` — RFC 9497 Oblivious PRFs.** Compute F(serverKey, input) without the server learning the input and without the client learning the key — the Oblivious PRF primitive behind password hardening (the server peppers a password it never sees), private set intersection, and Privacy Pass. b.crypto.oprf.suite(name) returns an RFC 9497 ciphersuite — ristretto255-sha512, p256-sha256, p384-sha384, or p521-sha512 — each exposing the base oprf mode and the verifiable voprf mode (a DLEQ proof lets the client confirm the server used the key committed in its public key). The client blinds its input, the server blind-evaluates with its secret key, and the client finalizes by un-blinding and hashing; because un-blinding cancels the blind, the output depends only on key and input. Validated byte-for-byte against the RFC 9497 Appendix-A test vectors. Group and hash-to-curve operations come from the newly vendored @noble/curves (Paul Miller, MIT) — the same maintainer as the framework's existing vendored @noble/post-quantum and @noble/ciphers, with no added npm runtime dependency. **Added:** *`b.crypto.oprf` — RFC 9497 OPRF / VOPRF* — `suite(name)` returns `{ name, oprf, voprf }` for one of the four RFC 9497 ciphersuites (ristretto255-SHA512 / P-256-SHA256 / P-384-SHA384 / P-521-SHA512). The `oprf` (base) mode provides `deriveKeyPair` / `generateKeyPair` / `blind` / `blindEvaluate` / `finalize` / `evaluate`; `voprf` (verifiable) adds a DLEQ proof so the client can prove the server used the committed key. Use it for password hardening, private set intersection, and OPRF-based tokens. Verified against the RFC 9497 Appendix-A vectors. The partially-oblivious `poprf` mode is not yet exposed (the vendored `@noble/curves` does not implement it) and will follow upstream. · *Vendored `@noble/curves`* — `@noble/curves` 2.2.0 (Paul Miller, MIT) is vendored under `lib/vendor/` (no npm runtime dependency), supplying the ristretto255 / NIST-curve group and hash-to-curve operations behind `b.crypto.oprf`. It joins the existing vendored `@noble/post-quantum` and `@noble/ciphers` from the same maintainer; tracked in the SBOM and the vendor-currency gate.
|
|
14
|
+
|
|
9
15
|
## v0.12.x
|
|
10
16
|
|
|
17
|
+
- v0.12.70 (2026-05-26) — **`b.network.dns.tsig` — RFC 8945 DNS transaction signatures.** Sign and verify DNS messages with RFC 8945 TSIG — the shared-key HMAC that authenticates a DNS transaction (zone transfers, dynamic updates, query/response pairs) and proves it was not altered in flight. b.network.dns.tsig.sign(message, opts) appends a TSIG resource record and returns the signed wire; b.network.dns.tsig.verify(message, opts) locates the TSIG record, recomputes the HMAC over the RFC 8945 §4.3.3 digest, compares it in constant time, and checks the time window (valid only within `fudge` seconds of `timeSigned`). HMAC-SHA-256 is the default; SHA-384 / SHA-512 are available and the broken HMAC-MD5 / HMAC-SHA-1 algorithms are refused unless allowLegacy is set. Signing a response chains the request MAC into the digest. Verified byte-for-byte against dnspython 2.8.0 reference signatures. TSIG completes the DNS-trust set alongside the existing DNSSEC (zone-data authentication) and DANE primitives — DNSSEC authenticates the data end-to-end, TSIG authenticates a single hop's transaction with a pre-shared key. **Added:** *`b.network.dns.tsig.sign` / `b.network.dns.tsig.verify`* — RFC 8945 TSIG transaction authentication. `sign(message, { keyName, secret, algorithm, fudge, time, requestMac })` appends a TSIG RR to a DNS wire message and returns `{ wire, mac }`; `verify(message, { keys, now, requestMac })` returns `{ valid, keyName, algorithm, timeSigned, error, macValid, timeValid, reason }`, with a constant-time MAC compare (via `b.crypto.timingSafeEqual`), a `fudge`-second time-window check, truncated-MAC handling per §5.2.2.1, and request-MAC chaining for responses (§5.4.1). HMAC-SHA-256 default; HMAC-SHA-384 / SHA-512 supported; HMAC-MD5 / HMAC-SHA-1 refused unless `allowLegacy: true`. The transaction-level companion to `b.network.dns.dnssec` and `b.network.dns.dane`.
|
|
18
|
+
|
|
11
19
|
- v0.12.69 (2026-05-26) — **`b.middleware.botGuard` no longer blocks browsers that omit Sec-Fetch-Mode.** b.middleware.botGuard treated a missing Sec-Fetch-Mode header as a bot signal and returned 403 Forbidden, which refused legitimate browsers on any origin where the browser does not emit Fetch Metadata: every plain-HTTP non-localhost origin (Umbrel apps, LAN and *.local reverse-proxy deployments) and Safari before 16.4 even over HTTPS. Browsers only send Sec-Fetch-* in a secure context, so its absence is normal there — not a bot. Sec-Fetch-Mode is now advisory only: it never blocks, and it sets req.suspectedBot in mode:"tag" only on a secure-context HTML GET where a modern browser would have sent it. Drive-by bots are still blocked by the missing-Accept-Language and User-Agent heuristics. No configuration change is needed; if you had widened skipPaths or disabled bot-guard to work around this, you can revert that. **Fixed:** *`b.middleware.botGuard` no longer 403s browsers over plain HTTP or older Safari* — A missing `Sec-Fetch-Mode` was a blocking heuristic, but browsers omit Fetch Metadata outside a secure context (every plain-HTTP non-localhost origin — Umbrel, LAN, `*.local` proxies) and Safari < 16.4 omits it even over HTTPS. Those legitimate browsers were refused with `403 Forbidden`. `Sec-Fetch-Mode` is now advisory: it never blocks, and only sets `req.suspectedBot` in `mode: "tag"` on a secure-context HTML GET. The `Accept-Language` and User-Agent heuristics (which catch the same bots) are unchanged. **Detectors:** *reserved-hostname trailing-dot detector recognizes regex strips* — The codebase-patterns gate that requires stripping the RFC 1034 trailing root-zone dot before a reserved-hostname comparison now also recognizes end-anchored regex strips (`.replace(/\.$/, …)`), not only the `charAt` / `while`-loop forms.
|
|
12
20
|
|
|
13
21
|
- v0.12.68 (2026-05-26) — **`b.jwk` — RFC 7638 JWK thumbprint.** Compute the RFC 7638 thumbprint of a JSON Web Key — the canonical base64url(SHA-256(canonical-JSON)) identifier used to name a key (DPoP jkt bindings, ACME account-key thumbprints, DBSC session pins, kid derivation). b.jwk.thumbprint(jwk) returns the digest; b.jwk.canonicalize(jwk) returns the exact JSON that is hashed — only the key-type's required members, member names in lexicographic order, no whitespace, so the same key always yields the same thumbprint regardless of how its JWK was serialized. The standard key types are supported (EC, RSA, oct, OKP per RFC 8037) plus AKP, the IANA key type Node uses for ML-DSA / SLH-DSA post-quantum public keys; SHA-256 is the default, with hash: "sha384" | "sha512" for RFC 9278 thumbprint-with-hash. Verified against the RFC 7638 §3.1 worked example. b.auth.dpop, b.acme, and b.dbsc now compute their thumbprints through this primitive. **Added:** *`b.jwk.thumbprint` / `b.jwk.canonicalize`* — RFC 7638 JWK thumbprint. `thumbprint(jwk, opts)` returns `base64url(hash(canonical-JSON))` — only the key-type's required members feed the hash, so optional fields (`kid`, `use`, `alg`, …) never change the result. `canonicalize(jwk)` returns the canonical JSON string itself. Supports EC / RSA / oct / OKP and the AKP post-quantum key type; SHA-256 default, `hash` selects SHA-384 / SHA-512. Throws `JwkError` on an invalid key or unknown hash. **Changed:** *DPoP, ACME, and DBSC compose `b.jwk`* — `b.auth.dpop` (the `jkt` proof-key thumbprint), `b.acme` (the RFC 8555 account-key authorization), and `b.dbsc` (the session-pin thumbprint) now compute RFC 7638 thumbprints through `b.jwk` instead of carrying their own implementations. Behavior is unchanged — DPoP still refuses symmetric key types, and each surface keeps its own error codes.
|