@blamejs/blamejs-shop 0.3.15 → 0.3.17

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.
Files changed (59) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/lib/admin.js +136 -3
  3. package/lib/asset-manifest.json +1 -1
  4. package/lib/catalog.js +61 -2
  5. package/lib/search-facets.js +15 -6
  6. package/lib/storefront.js +158 -9
  7. package/lib/vendor/MANIFEST.json +2 -2
  8. package/lib/vendor/blamejs/CHANGELOG.md +2 -0
  9. package/lib/vendor/blamejs/api-snapshot.json +2 -2
  10. package/lib/vendor/blamejs/lib/a2a-tasks.js +6 -6
  11. package/lib/vendor/blamejs/lib/ai-input.js +1 -1
  12. package/lib/vendor/blamejs/lib/auth/acr-vocabulary.js +1 -1
  13. package/lib/vendor/blamejs/lib/auth/ciba.js +3 -3
  14. package/lib/vendor/blamejs/lib/auth/oauth.js +1 -1
  15. package/lib/vendor/blamejs/lib/auth/oid4vci.js +1 -1
  16. package/lib/vendor/blamejs/lib/auth/openid-federation.js +1 -1
  17. package/lib/vendor/blamejs/lib/breach-deadline.js +3 -3
  18. package/lib/vendor/blamejs/lib/calendar.js +2 -2
  19. package/lib/vendor/blamejs/lib/content-credentials.js +3 -3
  20. package/lib/vendor/blamejs/lib/ddl-change-control.js +2 -2
  21. package/lib/vendor/blamejs/lib/did.js +2 -2
  22. package/lib/vendor/blamejs/lib/dsr.js +4 -4
  23. package/lib/vendor/blamejs/lib/external-db.js +1 -1
  24. package/lib/vendor/blamejs/lib/guard-cidr.js +1 -1
  25. package/lib/vendor/blamejs/lib/guard-image.js +1 -1
  26. package/lib/vendor/blamejs/lib/guard-list-unsubscribe.js +1 -1
  27. package/lib/vendor/blamejs/lib/guard-time.js +1 -1
  28. package/lib/vendor/blamejs/lib/guard-xml.js +1 -1
  29. package/lib/vendor/blamejs/lib/http-client-cache.js +1 -1
  30. package/lib/vendor/blamejs/lib/iab-tcf.js +4 -4
  31. package/lib/vendor/blamejs/lib/json-schema.js +1 -1
  32. package/lib/vendor/blamejs/lib/jtd.js +1 -1
  33. package/lib/vendor/blamejs/lib/mail-auth.js +1 -1
  34. package/lib/vendor/blamejs/lib/mail-bimi.js +1 -1
  35. package/lib/vendor/blamejs/lib/mail-crypto-smime.js +1 -1
  36. package/lib/vendor/blamejs/lib/mail-server-mx.js +1 -1
  37. package/lib/vendor/blamejs/lib/mail-server-rate-limit.js +1 -1
  38. package/lib/vendor/blamejs/lib/mail-server-submission.js +1 -1
  39. package/lib/vendor/blamejs/lib/mcp.js +7 -7
  40. package/lib/vendor/blamejs/lib/mdoc.js +1 -1
  41. package/lib/vendor/blamejs/lib/metrics.js +2 -2
  42. package/lib/vendor/blamejs/lib/middleware/compose-pipeline.js +1 -1
  43. package/lib/vendor/blamejs/lib/network-dnssec.js +2 -2
  44. package/lib/vendor/blamejs/lib/network-smtp-policy.js +1 -1
  45. package/lib/vendor/blamejs/lib/network-tls.js +1 -1
  46. package/lib/vendor/blamejs/lib/network-tsig.js +3 -3
  47. package/lib/vendor/blamejs/lib/rfc3339.js +2 -2
  48. package/lib/vendor/blamejs/lib/safe-decompress.js +1 -1
  49. package/lib/vendor/blamejs/lib/standard-webhooks.js +3 -3
  50. package/lib/vendor/blamejs/lib/stream-throttle.js +2 -2
  51. package/lib/vendor/blamejs/lib/structured-fields.js +1 -1
  52. package/lib/vendor/blamejs/lib/vault/seal-pem-file.js +1 -1
  53. package/lib/vendor/blamejs/lib/web-push-vapid.js +1 -1
  54. package/lib/vendor/blamejs/lib/webhook.js +1 -1
  55. package/lib/vendor/blamejs/lib/websocket.js +1 -1
  56. package/lib/vendor/blamejs/package.json +1 -1
  57. package/lib/vendor/blamejs/release-notes/v0.14.8.json +27 -0
  58. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +221 -35
  59. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.3.x
10
10
 
