@blamejs/blamejs-shop 0.0.120 → 0.0.122

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 (35) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +3 -1
  3. package/SECURITY.md +9 -0
  4. package/lib/admin.js +51 -0
  5. package/lib/order.js +23 -0
  6. package/lib/reviews.js +29 -0
  7. package/lib/storefront.js +359 -9
  8. package/lib/vendor/MANIFEST.json +2 -2
  9. package/lib/vendor/blamejs/CHANGELOG.md +14 -0
  10. package/lib/vendor/blamejs/README.md +2 -0
  11. package/lib/vendor/blamejs/SECURITY.md +1 -0
  12. package/lib/vendor/blamejs/api-snapshot.json +195 -2
  13. package/lib/vendor/blamejs/index.js +2 -0
  14. package/lib/vendor/blamejs/lib/ai-capability.js +482 -0
  15. package/lib/vendor/blamejs/lib/ai-disclosure.js +107 -0
  16. package/lib/vendor/blamejs/lib/ai-quota.js +526 -0
  17. package/lib/vendor/blamejs/lib/backup/index.js +210 -1
  18. package/lib/vendor/blamejs/lib/compliance.js +48 -1
  19. package/lib/vendor/blamejs/package.json +1 -1
  20. package/lib/vendor/blamejs/release-notes/v0.12.22.json +18 -0
  21. package/lib/vendor/blamejs/release-notes/v0.12.23.json +18 -0
  22. package/lib/vendor/blamejs/release-notes/v0.12.24.json +18 -0
  23. package/lib/vendor/blamejs/release-notes/v0.12.25.json +18 -0
  24. package/lib/vendor/blamejs/release-notes/v0.12.26.json +30 -0
  25. package/lib/vendor/blamejs/release-notes/v0.12.27.json +26 -0
  26. package/lib/vendor/blamejs/release-notes/v0.12.28.json +26 -0
  27. package/lib/vendor/blamejs/test/layer-0-primitives/ai-capability.test.js +228 -0
  28. package/lib/vendor/blamejs/test/layer-0-primitives/ai-disclosure-apply-all.test.js +126 -0
  29. package/lib/vendor/blamejs/test/layer-0-primitives/ai-quota.test.js +264 -0
  30. package/lib/vendor/blamejs/test/layer-0-primitives/backup-clone-bundle.test.js +178 -0
  31. package/lib/vendor/blamejs/test/layer-0-primitives/backup-find-bundles.test.js +104 -0
  32. package/lib/vendor/blamejs/test/layer-0-primitives/backup-rewrap-all.test.js +168 -0
  33. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +48 -0
  34. package/lib/vendor/blamejs/test/layer-0-primitives/compliance-eu-ai-act-posture.test.js +93 -0
  35. 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.0.x
10
10
 