11
+ - v0.3.17 (2026-05-30) — **Search results paginate with an accurate count, and collection pages get canonical URLs.** Two customer-facing storefront fixes. Search no longer stops at the first 24 products: the result count now reports the true number of matches instead of the page size, and numbered page links (with prev/next) make every matching product reachable while preserving the active query and filters. Separately, collection pages now emit a correct canonical and og:url so search engines and shared links resolve to one address, while the cart and account pages — which should not be indexed — are explicitly marked noindex. **Fixed:** *Search shows the real match count and paginates* — The search results page previously capped at 24 products, discarded the real total, and reported the page size as the match count — so anything past the first 24 was unreachable and the count was wrong. It now computes the true total, shows it in the "Showing N matches" summary, and adds no-JavaScript numbered page links (with prev/next) that carry the active query and facet filters, so every matching product can be reached. · *Collection pages have canonical URLs; cart and account are noindex* — Collection pages now emit an absolute canonical and og:url derived from the request host, so indexers and shared links resolve to a single address instead of an empty tag. The cart and account pages, which are not meant to be indexed, now carry a noindex robots tag in their markup, consistent on both the edge and container render paths.
12
+
13
+ - v0.3.16 (2026-05-30) — **Uploaded SVGs are sanitized, and product images can be reordered with a chosen primary.** Two things for product media in the admin console. First, when an SVG image is uploaded it now passes through a strict sanitizer: an SVG that carries script or other active content is repaired, or refused outright when it can't be made safe, so a stored image can't become a script-execution vector. Second, a product's images can now be reordered and any image promoted to the primary (hero) slot directly from the console — previously the order was fixed at attach time and the first-attached image was always the hero. The primary image is the one shown first on the product page. **Added:** *Reorder product images and set the primary from the console* — Each image on a product now has a "Make primary" control that moves it to the hero slot shown first on the product page, and a reorder control sets the full order. Both are no-JavaScript form actions. The product page renders images in this order, so changing it here changes what shoppers see first. **Changed:** *Vendored runtime updated to v0.14.8* — The vendored blamejs runtime is updated to v0.14.8, an internal source-comment and lint-hygiene release with no API, wire-format, or behavior changes. No operator action is required. **Security:** *Uploaded SVG images are sanitized or refused* — An uploaded SVG is now checked by a strict sanitizer before it is stored: active content (scripts, event handlers, external references) is stripped, and an SVG that cannot be made safe is rejected with a clear error and never written. This closes the path where a crafted SVG, once stored and served, could execute script. Raster image uploads (PNG, JPEG, WebP, GIF) are unaffected.
14
+
11
15
  - v0.3.15 (2026-05-30) — **Product image galleries are interactive — click a thumbnail to view that image full size.** A product page's image gallery now lets a shopper switch between the product's images: clicking a thumbnail swaps the main image, with no JavaScript required. Previously the thumbnail strip was decorative — the additional images were unreachable, a single-image product showed empty placeholder slots, and only the first four images were shown. Now the main image stacks the product's images and the thumbnails are real, keyboard-accessible controls that select which one is shown; a product with one image shows just that image (no empty slots), and the four-image cap is raised to twelve so far more images are reachable. The picker is pure HTML and CSS, so it works without scripting and degrades cleanly. **Fixed:** *The product image gallery actually switches images now* — Thumbnails on the product page are now interactive: selecting one shows that image as the main image, using a no-JavaScript radio/label control. The empty placeholder slots a single-image product used to show are gone, the four-image cap is raised to twelve so far more images are reachable, and the strip is keyboard-navigable with accessible labels. The edge and container renderers emit identical markup.
12
16
 
13
17
  - v0.3.14 (2026-05-30) — **Drop the inert Document-Policy header that only produced browser warnings.** Container-rendered pages were sending a Document-Policy response header whose feature tokens (document-write, unsized-media, oversized-images) are no longer recognized by current browsers — so it enforced nothing while making the browser log a warning for each token on every page. The header is now omitted; every other security header (Content-Security-Policy, Permissions-Policy, HSTS, X-Frame-Options, the COOP/CORP set, and the rest) is unchanged. The edge and container paths now agree — neither sends Document-Policy — closing a header-parity gap. **Fixed:** *No more Document-Policy console warnings* — The Document-Policy header is no longer emitted on container-rendered pages. Its tokens were rejected by current browsers (the policy applied nothing and logged a warning per token on every page), so the header is dropped rather than shipped inert; the full set of other security headers is retained unchanged. Edge and container responses are now consistent.
package/lib/admin.js CHANGED
@@ -1200,6 +1200,43 @@ function mount(router, deps) {
1200
1200
  // caller renders the problem.
1201
1201
  async function _storeAndAttach(buf, contentType, body) {
1202
1202
  var declared = String(contentType).split(";")[0].trim().toLowerCase();
1203
+ // SVG is text — magic-byte sniffing can't classify it, so the type
1204
+ // gate trusts the declared `image/svg+xml`. An SVG served same-origin
1205
+ // from the media bucket is a stored-XSS vector on direct navigation
1206
+ // (embedded <script>, on* handlers, javascript: URLs, foreignObject
1207
+ // namespace escapes). Run the bytes through the framework's SVG guard
1208
+ // before they reach the bucket: `refuse` (an unrepairable threat —
1209
+ // <script>, DOCTYPE/XXE, SVGZ) is an operational failure the caller
1210
+ // renders as a clean 4xx with no row written; `sanitize` stores the
1211
+ // repaired bytes; `serve` stores the (clean) original. Composes the
1212
+ // vendored b.guardSvg — never a hand-rolled SVG allowlist.
1213
+ if (declared === "image/svg+xml") {
1214
+ var verdict;
1215
+ try {
1216
+ verdict = await b.guardSvg.gate({ profile: "strict" }).check({ bytes: buf });
1217
+ } catch (_e) {
1218
+ return { status: 422, code: "svg-unsafe",
1219
+ detail: "the uploaded SVG could not be validated as safe to serve" };
1220
+ }
1221
+ if (!verdict || verdict.action === "refuse") {
1222
+ return { status: 422, code: "svg-unsafe",
1223
+ detail: "the uploaded SVG carries active content that can't be served safely (scripts, external entities, or a compressed payload)" };
1224
+ }
1225
+ if (verdict.action === "sanitize") {
1226
+ // The guard hands back the repaired bytes as a Buffer (the gate
1227
+ // contract) — or, defensively, a string. Anything else is a guard
1228
+ // malfunction; fail closed rather than store unvalidated bytes.
1229
+ var clean = verdict.sanitized;
1230
+ if (Buffer.isBuffer(clean)) {
1231
+ buf = clean;
1232
+ } else if (typeof clean === "string") {
1233
+ buf = Buffer.from(clean, "utf8");
1234
+ } else {
1235
+ return { status: 422, code: "svg-unsafe",
1236
+ detail: "the uploaded SVG could not be sanitized into safe-to-serve bytes" };
1237
+ }
1238
+ }
1239
+ }
1203
1240
  var ext = _extFromContentType(declared);
1204
1241
  var id = b.uuid.v7();
1205
1242
  var key = "media/" + id + (ext ? "." + ext : "");
@@ -1432,6 +1469,76 @@ function mount(router, deps) {
1432
1469
  },
1433
1470
  ));
1434
1471
 
1472
+ // Reorder a product's media. The PDP gallery renders the media in list
1473
+ // order with the first row as the hero, so this is "rearrange the gallery
1474
+ // + choose which image leads." Browser form posts the full ordered id set
1475
+ // as a comma-joined `ordered_media_ids` string (same convention as the
1476
+ // collections-members reorder); the JSON API accepts an array. The
1477
+ // primitive rewrites `position` to 0..N-1 and refuses a partial / foreign
1478
+ // set (TypeError → clean 400 / ?err=1). DB-only — no R2 bridge needed.
1479
+ router.post("/admin/products/:id/media/reorder", _pageOrApi(false,
1480
+ W("media.reorder", async function (req, res) {
1481
+ var body = req.body || {};
1482
+ var ids = Array.isArray(body.ordered_media_ids) ? body.ordered_media_ids : null;
1483
+ if (!ids || ids.length === 0) return _problem(res, 400, "bad-request", "ordered_media_ids array required");
1484
+ try { await catalog.media.reorder(req.params.id, ids); }
1485
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
1486
+ _json(res, 200, { ok: true });
1487
+ return { id: req.params.id };
1488
+ }),
1489
+ async function (req, res) {
1490
+ var id = req.params.id;
1491
+ var enc = encodeURIComponent(id);
1492
+ var body = req.body || {};
1493
+ // The reorder form posts a single comma-joined id field; also accept
1494
+ // repeated ordered_media_id rows for parity with the collections form.
1495
+ var ids;
1496
+ if (body.ordered_media_ids != null && typeof body.ordered_media_ids === "string") {
1497
+ ids = body.ordered_media_ids.split(",").map(function (s) { return s.trim(); }).filter(Boolean);
1498
+ } else {
1499
+ var raw = body.ordered_media_id;
1500
+ var rows = raw == null ? [] : (Array.isArray(raw) ? raw : [raw]);
1501
+ ids = rows.map(function (s) { return String(s).trim(); }).filter(Boolean);
1502
+ }
1503
+ if (!ids.length) return _redirect(res, "/admin/products/" + enc + "?err=1");
1504
+ try { await catalog.media.reorder(id, ids); }
1505
+ catch (e) {
1506
+ _safeNotice(e, "media.reorder");
1507
+ return _redirect(res, "/admin/products/" + enc + "?err=1");
1508
+ }
1509
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".media.reorder", outcome: "success", metadata: { id: id } });
1510
+ _redirect(res, "/admin/products/" + enc + "?saved=1");
1511
+ },
1512
+ ));
1513
+
1514
+ // Promote one media row to the hero slot (position 0). No body — the media
1515
+ // id is in the path, the product id only used to PRG back to the detail.
1516
+ // A malformed id throws TypeError (→ 400 / ?err=1); an unknown or
1517
+ // variant-only row returns false (→ 404 / ?err=1). DB-only.
1518
+ router.post("/admin/products/:id/media/:mid/primary", _pageOrApi(false,
1519
+ W("media.set_primary", async function (req, res) {
1520
+ var ok;
1521
+ try { ok = await catalog.media.setPrimary(req.params.mid); }
1522
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
1523
+ if (!ok) return _problem(res, 404, "media-not-found");
1524
+ _json(res, 200, { ok: true });
1525
+ return { id: req.params.mid };
1526
+ }),
1527
+ async function (req, res) {
1528
+ var id = req.params.id;
1529
+ var enc = encodeURIComponent(id);
1530
+ var ok = false;
1531
+ try { ok = await catalog.media.setPrimary(req.params.mid); }
1532
+ catch (e) {
1533
+ _safeNotice(e, "media.set_primary");
1534
+ return _redirect(res, "/admin/products/" + enc + "?err=1");
1535
+ }
1536
+ if (!ok) return _redirect(res, "/admin/products/" + enc + "?err=1");
1537
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".media.set_primary", outcome: "success", metadata: { id: req.params.mid } });
1538
+ _redirect(res, "/admin/products/" + enc + "?saved=1");
1539
+ },
1540
+ ));
1541
+
1435
1542
  // ---- bulk catalog import --------------------------------------------
1436
1543
 
1437
1544
  // POST /admin/catalog/import — Content-Type: text/csv body. The CSV