11
+ - v0.0.122 (2026-05-24) — **Product reviews on the storefront — verified-buyer submission, operator moderation, and rich-snippet ratings.** The product page now shows customer reviews: an average rating, a per-star distribution, and the published review list, with `AggregateRating` structured data so star ratings can surface in search results. Submission is gated to signed-in customers who have actually purchased the product — the route confirms a completed order for that product before accepting a review, and re-checks on submit. Reviews land in a pending state; operators publish or reject them through new bearer-token admin endpoints. Display and structured data are identical whether the page is served at the edge or from the container. **Added:** *Reviews on the product page* — The PDP renders an average rating, a per-star distribution bar chart, and the published reviews (newest first, verified-buyer badge, date). Products with no reviews show an invite to be the first. Rendered server-side on both the edge and container paths. · *Verified-buyer review submission* — `GET /products/:slug/review` shows the review form to a signed-in customer who has purchased the product; everyone else is redirected to sign in or told only verified buyers can review. `POST /products/:slug/review` re-checks the purchase before accepting the review, so a direct POST can't bypass the gate. Submitted reviews start pending. · *Operator review moderation* — `GET /admin/reviews?status=pending` lists the moderation queue across all products; `GET /admin/reviews/:id` reads one; `POST /admin/reviews/:id/publish` and `POST /admin/reviews/:id/reject` move it. Bearer-token-gated like the rest of the admin API. Pending and rejected reviews never appear on the storefront. · *`AggregateRating` structured data* — The product page emits Schema.org `AggregateRating` (rating value + review count) nested in the existing `Product` JSON-LD when published reviews exist, so eligible products can show star ratings in search results. Omitted entirely at zero reviews to stay valid. The container render path now emits the full `Product` + `AggregateOffer` + `BreadcrumbList` JSON-LD that the edge already did. · *`order.hasPurchasedProduct(customerId, productId)`* — Existence check — true when the customer has an order line for any variant of the product in an order that reached `paid` or later (excludes `pending` and `cancelled`). Backs the review purchase gate. · *`reviews.listByStatus(status, opts)`* — Lists reviews across all products by status, newest first, with the same opaque tuple cursor as `listForProduct`. Backs the admin moderation queue.
12
+
13
+ - v0.0.121 (2026-05-24) — **Codebase-patterns detector blocks SHA3 primitives in `worker/` — prevents the regression that broke newsletter signup.** v0.0.120 fixed the newsletter signup by routing the POST to the container instead of computing `b.crypto.namespaceHash` (SHA3-512) at the edge — Workers' `nodejs_compat` doesn't expose SHA3-family digests. New `worker-uses-sha3-primitive` codebase-patterns detector flags any `b.crypto.{sha3Hash, hmacSha3, namespaceHash, shake256, shake512, hkdfSha3}(...)` call under `worker/` so the next operator can't re-introduce the substrate-mismatch bug. Catalog grows 121 → 122. Detector targets the exact SHA3-family primitive names rather than a general regex match — Worker code that legitimately uses `b.crypto.toBase64Url`, `b.crypto.timingSafeEqual`, `b.crypto.generateBytes`, `b.crypto.hmacSha256` (the Stripe webhook augment), etc. is unaffected. **Added:** *`worker-uses-sha3-primitive` detector* — Flags `b.crypto.sha3Hash(...)`, `hmacSha3(...)`, `namespaceHash(...)`, `shake256(...)`, `shake512(...)`, and `hkdfSha3(...)` in any file under `worker/`. Catches the substrate-mismatch class that broke v0.0.92's edge newsletter handler (live for 28 versions before being routed back to the container in v0.0.120). When edge code needs a stable hash: route to the container OR use `b.crypto.hmacSha256` IFF both sides read with the same SHA-256 path. Never have one substrate write SHA3 and another read SHA-256 — silent divergence breaks cross-substrate lookups (e.g. unsubscribe-by-email_hash).
14
+
11
15
  - v0.0.120 (2026-05-24) — **Route POST /newsletter through the container — Workers `nodejs_compat` can't compute SHA3-512 (`b.crypto.namespaceHash`).** The v0.0.92 edge-served newsletter handler was 500-ing every submission with `Error: Digest method not supported`. Root cause surfaced via the temporary `X-Newsletter-Diag` header (added in v0.0.119): Cloudflare Workers' `nodejs_compat` runtime exposes `node:crypto` but the supported digest set is a subset of full Node — `createHash("sha3-512")` isn't in it. Working around with a Web-Crypto-API SHA-256 fallback would silently diverge the Worker's `email_hash` values from the container's SHA3-512 values, breaking the unsubscribe lookup (different hash → different row → silent unsubscribe failure). The edge handler was the wrong substrate for this primitive. The POST falls through to `_forwardToContainer` so the framework's SHA3-512 path runs server-side and the `email_hash` column stays consistent across reads. The ~200ms container hop on signup submit is paid back by a working unsubscribe flow. The dead `_edgeNewsletter` function + its `renderNewsletterThanks` / `renderNewsletterError` imports + the diagnostic `X-Newsletter-Diag` header are all removed in the same patch. **Fixed:** *Newsletter signup now uses the framework's SHA3-512 hash via the container* — Removed: `_edgeNewsletter` handler (123 lines), `renderNewsletterThanks` / `renderNewsletterError` imports in `worker/index.js`, the `X-Newsletter-Diag` diagnostic response header (its job is done). The dispatch comment above the now-removed `if (pathname === "/newsletter" && ...)` block documents the substrate decision + the SHA3-512 / Workers-runtime constraint so the next operator doesn't re-introduce the edge handler.
12
16
 