@@ -9658,24 +9765,50 @@ function renderAdminProduct(opts) {
9658
9765
  var variantsSection = "<section class=\"mt\"><h3 class=\"fs-105\">Variants</h3>" + variantsBody + addVariant + "</section>";
9659
9766
 
9660
9767
  // ---- media -----------------------------------------------------------
9661
- var mediaCards = mediaRows.map(function (m) {
9768
+ var mediaCards = mediaRows.map(function (m, idx) {
9662
9769
  var mid = _htmlEscape(m.id);
9663
9770
  var url = assetPrefix + m.r2_key;
9664
9771
  var isImage = /^image\//.test(m.content_type || "");
9665
9772
  var thumb = isImage
9666
9773
  ? "<img class=\"media-thumb\" src=\"" + _htmlEscape(url) + "\" alt=\"" + _htmlEscape(m.alt_text || "") + "\" loading=\"lazy\">"
9667
9774
  : "<span class=\"media-thumb media-thumb--file\">" + _htmlEscape((m.content_type || "file").split("/")[0]) + "</span>";
9775
+ // The first row is the hero shown on the product page — mark it with a
9776
+ // pill; every other row offers a "Make primary" form that promotes it to
9777
+ // position 0 (the CSRF field is injected into the form by the shell).
9778
+ var makePrimary = (idx === 0)
9779
+ ? "<span class=\"status-pill paid\">Primary</span>"
9780
+ : "<form method=\"post\" action=\"/admin/products/" + pid + "/media/" + mid + "/primary\" class=\"form-inline\">" +
9781
+ "<button class=\"btn btn--ghost\" type=\"submit\">Make primary</button></form>";
9668
9782
  return "<div class=\"media-card\">" +
9669
9783
  thumb +
9670
9784
  "<code class=\"order-id\">" + _htmlEscape(m.r2_key) + "</code>" +
9671
9785
  "<span class=\"u-mute\">" + _htmlEscape(m.content_type || "") + (m.alt_text ? " · " + _htmlEscape(m.alt_text) : "") + "</span>" +
9672
- "<a class=\"btn btn--danger\" href=\"/admin/media/" + mid + "/delete/confirm-page?product_id=" + pid + "\">Delete</a>" +
9786
+ "<div class=\"actions-row mt-0\">" + makePrimary +
9787
+ "<a class=\"btn btn--danger\" href=\"/admin/media/" + mid + "/delete/confirm-page?product_id=" + pid + "\">Delete</a>" +
9788
+ "</div>" +
9673
9789
  "</div>";
9674
9790
  }).join("");
9675
9791
  var mediaGrid = mediaRows.length
9676
9792
  ? "<div class=\"media-grid\">" + mediaCards + "</div>"
9677
9793
  : "<p class=\"empty\">No media attached yet.</p>";
9678
9794
 
9795
+ // Reorder: a single field of the current media ids, comma-joined, that the
9796
+ // operator rewrites into the new order. No-JS-required and v1-defensible
9797
+ // — the primitive normalises positions to 0..N-1 and the first row is the
9798
+ // hero. Only shown when there's more than one image to order.
9799
+ var currentMediaIds = mediaRows.map(function (m) { return m.id; }).join(",");
9800
+ var mediaReorderForm = mediaRows.length > 1
9801
+ ? "<div class=\"panel mt-1 mw-40\">" +
9802
+ "<h3 class=\"subhead\">Reorder images</h3>" +
9803
+ "<p class=\"meta\">The first image is the hero shown on the product page. Rewrite the comma-separated id list into the order you want — list every current image.</p>" +
9804
+ "<form method=\"post\" action=\"/admin/products/" + pid + "/media/reorder\">" +
9805
+ "<label class=\"form-field\"><span>Ordered media ids</span>" +
9806
+ "<input type=\"text\" name=\"ordered_media_ids\" value=\"" + _htmlEscape(currentMediaIds) + "\"></label>" +
9807
+ "<div class=\"actions-row\"><button class=\"btn btn--ghost\" type=\"submit\">Apply order</button></div>" +
9808
+ "</form>" +
9809
+ "</div>"
9810
+ : "";
9811
+
9679
9812
  var attachForm =
9680
9813
  "<div class=\"panel mt-1 mw-40\">" +
9681
9814
  "<h3 class=\"subhead\">Attach media by R2 key</h3>" +
@@ -9716,7 +9849,7 @@ function renderAdminProduct(opts) {
9716
9849
  "</div>"
9717
9850
  : "";
9718
9851
 
9719
- var mediaSection = "<section class=\"mt\"><h3 class=\"fs-105\">Media</h3>" + mediaGrid + fileUploadForm + attachForm + uploadForm + "</section>";
9852
+ var mediaSection = "<section class=\"mt\"><h3 class=\"fs-105\">Media</h3>" + mediaGrid + mediaReorderForm + fileUploadForm + attachForm + uploadForm + "</section>";
9720
9853
 
9721
9854
  // ---- head + assembly -------------------------------------------------
9722
9855
  var statusCls = p.status === "active" ? "paid" : (p.status === "archived" ? "refunded" : "pending");
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.3.15",
2
+ "version": "0.3.17",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-1SIn6oAf1DjECbRfKENZasdKHJiywGdXR58wn0hsFGcdVHzUmvfgMkEz5ANIAZJ3",
package/lib/catalog.js CHANGED
@@ -8,10 +8,11 @@
8
8
  * Five sub-modules:
9
9
  *
10
10
  * - `products` — create, get, bySlug, list, update, archive, restore
11
- * - `variants` — create, get, listForProduct, update, delete, reorder
11
+ * - `variants` — create, get, listForProduct, update, delete
12
12
  * - `prices` — set (versioned), current, history
13
13
  * - `inventory` — create, get, decrement, release, restock
14
- * - `media` — attach, listForProduct, listForVariant, delete, reorder
14
+ * - `media` — attach, get, listForProduct, listForVariant, delete,
15
+ * reorder, setPrimary
15
16
  *
16
17
  * Every write validates input at entry; bad input throws TypeError
17
18
  * with a descriptive message so the calling route handler can
@@ -789,6 +790,64 @@ function _mediaModule(query) {
789
790
  var r = await query("DELETE FROM media WHERE id = ?1", [id]);
790
791
  return r.rowCount > 0;
791
792
  },
793
+
794
+ // Reorder a product's media. `orderedIds` is the FULL set of this
795
+ // product's media ids in the desired order; `position` is rewritten to
796
+ // 0..N-1 to match. Throws TypeError on a non-array / id-shape error or
797
+ // when the set doesn't exactly match the product's current media (a
798
+ // missing / extra / foreign id) — a partial reorder would silently drop
799
+ // a row's ordering. D1 has no cross-statement transaction over the HTTP
800
+ // bridge, so positions are written one UPDATE per row, each scoped by
801
+ // product_id so a crafted id list can never reposition another product's
802
+ // row out of band. Returns the count written.
803
+ reorder: async function (productId, orderedIds) {
804
+ _id(productId, "product_id");
805
+ if (!Array.isArray(orderedIds) || orderedIds.length === 0) {
806
+ throw new TypeError("catalog.media.reorder: orderedIds must be a non-empty array");
807
+ }
808
+ orderedIds.forEach(function (id) { _id(id, "media id"); });
809
+ var existing = await query("SELECT id FROM media WHERE product_id = ?1", [productId]);
810
+ var have = existing.rows.map(function (r) { return r.id; }).sort();
811
+ var want = orderedIds.slice().sort();
812
+ if (have.length !== want.length ||
813
+ have.some(function (id, i) { return id !== want[i]; })) {
814
+ throw new TypeError("catalog.media.reorder: orderedIds must list exactly this product's current media");
815
+ }
816
+ for (var i = 0; i < orderedIds.length; i += 1) {
817
+ await query(
818
+ "UPDATE media SET position = ?1 WHERE id = ?2 AND product_id = ?3",
819
+ [i, orderedIds[i], productId],
820
+ );
821
+ }
822
+ return orderedIds.length;
823
+ },
824
+
825
+ // Promote one media row to the hero slot (position 0) and push every
826
+ // other row of the same product down by one, preserving their relative
827
+ // order. The PDP gallery renders media[0] as the hero, so this is the
828
+ // "set primary image" operation. Throws TypeError on a malformed id;
829
+ // returns false when the id doesn't resolve to a product-scoped row
830
+ // (an unknown id, or a variant-only row that has no product_id). Every
831
+ // UPDATE is scoped by product_id — same cross-product-IDOR guard as
832
+ // reorder.
833
+ setPrimary: async function (mediaId) {
834
+ _id(mediaId, "media id");
835
+ var row = (await query("SELECT id, product_id FROM media WHERE id = ?1", [mediaId])).rows[0];
836
+ if (!row || !row.product_id) return false;
837
+ var ordered = (await query(
838
+ "SELECT id FROM media WHERE product_id = ?1 ORDER BY position ASC, created_at ASC",
839
+ [row.product_id],
840
+ )).rows.map(function (r) { return r.id; });
841
+ var without = ordered.filter(function (id) { return id !== mediaId; });
842
+ var next = [mediaId].concat(without);
843
+ for (var i = 0; i < next.length; i += 1) {
844
+ await query(
845
+ "UPDATE media SET position = ?1 WHERE id = ?2 AND product_id = ?3",
846
+ [i, next[i], row.product_id],
847
+ );
848
+ }
849
+ return true;
850
+ },
792
851
  };
793
852
  }
794
853
 
@@ -36,10 +36,12 @@
36
36
  * the facet registry portable across whichever search
37
37
  * backend the operator chooses.
38
38
  *
39
- * `previewQuery({ query, filters })` returns the product count +
40
- * a sample of matching products for a candidate filter
41
- * combination — used by the storefront to render "Show 23 results"
42
- * before the shopper commits the click.
39
+ * `previewQuery({ query, filters, sample?, offset? })` returns the
40
+ * product count + a window of matching products for a candidate
41
+ * filter combination — used by the storefront to render "Show 23
42
+ * results" before the shopper commits the click, and to page the
43
+ * results grid (`offset`/`sample` window the matches; `total` stays
44
+ * the full passing count so the result-count copy is page-independent).
43
45
  *
44
46
  * `recordFacetUse({ key, value, session_id })` appends an
45
47
  * analytics row. The session id is namespaceHashed under
@@ -66,7 +68,7 @@
66
68
  * - updateFacet(key, patch)
67
69
  * - archiveFacet(key)
68
70
  * - getFacets({ query?, applied_filters?, scope? })
69
- * - previewQuery({ query?, filters })
71
+ * - previewQuery({ query?, filters, sample?, offset? })
70
72
  * - recordFacetUse({ key, value, session_id })
71
73
  *
72
74
  * Catalog contract — the operator passes a `catalog` to the
@@ -768,6 +770,13 @@ function create(opts) {
768
770
  var qStr = _optQuery(input.query, "searchFacets.previewQuery");
769
771
  var filters = _appliedFilters(input.filters, "searchFacets.previewQuery");
770
772
  var sample = _sampleSize(input.sample, "searchFacets.previewQuery");
773
+ // `offset` pages the returned window over the full passing set so a
774
+ // caller can request page N of the matches (storefront search
775
+ // pagination) without re-running the in-memory filter walk. The
776
+ // `total` is always the REAL passing count — the page window never
777
+ // changes it — so the result-count copy stays honest regardless of
778
+ // which page is requested. Defaults to 0 (the first window).
779
+ var offset = _offset(input.offset, "searchFacets.previewQuery");
771
780
 
772
781
  var facets = await _loadFacets();
773
782
  var facetsByKey = {};
@@ -790,7 +799,7 @@ function create(opts) {
790
799
  }
791
800
  return {
792
801
  total: passing.length,
793
- sample: passing.slice(0, sample),
802
+ sample: passing.slice(offset, offset + sample),
794
803
  };
795
804
  },
796
805
 
package/lib/storefront.js CHANGED
@@ -243,6 +243,7 @@ var LAYOUT =
243
243
  " <title>{{title}} — {{shop_name}}</title>\n" +
244
244
  " <meta name=\"description\" content=\"{{og_description}}\">\n" +
245
245
  " <link rel=\"canonical\" href=\"{{canonical_url}}\">\n" +
246
+ "RAW_ROBOTS_META" +
246
247
  " <link rel=\"icon\" type=\"image/svg+xml\" href=\"/assets/brand/favicon.svg\">\n" +
247
248
  " <link rel=\"icon\" type=\"image/png\" href=\"/assets/brand/favicon.png\">\n" +
248
249
  " <link rel=\"apple-touch-icon\" href=\"/assets/brand/favicon.png\">\n" +
@@ -755,8 +756,21 @@ function _wrap(opts) {
755
756
  vars[ck] = chrome[ck];
756
757
  }
757
758
 
759
+ // Per-page robots directive. Indexable pages (the default) emit no
760
+ // robots meta — the absence means "index, follow". Pages the storefront
761
+ // keeps out of search (cart / account / any session-scoped surface)
762
+ // pass `robots: "noindex"`, which stamps a noindex,nofollow meta in the
763
+ // <head> — a belt-and-suspenders pairing with the robots.txt Disallow +
764
+ // the edge `x-robots-tag` header, so a directly-fetched page is also
765
+ // self-describing. A noindex page needs no canonical (a crawler won't
766
+ // index it to dedupe), so these pages don't thread a canonical_url.
767
+ var robotsMeta = (opts.robots === "noindex")
768
+ ? " <meta name=\"robots\" content=\"noindex,nofollow\">\n"
769
+ : "";
770
+
758
771
  var assembled = _render(LAYOUT, vars)
759
772
  .replace("RAW_CSS_INTEGRITY", themeCssIntegrity)
773
+ .replace("RAW_ROBOTS_META", robotsMeta)
760
774
  .replace("RAW_ANNOUNCEMENT_BAR", announcementBarHtml)
761
775
  .replace("RAW_CONSENT_SCRIPT", _islandScript("consent.js", { id: "consent-island", policy: _activeConsentPolicy }))
762
776
  .replace("RAW_CART_COUNT_SCRIPT", _islandScript("cart-count.js", { id: "cart-count-island" }))
@@ -1331,6 +1345,87 @@ function _renderSearchChips(facets, filters, q) {
1331
1345
  return "<div class=\"search-active-filters\" aria-label=\"Active filters\">\n" + chips + clearAll + "</div>\n";
1332
1346
  }
1333
1347
 
1348
+ // The fixed search page size — the number of result cards one page of
1349
+ // `/search` shows. Pages beyond the first are reached via `?page=N`
1350
+ // links the pagination nav emits (the real total drives how many pages
1351
+ // exist). Mirrors the edge renderer's SEARCH_PAGE_SIZE.
1352
+ var SEARCH_PAGE_SIZE = 24;
1353
+
1354
+ // `/search?...` URL for a specific results page — the query + active
1355
+ // filters carried forward (so paging preserves the facet state) with a
1356
+ // `page` param appended for any page past the first. Page 1 omits the
1357
+ // param so the canonical first page has one URL. Mirrors the edge
1358
+ // renderer's `_searchPageUrl` byte-for-byte.
1359
+ function _searchPageUrl(q, filters, page) {
1360
+ var base = _searchUrl(q, filters);
1361
+ if (page <= 1) return base;
1362
+ return base + (base.indexOf("?") === -1 ? "?" : "&") + "page=" + page;
1363
+ }
1364
+
1365
+ var SEARCH_PAGE_LINK =
1366
+ "<li class=\"search-pagination__page\"><a class=\"search-pagination__link\" href=\"{{href}}\"{{aria_current}}>{{n}}</a></li>\n";
1367
+
1368
+ // Numbered prev/next pagination for the result grid. No-JS + SEO-
1369
+ // friendly: every page is a real `?page=N` link carrying the active
1370
+ // query + facets, `rel="prev"/"next"` mark the sequence, and the current
1371
+ // page is `aria-current="page"`. Renders nothing for a single page (the
1372
+ // default when a caller threads no total — keeps the unpaginated render
1373
+ // byte-identical). `total` is the REAL match count (not the page slice
1374
+ // length); `page` is the 1-based current page. Mirrors the edge
1375
+ // renderer's `_renderSearchPagination` byte-for-byte.
1376
+ function _renderSearchPagination(q, filters, total, page, pageSize) {
1377
+ var size = pageSize > 0 ? pageSize : SEARCH_PAGE_SIZE;
1378
+ var totalPages = Math.max(1, Math.ceil(total / size));
1379
+ if (totalPages <= 1) return "";
1380
+ var cur = page < 1 ? 1 : (page > totalPages ? totalPages : page);
1381
+ var prev = cur > 1
1382
+ ? _render("<a class=\"search-pagination__link search-pagination__prev\" href=\"{{href}}\" rel=\"prev\">Previous</a>\n",
1383
+ { href: _searchPageUrl(q, filters, cur - 1) })
1384
+ : "<span class=\"search-pagination__link search-pagination__prev is-disabled\" aria-disabled=\"true\">Previous</span>\n";
1385
+ var next = cur < totalPages
1386
+ ? _render("<a class=\"search-pagination__link search-pagination__next\" href=\"{{href}}\" rel=\"next\">Next</a>\n",
1387
+ { href: _searchPageUrl(q, filters, cur + 1) })
1388
+ : "<span class=\"search-pagination__link search-pagination__next is-disabled\" aria-disabled=\"true\">Next</span>\n";
1389
+ var pagesHtml = "";
1390
+ for (var n = 1; n <= totalPages; n += 1) {
1391
+ pagesHtml += _render(SEARCH_PAGE_LINK, {
1392
+ href: _searchPageUrl(q, filters, n),
1393
+ aria_current: n === cur ? "RAW_ARIA" : "",
1394
+ n: String(n),
1395
+ }).replace("RAW_ARIA", n === cur ? " aria-current=\"page\"" : "");
1396
+ }
1397
+ return "<nav class=\"search-pagination\" aria-label=\"Search results pages\">\n" +
1398
+ prev +
1399
+ "<ol class=\"search-pagination__pages\">\n" + pagesHtml + "</ol>\n" +
1400
+ next +
1401
+ "</nav>\n";
1402
+ }
1403
+
1404
+ // Read the 1-based `?page=N` results page off a parsed URL. A missing,
1405
+ // non-integer, or sub-1 value reads as page 1 (the canonical first page);
1406
+ // the upper bound is clamped against the real page count by `_clampPage`
1407
+ // once the total is known. Defensive request-shape reader — returns the
1408
+ // safe default for any garbage rather than throwing.
1409
+ function _parsePageParam(url) {
1410
+ if (!url || !url.searchParams) return 1;
1411
+ var raw = url.searchParams.get("page");
1412
+ if (raw == null) return 1;
1413
+ if (!/^[0-9]{1,9}$/.test(raw)) return 1;
1414
+ var n = parseInt(raw, 10);
1415
+ return n >= 1 ? n : 1;
1416
+ }
1417
+
1418
+ // Clamp a 1-based page to `[1, lastPage]` for a given total + page size.
1419
+ // A `?page` past the end serves the last page (a stable, link-followable
1420
+ // result) rather than an empty grid; a zero-result query stays on page 1.
1421
+ function _clampPage(page, total, pageSize) {
1422
+ var size = pageSize > 0 ? pageSize : SEARCH_PAGE_SIZE;
1423
+ var lastPage = Math.max(1, Math.ceil(total / size));
1424
+ if (page < 1) return 1;
1425
+ if (page > lastPage) return lastPage;
1426
+ return page;
1427
+ }
1428
+
1334
1429
  // Hard caps mirroring `lib/search-facets.js`'s applied-filter
1335
1430
  // validators so a hostile / stale URL can't blow the in-memory facet
1336
1431
  // walk. Garbage values and unknown facet keys are dropped (not
@@ -1377,6 +1472,14 @@ function renderSearch(opts) {
1377
1472
  var facets = Array.isArray(opts.facets) ? opts.facets : [];
1378
1473
  var filters = (opts.filters && typeof opts.filters === "object") ? opts.filters : {};
1379
1474
  var hasFilters = Object.keys(filters).length > 0;
1475
+ // The result count copy + the page math run off the REAL match total —
1476
+ // the count of every product the query matched, not the length of the
1477
+ // page slice the renderer was handed. A caller that doesn't thread a
1478
+ // total (a unit test rendering one page directly) falls back to the page
1479
+ // length so the unpaginated render is unchanged.
1480
+ var pageSize = (typeof opts.page_size === "number" && opts.page_size > 0) ? opts.page_size : SEARCH_PAGE_SIZE;
1481
+ var totalCount = (typeof opts.total === "number" && opts.total >= 0) ? opts.total : products.length;
1482
+ var page = (typeof opts.page === "number" && opts.page >= 1) ? Math.floor(opts.page) : 1;
1380
1483
  var qTrim = opts.q.trim();
1381
1484
  var title, summary, emptyHeading, emptyCopy;
1382
1485
  if (qTrim.length === 0) {
@@ -1384,14 +1487,14 @@ function renderSearch(opts) {
1384
1487
  summary = "Use the search box in the header to look for a product by title, SKU, or description.";
1385
1488
  emptyHeading = "What are you looking for?";
1386
1489
  emptyCopy = "Type a query in the header search to find products by title, SKU, or description.";
1387
- } else if (products.length === 0) {
1490
+ } else if (totalCount === 0) {
1388
1491
  title = "No matches";
1389
1492
  summary = "Nothing in the catalog matched “" + qTrim + "”.";
1390
1493
  emptyHeading = "We don't carry that yet";
1391
1494
  emptyCopy = "Try a broader term, or browse every product on the home page.";
1392
1495
  } else {
1393
1496
  title = "“" + qTrim + "”";
1394
- summary = "Showing " + products.length + " match" + (products.length === 1 ? "" : "es") + " for your query.";
1497
+ summary = "Showing " + totalCount + " match" + (totalCount === 1 ? "" : "es") + " for your query.";
1395
1498
  }
1396
1499
 
1397
1500
  var correctionHtml = "";
@@ -1424,6 +1527,14 @@ function renderSearch(opts) {
1424
1527
  }).join("\n");
1425
1528
  resultsInner = "<section class=\"search-grid\"><div class=\"grid\">" + cards + "</div></section>";
1426
1529
  }
1530
+ // Pagination nav under the grid — `?page=N` links carrying the active
1531
+ // query + facets so paging never drops the filter state. Renders nothing
1532
+ // for a single page (the result count fits one page) or the empty / no-
1533
+ // query states. Driven by the real total, not the page slice length.
1534
+ var paginationHtml = (qTrim.length > 0 && totalCount > 0)
1535
+ ? _renderSearchPagination(opts.q, filters, totalCount, page, pageSize)
1536
+ : "";
1537
+ resultsInner += paginationHtml;
1427
1538
  var body;
1428
1539
  if (facetsHtml.length > 0) {
1429
1540
  body = header + correctionHtml + chipsHtml +
@@ -2689,6 +2800,7 @@ function renderCollectionList(opts) {
2689
2800
  return _wrap({
2690
2801
  title: "Collections", shop_name: opts.shop_name || "blamejs.shop",
2691
2802
  cart_count: opts.cart_count == null ? 0 : opts.cart_count, theme_css: opts.theme_css, body: body,
2803
+ canonical_url: opts.canonical_url, og_url: opts.og_url,
2692
2804
  });
2693
2805
  }
2694
2806
 
@@ -2719,6 +2831,7 @@ function renderCollection(opts) {
2719
2831
  return _wrap({
2720
2832
  title: col.title, shop_name: opts.shop_name || "blamejs.shop",
2721
2833
  cart_count: opts.cart_count == null ? 0 : opts.cart_count, theme_css: opts.theme_css, body: body,
2834
+ canonical_url: opts.canonical_url, og_url: opts.og_url,
2722
2835
  });
2723
2836
  }
2724
2837
 
@@ -4969,6 +5082,11 @@ function renderCart(opts) {
4969
5082
  shop_name: shopName,
4970
5083
  cart_count: lines.length,
4971
5084
  theme_css: opts.theme_css,
5085
+ // The cart is session-scoped and robots.txt-disallowed — keep it out
5086
+ // of the index with a noindex meta (matches the edge x-robots-tag on
5087
+ // the guest cart render), so a directly-crawled cart URL is self-
5088
+ // describing rather than relying on robots.txt alone.
5089
+ robots: "noindex",
4972
5090
  body: body,
4973
5091
  }, _currencyWrapOpts(opts)));
4974
5092
  }
@@ -5808,6 +5926,10 @@ function renderAccount(opts) {
5808
5926
  shop_name: opts.shop_name || "blamejs.shop",
5809
5927
  cart_count: opts.cart_count,
5810
5928
  theme_css: opts.theme_css,
5929
+ // The account dashboard is per-customer + robots.txt-disallowed — keep
5930
+ // it out of the index with a noindex meta rather than relying on the
5931
+ // crawl policy alone.
5932
+ robots: "noindex",
5811
5933
  body: body,
5812
5934
  });
5813
5935
  }
@@ -7396,10 +7518,17 @@ function mount(router, deps) {
7396
7518
  // otherwise round-trip through the LIKE escape function.
7397
7519
  if (q.length > 200) q = q.slice(0, 200);
7398
7520
 
7521
+ // 1-based results page from `?page=N`. A missing / non-numeric /
7522
+ // out-of-low-range value reads as page 1; the upper bound is clamped
7523
+ // to the real page count once the total is known (below) so a `?page`
7524
+ // past the end serves the last page rather than an empty grid.
7525
+ var page = _parsePageParam(url);
7526
+
7399
7527
  var products = [];
7400
7528
  var facetGroups = [];
7401
7529
  var filters = {};
7402
7530
  var correctedQ = "";
7531
+ var totalCount = 0;
7403
7532
 
7404
7533
  if (q.trim().length > 0) {
7405
7534
  // Synonym + typo rewrite expands the typed query into the
@@ -7434,11 +7563,28 @@ function mount(router, deps) {
7434
7563
  var facetDefs = await sfInstance.listFacets({});
7435
7564
  filters = _parseSearchFilters(url, facetDefs);
7436
7565
  facetGroups = await sfInstance.getFacets({ query: q, applied_filters: filters });
7437
- var preview = await sfInstance.previewQuery({ query: q, filters: filters, sample: 24 });
7566
+ // First pass reads the REAL total so the result-count copy is
7567
+ // honest and the page can be clamped to the last page. The second
7568
+ // pass windows that page (offset/sample) over the full passing set
7569
+ // — pages past the first are reachable, not discarded at 24.
7570
+ var totalPreview = await sfInstance.previewQuery({ query: q, filters: filters, sample: 0 });
7571
+ totalCount = totalPreview.total;
7572
+ page = _clampPage(page, totalCount, SEARCH_PAGE_SIZE);
7573
+ var preview = await sfInstance.previewQuery({
7574
+ query: q,
7575
+ filters: filters,
7576
+ sample: SEARCH_PAGE_SIZE,
7577
+ offset: (page - 1) * SEARCH_PAGE_SIZE,
7578
+ });
7438
7579
  products = preview.sample;
7439
7580
  } else {
7440
- // No facet dep — flat search over the expanded terms.
7441
- products = (await _facetableUniverse(terms)).slice(0, 24);
7581
+ // No facet dep — flat search over the expanded terms. The full
7582
+ // matched universe gives the honest total; the page is the
7583
+ // windowed slice (the same page size the faceted path uses).
7584
+ var flatUniverse = await _facetableUniverse(terms);
7585
+ totalCount = flatUniverse.length;
7586
+ page = _clampPage(page, totalCount, SEARCH_PAGE_SIZE);
7587
+ products = flatUniverse.slice((page - 1) * SEARCH_PAGE_SIZE, page * SEARCH_PAGE_SIZE);
7442
7588
  }
7443
7589
  }
7444
7590
 
@@ -7450,6 +7596,9 @@ function mount(router, deps) {
7450
7596
  facets: facetGroups,
7451
7597
  filters: filters,
7452
7598
  corrected_query: correctedQ,
7599
+ total: totalCount,
7600
+ page: page,
7601
+ page_size: SEARCH_PAGE_SIZE,
7453
7602
  shop_name: shopName,
7454
7603
  cart_count: cartCount,
7455
7604
  }, _requestUrls(req), ccy)));