13
17
  - v0.0.119 (2026-05-24) — **Newsletter handler surfaces `X-Newsletter-Diag` header so the live 500 root cause is visible without wrangler tail.** v0.0.118 added missing-table resilience to the newsletter handler, but every submission still 500s — the underlying D1 error isn't matching the `"no such table" / "no such column"` resilience-trigger regex. Without wrangler tail access during the diagnostic loop, the actual error message stays invisible. This patch adds an `X-Newsletter-Diag: <ErrorClass>: <first 96 chars of message>` response header (redacted through `_redact` so secrets never leak) on the 500 path. The body remains the canonical operator-facing error page; the header surfaces the failing step. Operator probes `curl -sI` and reads the diag value to pinpoint the live root cause (likely a D1 binding shape mismatch / migration gap / module-load issue), then ships a targeted fix in the next patch. The diag header is intentionally short-lived — removable once the cause is identified and permanently fixed. **Added:** *`X-Newsletter-Diag` response header on the 500 path* — `_edgeNewsletter`'s outer catch now sets `X-Newsletter-Diag: <constructor.name>: <message[:96]>` (redacted through the framework's `b.redact.redact` pipeline). The body still renders the canonical 500 page; the header gives the operator a diagnostic surface without needing wrangler tail. Removable in a follow-up once the live root cause is identified and the underlying fix lands.
package/README.md CHANGED
@@ -63,9 +63,10 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
63
63
  | **`lib/email.js`** | Transactional templates — order receipt, ship notification, refund confirmation. Strict `{{var}}` renderer with HTML escape + refusal of unknown / unused placeholders. Composed on `b.mail` (DKIM/SPF/DMARC/BIMI upstream). |
64
64
  | **`lib/storefront.js`** | Server-rendered HTML — utility bar + sticky header + dark hero with code-preview card + primitives marquee + featured-product callout + collections grid + framework feature band + designed catalog grid + newsletter band + four-column footer. Designed surfaces also for PDP, cart, checkout, pay, order, account login / register / dashboard, search results, `/admin` API landing, 404. Image-bearing cards on the home + search grids pull from `catalog.media`. The default theme stylesheet is external (R2-served `themes/default/assets/css/main.css`) and CSP-compliant — operators override by uploading a replacement at the same key, by passing `opts.theme_css` to renderers, or by registering a named theme through the `theme` primitive. |
65
65
  | **`lib/customers.js`** | Customer accounts — passkey-only (WebAuthn). Email is stored hash-only (`b.crypto.namespaceHash` namespace `customer-email`); the raw address never lands in D1. Passkey credentials carry CBOR-encoded public keys, transport hints, and SHA3-512-fingerprinted attestation. Account routes (`/account/login`, `/account/register`, `/account`) ship as designed cards on the storefront. |
66
+ | **`lib/reviews.js`** | Operator-moderated product ratings. Submission requires a signed-in customer **and** a verified purchase — `/products/:slug/review` confirms a completed order for the product (via `order.hasPurchasedProduct`) before accepting, re-checked on POST; reviews land `pending`. Author identity is hash-only (`b.crypto.namespaceHash`); the raw email is never stored. The PDP renders the average, per-star distribution, and published reviews with `AggregateRating` JSON-LD. `/admin/reviews` is the moderation queue (`listByStatus` → publish / reject). |
66
67
  | **`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. |
67
68
  | **`lib/newsletter.js`** | Operator-collected email broadcast list — `signup({ email, source })` composes `b.guardEmail` for shape validation, `b.crypto.namespaceHash` for the dedup key, and `INSERT OR IGNORE` for idempotency. Storefront POST `/newsletter` route renders a designed thank-you card with separate copy for the `new` vs `dedup` branches. |
68
- | **`lib/admin.js`** | Bearer-token-gated CRUD over catalog + orders + refunds + bulk CSV import + subscription plans. Token compared via `b.crypto.timingSafeEqual`. Errors as RFC 9457 problem documents via `b.problemDetails`. Audit emission on every mutation. |
69
+ | **`lib/admin.js`** | Bearer-token-gated CRUD over catalog + orders + refunds + bulk CSV import + subscription plans + review moderation. Token compared via `b.crypto.timingSafeEqual`. Errors as RFC 9457 problem documents via `b.problemDetails`. Audit emission on every mutation. |
69
70
  | **`lib/catalog-import.js`** | Bulk CSV import — `POST /admin/catalog/import` accepts a `text/csv` body, parses via `b.csv`, content-safety-filters every cell through `b.guardCsv` (formula-injection / bidi / control / dangerous-function denylist), validates exact header order, de-dupes rows by `product_slug`, returns per-row errors without aborting. Default 1 MiB / 10000 rows caps. |
70
71
  | **`lib/theme.js`** | File-backed templates with fallback chain. Operators register a named theme under `<themesDir>/<name>/*.html` and the storefront dispatches every renderer through it. `assetUrl(path)` resolves to `/assets/themes/<name>/<path>`. The shipped `default` theme is the fallback. |
71
72
 
@@ -80,6 +81,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
80
81
  - `migrations-d1/0008_inventory_thresholds.sql` — low-stock alert thresholds + alerts
81
82
  - `migrations-d1/0009_subscriptions.sql` — subscription_plans + subscriptions (Stripe-mirrored)
82
83
  - `migrations-d1/0010_newsletter_signups.sql` — email signups with hash-based dedup
84
+ - `migrations-d1/0011_reviews.sql` — operator-moderated product reviews (hash-only author identity)
83
85
 
84
86
  ### Demo seed
85
87
 
package/SECURITY.md CHANGED
@@ -139,3 +139,12 @@ node -e "
139
139
  framework's at-rest key material. Operators MUST rotate the
140
140
  passphrase via `b.vault.rotate` after any container image rebuild
141
141
  that changed a vendored crypto dependency.
142
+ - **Reviews are purchase-gated and operator-moderated.** A review can
143
+ only be submitted by a signed-in customer who has a completed order
144
+ for that product; the route confirms the purchase before accepting
145
+ and re-checks it on the POST, so a direct request can't plant a
146
+ review for a product the account never bought. Submissions land in a
147
+ `pending` state and never reach the storefront until an operator
148
+ publishes them through `/admin/reviews`. The author identity is
149
+ stored hash-only (`b.crypto.namespaceHash`) — the raw email is never
150
+ persisted.
package/lib/admin.js CHANGED
@@ -162,6 +162,7 @@ function mount(router, deps) {
162
162
  var r2 = deps.r2_bridge || null; // media-upload endpoint disabled when absent
163
163
  var assetPrefix = typeof deps.asset_prefix === "string" ? deps.asset_prefix : "/assets/";
164
164
  var catalogImport = deps.catalogImport || null; // bulk-import route disabled when absent
165
+ var reviews = deps.reviews || null; // moderation endpoints disabled when absent
165
166
 
166
167
  try { _b().audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
167
168
 
@@ -529,6 +530,56 @@ function mount(router, deps) {
529
530
  }));
530
531
  }
531
532
 
533
+ // ---- reviews (moderation) -------------------------------------------
534
+
535
+ // Operator-side review moderation. The queue lists reviews across all
536
+ // products in one status (defaults to `pending`); publish / reject
537
+ // drive the same transitions the storefront submit path leaves in
538
+ // `pending`. Endpoints are omitted entirely when no reviews primitive
539
+ // is wired.
540
+ if (reviews) {
541
+ router.get("/admin/reviews", R(async function (req, res) {
542
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
543
+ var status = (url && url.searchParams.get("status")) || "pending";
544
+ var cursor = url && url.searchParams.get("cursor");
545
+ var limitS = url && url.searchParams.get("limit");
546
+ var limit = limitS == null ? undefined : parseInt(limitS, 10);
547
+ var page = await reviews.listByStatus(status, { cursor: cursor || undefined, limit: limit });
548
+ _json(res, 200, page);
549
+ }));
550
+
551
+ router.get("/admin/reviews/:id", R(async function (req, res) {
552
+ var rev = await reviews.get(req.params.id);
553
+ if (!rev) return _problem(res, 404, "review-not-found");
554
+ _json(res, 200, rev);
555
+ }));
556
+
557
+ router.post("/admin/reviews/:id/publish", W("review.publish", async function (req, res) {
558
+ var rev;
559
+ try {
560
+ rev = await reviews.publish(req.params.id);
561
+ } catch (e) {
562
+ if (e && e.code === "REVIEW_NOT_FOUND") return _problem(res, 404, "review-not-found");
563
+ throw e;
564
+ }
565
+ _json(res, 200, rev);
566
+ return rev;
567
+ }));
568
+
569
+ router.post("/admin/reviews/:id/reject", W("review.reject", async function (req, res) {
570
+ var body = req.body || {};
571
+ var rev;
572
+ try {
573
+ rev = await reviews.reject(req.params.id, body.reason);
574
+ } catch (e) {
575
+ if (e && e.code === "REVIEW_NOT_FOUND") return _problem(res, 404, "review-not-found");
576
+ throw e;
577
+ }
578
+ _json(res, 200, rev);
579
+ return rev;
580
+ }));
581
+ }
582
+
532
583
  // ---- config ---------------------------------------------------------
533
584
 
534
585
  var config = deps.config || null;
package/lib/order.js CHANGED
@@ -368,6 +368,29 @@ function create(opts) {
368
368
  if (r.rowCount === 0) return null;
369
369
  return await this.get(orderId);
370
370
  },
371
+
372
+ // Has this customer purchased this product? True iff an order
373
+ // line for any variant of the product sits in an order owned by
374
+ // the customer whose status is a real purchase — anything except
375
+ // pending (never captured) or cancelled (reversed before
376
+ // fulfillment). paid|fulfilling|shipped|delivered|refunded all
377
+ // count as purchased. The review gate composes this so only
378
+ // verified buyers can leave a review. Existence check, one round
379
+ // trip.
380
+ hasPurchasedProduct: async function (customerId, productId) {
381
+ _uuid(customerId, "customer id");
382
+ _uuid(productId, "product id");
383
+ var rows = (await query(
384
+ "SELECT 1 FROM order_lines ol " +
385
+ "JOIN orders o ON o.id = ol.order_id " +
386
+ "JOIN variants v ON v.id = ol.variant_id " +
387
+ "WHERE o.customer_id = ?1 AND v.product_id = ?2 " +
388
+ "AND o.status NOT IN ('pending','cancelled') " +
389
+ "LIMIT 1",
390
+ [customerId, productId],
391
+ )).rows;
392
+ return rows.length > 0;
393
+ },
371
394
  };
372
395
  }
373
396
 
package/lib/reviews.js CHANGED
@@ -349,6 +349,35 @@ function create(opts) {
349
349
  return { rows: r.rows, next_cursor: _encodeNext(r.rows, REVIEW_ORDER_KEY, limit) };
350
350
  },
351
351
 
352
+ // Cross-product moderation listing — every review in one status
353
+ // regardless of product. The moderation queue needs all `pending`
354
+ // rows globally, which listForProduct (per-product) can't serve.
355
+ // status is required here (the queue is always status-scoped). Same
356
+ // (created_at DESC, id DESC) tuple cursor as listForProduct.
357
+ listByStatus: async function (status, listOpts) {
358
+ status = _statusFilter(status);
359
+ if (status === undefined) {
360
+ throw new TypeError("reviews.listByStatus: status is required (one of " + ALLOWED_STATUSES.join(", ") + ")");
361
+ }
362
+ listOpts = listOpts || {};
363
+ var limit = _limit(listOpts.limit);
364
+ var cursorVals = _decodeCursor(listOpts.cursor, REVIEW_ORDER_KEY, "listByStatus");
365
+
366
+ var sql, params;
367
+ if (cursorVals) {
368
+ sql = "SELECT * FROM reviews WHERE status = ?1 " +
369
+ "AND (created_at < ?2 OR (created_at = ?2 AND id < ?3)) " +
370
+ "ORDER BY created_at DESC, id DESC LIMIT ?4";
371
+ params = [status, cursorVals[0], cursorVals[1], limit];
372
+ } else {
373
+ sql = "SELECT * FROM reviews WHERE status = ?1 " +
374
+ "ORDER BY created_at DESC, id DESC LIMIT ?2";
375
+ params = [status, limit];
376
+ }
377
+ var r = await query(sql, params);
378
+ return { rows: r.rows, next_cursor: _encodeNext(r.rows, REVIEW_ORDER_KEY, limit) };
379
+ },
380
+
352
381
  // Aggregate ratings — published-only by construction; counts
353
382
  // every star bucket so the storefront can draw the distribution
354
383
  // bar chart without a second query.
package/lib/storefront.js CHANGED
@@ -678,6 +678,7 @@ var PRODUCT_PAGE =
678
678
  " </div>\n" +
679
679
  " </div>\n" +
680
680
  " </div>\n" +
681
+ " RAW_REVIEWS_PLACEHOLDER\n" +
681
682
  "</section>\n";
682
683
 
683
684
  // PDP gallery markup — composed once per render call from the
@@ -720,6 +721,193 @@ function _buildPdpGallery(product, media, assetPrefix) {
720
721
  return heroImg + "<ul class=\"pdp__thumbs\" aria-hidden=\"true\">" + thumbs.join("") + "</ul>";
721
722
  }
722
723
 
724
+ // Accessible star glyph row — the precise figure rides in a visually-
725
+ // hidden label so a screen reader announces "4.3 out of 5 stars" while
726
+ // sighted users see the rounded glyph fill. Mirrors the edge renderer
727
+ // (`worker/render/product.js`) so both paths emit identical markup.
728
+ function _reviewStars(value, label) {
729
+ var esc = _b().template.escapeHtml;
730
+ var filled = Math.round(value);
731
+ if (filled < 0) filled = 0;
732
+ if (filled > 5) filled = 5;
733
+ var glyphs = "";
734
+ for (var i = 1; i <= 5; i += 1) {
735
+ glyphs += "<span class=\"star" + (i <= filled ? " star--on" : "") + "\">" +
736
+ (i <= filled ? "★" : "☆") + "</span>";
737
+ }
738
+ return "<span class=\"stars\" aria-hidden=\"true\">" + glyphs + "</span>" +
739
+ "<span class=\"sr-only\">" + esc(label) + "</span>";
740
+ }
741
+
742
+ function _reviewDate(ts) {
743
+ var n = Number(ts);
744
+ if (!Number.isFinite(n) || n <= 0) return "";
745
+ return new Date(n).toISOString().slice(0, 10);
746
+ }
747
+
748
+ // Builds the PDP reviews block from the published aggregate + list.
749
+ // Renders the "no reviews yet" empty state when the product has none;
750
+ // `ctaHtml` is the operator/customer call-to-action (a "Write a review"
751
+ // link, or "Sign in to review", resolved by the route). Mirrors the
752
+ // edge renderer byte-for-byte so the two render paths stay in sync.
753
+ function _buildReviews(summary, reviews, ctaHtml) {
754
+ var esc = _b().template.escapeHtml;
755
+ summary = summary || { count: 0, avg_rating: 0, distribution: { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 } };
756
+ reviews = reviews || [];
757
+ var count = Number(summary.count) || 0;
758
+
759
+ var head;
760
+ if (count > 0) {
761
+ var avg = Number(summary.avg_rating) || 0;
762
+ var avgStr = avg.toFixed(1);
763
+ var dist = summary.distribution || {};
764
+ var bars = "";
765
+ for (var s = 5; s >= 1; s -= 1) {
766
+ var n = Number(dist[s]) || 0;
767
+ var pct = count > 0 ? Math.round((n / count) * 100) : 0;
768
+ bars +=
769
+ "<li class=\"rating-bar\">" +
770
+ "<span class=\"rating-bar__label\">" + s + " star</span>" +
771
+ "<span class=\"rating-bar__track\"><span class=\"rating-bar__fill\" style=\"width:" + pct + "%\"></span></span>" +
772
+ "<span class=\"rating-bar__count\">" + n + "</span>" +
773
+ "</li>";
774
+ }
775
+ head =
776
+ "<div class=\"reviews__summary\">" +
777
+ "<div class=\"reviews__average\">" +
778
+ "<span class=\"reviews__average-num\">" + esc(avgStr) + "</span>" +
779
+ _reviewStars(avg, avgStr + " out of 5 stars") +
780
+ "<span class=\"reviews__count\">" + count + (count === 1 ? " review" : " reviews") + "</span>" +
781
+ "</div>" +
782
+ "<ul class=\"reviews__distribution\">" + bars + "</ul>" +
783
+ "</div>";
784
+ } else {
785
+ head = "<p class=\"reviews__empty\">No reviews yet. Be the first to review this product.</p>";
786
+ }
787
+
788
+ var list = "";
789
+ for (var i = 0; i < reviews.length; i += 1) {
790
+ var r = reviews[i];
791
+ var rating = Number(r.rating) || 0;
792
+ var verified = Number(r.verified_purchase) === 1
793
+ ? "<span class=\"review__verified\">Verified buyer</span>"
794
+ : "";
795
+ var date = _reviewDate(r.created_at);
796
+ var bodyHtml = r.body
797
+ ? "<p class=\"review__body\">" + esc(String(r.body)) + "</p>"
798
+ : "";
799
+ list +=
800
+ "<li class=\"review\">" +
801
+ "<div class=\"review__head\">" +
802
+ _reviewStars(rating, rating + " out of 5 stars") +
803
+ "<h3 class=\"review__title\">" + esc(String(r.title || "")) + "</h3>" +
804
+ "</div>" +
805
+ "<div class=\"review__meta\">" + verified +
806
+ (date ? "<time class=\"review__date\" datetime=\"" + esc(date) + "\">" + esc(date) + "</time>" : "") +
807
+ "</div>" +
808
+ bodyHtml +
809
+ "</li>";
810
+ }
811
+ var listHtml = list ? "<ul class=\"reviews__list\">" + list + "</ul>" : "";
812
+
813
+ return "<section class=\"reviews\" aria-labelledby=\"reviews-title\">" +
814
+ "<h2 id=\"reviews-title\" class=\"reviews__heading\">Customer reviews</h2>" +
815
+ head +
816
+ listHtml +
817
+ (ctaHtml || "") +
818
+ "</section>";
819
+ }
820
+
821
+ var REVIEW_FORM_PAGE =
822
+ "<section class=\"review-form-page\">\n" +
823
+ " <nav class=\"breadcrumb\" aria-label=\"Breadcrumb\">\n" +
824
+ " <ol>\n" +
825
+ " <li><a href=\"/\">Shop</a></li>\n" +
826
+ " <li><a href=\"/products/{{slug}}\">{{title}}</a></li>\n" +
827
+ " <li aria-current=\"page\">Write a review</li>\n" +
828
+ " </ol>\n" +
829
+ " </nav>\n" +
830
+ " <h1 class=\"review-form-page__title\">Review {{title}}</h1>\n" +
831
+ " RAW_NOTICE_PLACEHOLDER\n" +
832
+ " <form class=\"review-form\" method=\"post\" action=\"/products/{{slug}}/review\">\n" +
833
+ " <fieldset class=\"review-form__rating\">\n" +
834
+ " <legend>Your rating</legend>\n" +
835
+ " RAW_STARS_PLACEHOLDER\n" +
836
+ " </fieldset>\n" +
837
+ " <label class=\"form-field\">\n" +
838
+ " <span class=\"form-field__label\">Title</span>\n" +
839
+ " <input type=\"text\" name=\"title\" maxlength=\"120\" required autocomplete=\"off\">\n" +
840
+ " </label>\n" +
841
+ " <label class=\"form-field\">\n" +
842
+ " <span class=\"form-field__label\">Your review</span>\n" +
843
+ " <textarea name=\"body\" maxlength=\"4000\" rows=\"6\"></textarea>\n" +
844
+ " </label>\n" +
845
+ " <button type=\"submit\" class=\"btn-primary\">Submit review</button>\n" +
846
+ " </form>\n" +
847
+ "</section>\n";
848
+
849
+ // Auth-gated review form. `opts.product` carries { title, slug };
850
+ // `opts.notice` is an optional error string rendered above the form
851
+ // (e.g. a validation rejection bounced back from POST).
852
+ function renderReviewForm(opts) {
853
+ var esc = _b().template.escapeHtml;
854
+ var slug = opts.product.slug;
855
+ var stars = "";
856
+ for (var rv = 5; rv >= 1; rv -= 1) {
857
+ stars +=
858
+ "<label class=\"star-radio\">" +
859
+ "<input type=\"radio\" name=\"rating\" value=\"" + rv + "\" required>" +
860
+ "<span>" + rv + (rv === 1 ? " star" : " stars") + "</span>" +
861
+ "</label>";
862
+ }
863
+ var notice = opts.notice
864
+ ? "<p class=\"form-notice form-notice--error\" role=\"alert\">" + esc(String(opts.notice)) + "</p>"
865
+ : "";
866
+ var body = _render(REVIEW_FORM_PAGE, {
867
+ title: opts.product.title,
868
+ slug: slug,
869
+ })
870
+ .replace("RAW_NOTICE_PLACEHOLDER", notice)
871
+ .replace("RAW_STARS_PLACEHOLDER", stars);
872
+ return _wrap({
873
+ title: "Review " + opts.product.title,
874
+ shop_name: opts.shop_name || "blamejs.shop",
875
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
876
+ theme_css: opts.theme_css,
877
+ body: body,
878
+ });
879
+ }
880
+
881
+ // Generic single-message page for the review flow (purchase-gate
882
+ // refusal, submission thank-you). `cta` is an optional { href, label }.
883
+ function _reviewMessagePage(opts, heading, message, cta) {
884
+ var esc = _b().template.escapeHtml;
885
+ var ctaHtml = cta
886
+ ? "<a class=\"btn-primary\" href=\"" + esc(cta.href) + "\">" + esc(cta.label) + "</a>"
887
+ : "";
888
+ var body =
889
+ "<section class=\"review-message\">" +
890
+ "<h1 class=\"review-message__title\">" + esc(heading) + "</h1>" +
891
+ "<p class=\"review-message__lede\">" + esc(message) + "</p>" +
892
+ ctaHtml +
893
+ "</section>";
894
+ return _wrap({
895
+ title: heading,
896
+ shop_name: opts.shop_name || "blamejs.shop",
897
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
898
+ theme_css: opts.theme_css,
899
+ body: body,
900
+ });
901
+ }
902
+
903
+ // Schema.org JSON-LD block. JSON.stringify covers the standard escapes;
904
+ // the `</` → `<\/` rewrite neutralises any literal `</script>` in a
905
+ // value. Mirrors the edge renderer's `jsonLdScript`.
906
+ function _jsonLdScript(data) {
907
+ var serialised = JSON.stringify(data).replace(/<\/(?=script>)/gi, "<\\/");
908
+ return "<script type=\"application/ld+json\">" + serialised + "</script>";
909
+ }
910
+
723
911
  function renderProduct(opts) {
724
912
  if (!opts || !opts.product) throw new TypeError("storefront.renderProduct: opts.product required");
725
913
  var variants = opts.variants || [];
@@ -741,6 +929,10 @@ function renderProduct(opts) {
741
929
  product: { title: opts.product.title, description: description },
742
930
  variants: rendered,
743
931
  has_variants: rendered.length > 0,
932
+ // Pre-rendered reviews block (internally HTML-escaped) for the
933
+ // theme's `{{{ reviews_html }}}` raw slot. The bundled themes
934
+ // include the slot; a custom theme opts in by adding it.
935
+ reviews_html: _buildReviews(opts.review_summary, opts.reviews, opts.review_cta),
744
936
  asset_css_main: opts.theme.assetUrl("css/main.css"),
745
937
  });
746
938
  }
@@ -749,18 +941,73 @@ function renderProduct(opts) {
749
941
  }).join("");
750
942
  if (!rows) rows = "<tr><td colspan=\"4\" class=\"empty\">No variants available.</td></tr>";
751
943
  var galleryHtml = _buildPdpGallery(opts.product, opts.media || [], opts.asset_prefix || "/assets/");
944
+ var reviewsHtml = _buildReviews(opts.review_summary, opts.reviews, opts.review_cta);
752
945
  var body = _render(PRODUCT_PAGE, {
753
946
  title: opts.product.title,
754
947
  description: description,
755
948
  variant_rows: "RAW_ROWS_PLACEHOLDER",
756
949
  })
757
950
  .replace("RAW_GALLERY_PLACEHOLDER", galleryHtml)
758
- .replace("RAW_ROWS_PLACEHOLDER", rows);
951
+ .replace("RAW_ROWS_PLACEHOLDER", rows)
952
+ .replace("RAW_REVIEWS_PLACEHOLDER", reviewsHtml);
759
953
  // Product-specific OpenGraph + Twitter Card values so shares
760
954
  // unfurl as "Operator Tee — blamejs.shop" with the SVG hero, not
761
955
  // the default shop-level description + brand logo.
762
956
  var heroMedia = (opts.media && opts.media[0]) || null;
763
957
  var ogImage = heroMedia ? ((opts.asset_prefix || "/assets/") + heroMedia.r2_key) : "/assets/brand/logo.png";
958
+
959
+ // Product + AggregateOffer JSON-LD, with AggregateRating folded in
960
+ // when published reviews exist. Kept byte-compatible with the edge
961
+ // renderer so the structured data is identical whichever substrate
962
+ // serves the PDP. AggregateRating is omitted (not null) at zero
963
+ // reviews — Google rejects `reviewCount: 0`.
964
+ var priceList = variants
965
+ .map(function (v) { return prices[v.id] ? prices[v.id].amount_minor : null; })
966
+ .filter(function (n) { return Number.isInteger(n); });
967
+ var jsonLd = null;
968
+ if (priceList.length > 0) {
969
+ var lowMinor = Math.min.apply(null, priceList);
970
+ var hiMinor = Math.max.apply(null, priceList);
971
+ var currency = (prices[variants[0].id] && prices[variants[0].id].currency) || "USD";
972
+ var divisor = currency === "JPY" || currency === "KRW" ? 1 : 100;
973
+ var aggregateRating;
974
+ if (opts.review_summary && Number(opts.review_summary.count) > 0) {
975
+ aggregateRating = {
976
+ "@type": "AggregateRating",
977
+ "ratingValue": (Number(opts.review_summary.avg_rating) || 0).toFixed(1),
978
+ "reviewCount": Number(opts.review_summary.count),
979
+ "bestRating": 5,
980
+ "worstRating": 1,
981
+ };
982
+ }
983
+ jsonLd = _jsonLdScript({
984
+ "@context": "https://schema.org",
985
+ "@type": "Product",
986
+ "name": opts.product.title,
987
+ "description": description || ("Browse " + opts.product.title + " on " + shopName + "."),
988
+ "image": heroMedia ? [ogImage] : undefined,
989
+ "sku": variants[0] && variants[0].sku,
990
+ "aggregateRating": aggregateRating,
991
+ "offers": {
992
+ "@type": "AggregateOffer",
993
+ "priceCurrency": currency,
994
+ "lowPrice": (lowMinor / divisor).toFixed(divisor === 1 ? 0 : 2),
995
+ "highPrice": (hiMinor / divisor).toFixed(divisor === 1 ? 0 : 2),
996
+ "offerCount": variants.length,
997
+ "availability": "https://schema.org/InStock",
998
+ },
999
+ });
1000
+ }
1001
+ var breadcrumbJsonLd = _jsonLdScript({
1002
+ "@context": "https://schema.org",
1003
+ "@type": "BreadcrumbList",
1004
+ "itemListElement": [
1005
+ { "@type": "ListItem", "position": 1, "name": "Shop", "item": "/" },
1006
+ { "@type": "ListItem", "position": 2, "name": opts.product.title, "item": "/products/" + opts.product.slug },
1007
+ ],
1008
+ });
1009
+ jsonLd = (jsonLd || "") + breadcrumbJsonLd;
1010
+
764
1011
  return _wrap({
765
1012
  title: opts.product.title,
766
1013
  shop_name: shopName,
@@ -770,7 +1017,7 @@ function renderProduct(opts) {
770
1017
  og_title: opts.product.title + " — " + shopName,
771
1018
  og_description: description || ("Browse " + opts.product.title + " on " + shopName + "."),
772
1019
  og_image: ogImage,
773
- body: body,
1020
+ body: body + jsonLd,
774
1021
  });
775
1022
  }
776
1023
 
@@ -1797,14 +2044,34 @@ function mount(router, deps) {
1797
2044
  cartCount = lines.length;
1798
2045
  }
1799
2046
  }
2047
+ // Published reviews aggregate + list. A failed read (e.g. the
2048
+ // reviews table not yet migrated) degrades to the empty state
2049
+ // rather than 500-ing the whole PDP — reviews are supplementary
2050
+ // to the buy path. Mirrors the edge renderer's missing-table
2051
+ // resilience.
2052
+ var reviewSummary, reviewRows, reviewCta;
2053
+ if (deps.reviews) {
2054
+ try {
2055
+ reviewSummary = await deps.reviews.summaryForProduct(product.id);
2056
+ reviewRows = (await deps.reviews.listForProduct(product.id, { limit: 10 })).rows;
2057
+ } catch (_e) { reviewSummary = undefined; reviewRows = []; }
2058
+ // The form route enforces auth + the verified-purchase gate, so
2059
+ // the CTA links there unconditionally; logged-out shoppers get
2060
+ // redirected to login, non-purchasers get a clear "not eligible".
2061
+ reviewCta = "<a class=\"btn-secondary reviews__cta\" href=\"/products/" +
2062
+ _b().template.escapeHtml(product.slug) + "/review\">Write a review</a>";
2063
+ }
1800
2064
  var html = renderProduct({
1801
- product: product,
1802
- variants: variants,
1803
- prices: prices,
1804
- media: media,
1805
- shop_name: shopName,
1806
- cart_count: cartCount,
1807
- theme: theme,
2065
+ product: product,
2066
+ variants: variants,
2067
+ prices: prices,
2068
+ media: media,
2069
+ review_summary: reviewSummary,
2070
+ reviews: reviewRows,
2071
+ review_cta: reviewCta,
2072
+ shop_name: shopName,
2073
+ cart_count: cartCount,
2074
+ theme: theme,
1808
2075
  });
1809
2076
  _send(res, 200, html);
1810
2077
  });
@@ -2339,6 +2606,89 @@ function mount(router, deps) {
2339
2606
  res.status(303); res.setHeader && res.setHeader("location", "/");
2340
2607
  return res.end ? res.end() : res.send("");
2341
2608
  });
2609
+
2610
+ // Product reviews — submission requires a logged-in customer AND a
2611
+ // verified purchase of the product (the gate, not just a badge).
2612
+ // Only mounts when both the reviews primitive and an order handle
2613
+ // (for the purchase check) are wired.
2614
+ if (deps.reviews && deps.order) {
2615
+ async function _reviewGateContext(req, res) {
2616
+ var slug = req.params && req.params.slug;
2617
+ var product = slug ? await deps.catalog.products.bySlug(slug) : null;
2618
+ if (!product) { _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme })); return null; }
2619
+ var auth;
2620
+ try { auth = _currentCustomer(req); }
2621
+ catch (e) {
2622
+ if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
2623
+ throw e;
2624
+ }
2625
+ if (!auth) {
2626
+ res.status(303); res.setHeader && res.setHeader("location", "/account/login");
2627
+ res.end ? res.end() : res.send("");
2628
+ return null;
2629
+ }
2630
+ var cartCount = await _cartCountForReq(req);
2631
+ var purchased = await deps.order.hasPurchasedProduct(auth.customer_id, product.id);
2632
+ return { product: product, auth: auth, cartCount: cartCount, purchased: purchased };
2633
+ }
2634
+
2635
+ function _reviewIneligible(res, ctx, code) {
2636
+ return _send(res, code, _reviewMessagePage(
2637
+ { shop_name: shopName, cart_count: ctx.cartCount },
2638
+ "Only verified buyers can review",
2639
+ "We can only accept a review for a product you've purchased. Make sure you're signed in with the account you ordered with.",
2640
+ { href: "/products/" + ctx.product.slug, label: "Back to product" },
2641
+ ));
2642
+ }
2643
+
2644
+ router.get("/products/:slug/review", async function (req, res) {
2645
+ var ctx = await _reviewGateContext(req, res);
2646
+ if (!ctx) return;
2647
+ if (!ctx.purchased) return _reviewIneligible(res, ctx, 200);
2648
+ _send(res, 200, renderReviewForm({
2649
+ product: { title: ctx.product.title, slug: ctx.product.slug },
2650
+ shop_name: shopName,
2651
+ cart_count: ctx.cartCount,
2652
+ }));
2653
+ });
2654
+
2655
+ router.post("/products/:slug/review", async function (req, res) {
2656
+ var ctx = await _reviewGateContext(req, res);
2657
+ if (!ctx) return;
2658
+ // Re-check the gate on write — a client can POST directly
2659
+ // without ever fetching the form.
2660
+ if (!ctx.purchased) return _reviewIneligible(res, ctx, 403);
2661
+ var body = req.body || {};
2662
+ try {
2663
+ await deps.reviews.submit({
2664
+ product_id: ctx.product.id,
2665
+ customer_id: ctx.auth.customer_id,
2666
+ rating: parseInt(body.rating, 10),
2667
+ title: body.title,
2668
+ body: body.body,
2669
+ verified_purchase: 1,
2670
+ });
2671
+ } catch (e) {
2672
+ // Shape rejections bounce back to the form with the reason;
2673
+ // anything else is a real 500.
2674
+ if (e instanceof TypeError) {
2675
+ return _send(res, 400, renderReviewForm({
2676
+ product: { title: ctx.product.title, slug: ctx.product.slug },
2677
+ notice: (e && e.message) || "Please check your review and try again.",
2678
+ shop_name: shopName,
2679
+ cart_count: ctx.cartCount,
2680
+ }));
2681
+ }
2682
+ throw e;
2683
+ }
2684
+ _send(res, 200, _reviewMessagePage(
2685
+ { shop_name: shopName, cart_count: ctx.cartCount },
2686
+ "Thanks for your review",
2687
+ "Your review has been submitted and is pending moderation. It will appear on the product page once an operator approves it.",
2688
+ { href: "/products/" + ctx.product.slug, label: "Back to product" },
2689
+ ));
2690
+ });
2691
+ }
2342
2692
  }
2343
2693
 
2344
2694
  // POST /cart/lines — add a line. Reads variant_id + qty from the
@@ -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.12.21",
7
- "tag": "v0.12.21",
6
+ "version": "0.12.28",
7
+ "tag": "v0.12.28",
8
8
  "license": "Apache-2.0",
9
9
  "author": "blamejs contributors",
10
10
  "source": "https://github.com/blamejs/blamejs",