@@ -7809,9 +7958,9 @@ function mount(router, deps) {
7809
7958
  router.get("/collections", async function (req, res) {
7810
7959
  var cols = await deps.collections.list({ active_only: true });
7811
7960
  var cartCount = await _cartCountForReq(req);
7812
- _send(res, 200, renderCollectionList({
7961
+ _send(res, 200, renderCollectionList(Object.assign({
7813
7962
  collections: cols, shop_name: shopName, cart_count: cartCount, asset_prefix: _cardAssetPrefix,
7814
- }));
7963
+ }, _requestUrls(req))));
7815
7964
  });
7816
7965
 
7817
7966
  router.get("/collections/:slug", async function (req, res) {
@@ -7835,9 +7984,9 @@ function mount(router, deps) {
7835
7984
  if (card) products.push(card);
7836
7985
  }
7837
7986
  var cartCount = await _cartCountForReq(req);
7838
- _send(res, 200, renderCollection({
7987
+ _send(res, 200, renderCollection(Object.assign({
7839
7988
  collection: col, products: products, shop_name: shopName, cart_count: cartCount,
7840
- }));
7989
+ }, _requestUrls(req))));
7841
7990
  });
7842
7991
  }
7843
7992
 
@@ -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.14.7",
7
- "tag": "v0.14.7",
6
+ "version": "0.14.8",
7
+ "tag": "v0.14.8",
8
8
  "license": "Apache-2.0",
9
9
  "author": "blamejs contributors",
10
10
  "source": "https://github.com/blamejs/blamejs",