@blamejs/blamejs-shop 0.0.122 → 0.0.124
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/README.md +4 -0
- package/SECURITY.md +2 -2
- package/lib/storefront.js +383 -2
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +2 -0
- package/lib/vendor/blamejs/README.md +1 -0
- package/lib/vendor/blamejs/SECURITY.md +1 -0
- package/lib/vendor/blamejs/api-snapshot.json +27 -2
- package/lib/vendor/blamejs/index.js +1 -0
- package/lib/vendor/blamejs/lib/ai-dp.js +539 -0
- package/lib/vendor/blamejs/lib/crypto.js +9 -2
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.12.29.json +31 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/ai-dp.test.js +167 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +14 -2
- package/lib/vendor/blamejs/test/layer-0-primitives/crypto-random-int.test.js +21 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.0.x
|
|
10
10
|
|
|
11
|
+
- v0.0.124 (2026-05-24) — **Save for later — move cart items into a holding list and back.** Each cart line now has a "Save for later" control that moves the item out of the cart into a per-customer holding list without losing it. Saved items live on a new account page where they can be moved back to the cart or removed. Moving an item back reprices it to the current catalog price and checks stock first, so a saved item that sold out (and isn't backorderable) can't silently re-enter the cart. Login-required, since the list is scoped to one customer. **Added:** *Save-for-later control on cart lines* — Each editable cart line gets a Save-for-later control. `POST /cart/lines/:line_id/save` moves the line out of the cart into the customer's saved list (`moveFromCart`). Login-gated — a signed-out shopper is redirected to sign in. · *`/account/saved` — the holding list* — A new account page lists saved items with a thumbnail, the saved price for reference, and Move-to-cart / Remove controls. Items whose product was archived render as "no longer available" rather than breaking the list. The account dashboard links to it (alongside Wishlist). · *Move back to cart, repriced + stock-checked* — `POST /saved/:save_id/move-to-cart` returns the item to the session cart at the current catalog price (not the stale snapshot) and refuses if the SKU is out of stock and not backorderable. `POST /saved/:save_id/remove` drops a saved row.
|
|
12
|
+
|
|
13
|
+
- v0.0.123 (2026-05-24) — **Wishlist — save products to your account, with social-proof counts on the product page.** Signed-in customers can now save products to a wishlist from the product page. A "N shoppers saved this" count surfaces social proof, and saved items live on a new account page where they can be removed or reopened. The save control and count render identically on the edge and container paths; the toggle is idempotent (saving twice is a no-op, toggling again removes) and login-gated, since a wishlist is scoped to one customer. **Added:** *Save to wishlist on the product page* — The PDP renders a "Save to wishlist" control and, once any customer has saved it, a "N shoppers saved this" social-proof count. Both render server-side on the edge and container paths. The count is public; the save action requires sign-in. · *`/account/wishlist` — saved items* — A new account page lists the customer's saved products with a thumbnail, a link back to the product, and a Remove control. Entries whose product was archived render as "no longer available" rather than breaking the list (wishlist rows are orphan-tolerant by design). The account dashboard links to it. · *`POST /wishlist/toggle`* — Login-required endpoint that saves the product if it isn't saved and removes it if it is. Idempotent (`INSERT OR IGNORE`). Redirects back to the product by resolving its canonical slug from the product id, or to a safe same-origin `return_to` (the account page's Remove uses it) — a forged or off-site redirect target is rejected.
|
|
14
|
+
|
|
11
15
|
- 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
16
|
|
|
13
17
|
- 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).
|
package/README.md
CHANGED
|
@@ -64,6 +64,8 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
|
|
|
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
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). |
|
|
67
|
+
| **`lib/wishlist.js`** | Per-customer saved products. The PDP renders a login-gated "Save to wishlist" toggle and a "N shoppers saved this" social-proof count; `/account/wishlist` lists saved items (remove + reopen, orphan-tolerant when a product is archived). `POST /wishlist/toggle` is idempotent (`INSERT OR IGNORE`) and redirects to the canonical product slug or a safe same-origin `return_to`. UUID-shape-validated ids, `b.pagination` HMAC cursors. |
|
|
68
|
+
| **`lib/save-for-later.js`** | Per-customer cart holding list. Each cart line gets a login-gated "Save for later" control (`POST /cart/lines/:id/save` → `moveFromCart`); `/account/saved` lists items with Move-to-cart / Remove. `moveToCart` reprices to the current catalog price and stock-gates (out-of-stock + non-backorderable is refused). Composes `catalog.inventory` + `catalog.prices` + `catalog.variants`. |
|
|
67
69
|
| **`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. |
|
|
68
70
|
| **`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. |
|
|
69
71
|
| **`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. |
|
|
@@ -82,6 +84,8 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
|
|
|
82
84
|
- `migrations-d1/0009_subscriptions.sql` — subscription_plans + subscriptions (Stripe-mirrored)
|
|
83
85
|
- `migrations-d1/0010_newsletter_signups.sql` — email signups with hash-based dedup
|
|
84
86
|
- `migrations-d1/0011_reviews.sql` — operator-moderated product reviews (hash-only author identity)
|
|
87
|
+
- `migrations-d1/0012_wishlist.sql` — per-customer saved products (unique customer + product + variant)
|
|
88
|
+
- `migrations-d1/0041_save_for_later.sql` — per-customer cart holding list (price snapshot + source line)
|
|
85
89
|
|
|
86
90
|
### Demo seed
|
|
87
91
|
|
package/SECURITY.md
CHANGED
|
@@ -92,8 +92,8 @@ node -e "
|
|
|
92
92
|
|
|
93
93
|
| Purpose | Algorithm | Fingerprint |
|
|
94
94
|
|-----------------------------------|------------|---------------------------|
|
|
95
|
-
| Maintainer commit/tag signing key | SSH-ED25519 |
|
|
96
|
-
| Release-signing public key | ML-DSA-65 (FIPS 204) | `
|
|
95
|
+
| Maintainer commit/tag signing key | SSH-ED25519 | `SHA256:5oF/XWhFpMde9TRfEX2GAHiApAq/MXOS4vti5zQbD7g` (cross-check against `https://github.com/<maintainer>.keys` — see below) |
|
|
96
|
+
| Release-signing public key | ML-DSA-65 (FIPS 204) | `d40e1e2b06a2509271cc3cf76bd34c63d2fbb093f42e1e1f6b349288900ec044509ca183b3d735cda003d80eb6cf5d80fd9181ad897889e1c1f701d61b7902c9` (SHA3-512 of `keys/release-pqc-pub.json#publicKey`) |
|
|
97
97
|
|
|
98
98
|
To populate the maintainer SSH fingerprint:
|
|
99
99
|
```
|
package/lib/storefront.js
CHANGED
|
@@ -676,6 +676,7 @@ var PRODUCT_PAGE =
|
|
|
676
676
|
" </table>\n" +
|
|
677
677
|
" </div>\n" +
|
|
678
678
|
" </div>\n" +
|
|
679
|
+
" RAW_WISHLIST_PLACEHOLDER\n" +
|
|
679
680
|
" </div>\n" +
|
|
680
681
|
" </div>\n" +
|
|
681
682
|
" RAW_REVIEWS_PLACEHOLDER\n" +
|
|
@@ -900,6 +901,158 @@ function _reviewMessagePage(opts, heading, message, cta) {
|
|
|
900
901
|
});
|
|
901
902
|
}
|
|
902
903
|
|
|
904
|
+
// Remove control for a wishlist entry — a form POST back through the
|
|
905
|
+
// toggle route with `return_to` so the customer lands back on the
|
|
906
|
+
// account page (not the product PDP the default toggle returns to).
|
|
907
|
+
function _wishlistRemoveForm(productId, esc) {
|
|
908
|
+
return "<form class=\"wishlist-item__remove\" method=\"post\" action=\"/wishlist/toggle\">" +
|
|
909
|
+
"<input type=\"hidden\" name=\"product_id\" value=\"" + esc(productId) + "\">" +
|
|
910
|
+
"<input type=\"hidden\" name=\"return_to\" value=\"/account/wishlist\">" +
|
|
911
|
+
"<button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">Remove</button>" +
|
|
912
|
+
"</form>";
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// Account "Saved items" page. `opts.items` is a resolved list:
|
|
916
|
+
// { product, hero_media } for live products, or { product: null,
|
|
917
|
+
// product_id } for entries whose product was archived/deleted (the
|
|
918
|
+
// wishlist row is orphan-tolerant by design — render "unavailable",
|
|
919
|
+
// never crash the listing).
|
|
920
|
+
function renderWishlist(opts) {
|
|
921
|
+
var esc = _b().template.escapeHtml;
|
|
922
|
+
var items = opts.items || [];
|
|
923
|
+
var prefix = opts.asset_prefix || "/assets/";
|
|
924
|
+
var rowsHtml = "";
|
|
925
|
+
for (var i = 0; i < items.length; i += 1) {
|
|
926
|
+
var it = items[i];
|
|
927
|
+
if (!it.product) {
|
|
928
|
+
rowsHtml +=
|
|
929
|
+
"<li class=\"wishlist-item wishlist-item--gone\">" +
|
|
930
|
+
"<span class=\"wishlist-item__title\">This item is no longer available.</span>" +
|
|
931
|
+
_wishlistRemoveForm(it.product_id, esc) +
|
|
932
|
+
"</li>";
|
|
933
|
+
continue;
|
|
934
|
+
}
|
|
935
|
+
var slug = esc(it.product.slug);
|
|
936
|
+
var thumb = it.hero_media
|
|
937
|
+
? "<img src=\"" + esc(prefix + it.hero_media.r2_key) + "\" alt=\"" + esc(it.hero_media.alt_text || it.product.title) + "\" loading=\"lazy\">"
|
|
938
|
+
: "<span class=\"wishlist-item__mark\" aria-hidden=\"true\">" + esc((it.product.title || "?").trim().charAt(0).toUpperCase() || "?") + "</span>";
|
|
939
|
+
rowsHtml +=
|
|
940
|
+
"<li class=\"wishlist-item\">" +
|
|
941
|
+
"<a class=\"wishlist-item__media\" href=\"/products/" + slug + "\">" + thumb + "</a>" +
|
|
942
|
+
"<div class=\"wishlist-item__body\">" +
|
|
943
|
+
"<a class=\"wishlist-item__title\" href=\"/products/" + slug + "\">" + esc(it.product.title) + "</a>" +
|
|
944
|
+
"<a class=\"wishlist-item__view card-link\" href=\"/products/" + slug + "\">View product →</a>" +
|
|
945
|
+
"</div>" +
|
|
946
|
+
_wishlistRemoveForm(it.product.id, esc) +
|
|
947
|
+
"</li>";
|
|
948
|
+
}
|
|
949
|
+
var inner = rowsHtml
|
|
950
|
+
? "<ul class=\"wishlist-list\">" + rowsHtml + "</ul>"
|
|
951
|
+
: "<p class=\"wishlist-empty\">You haven't saved anything yet. Browse the shop and tap <strong>Save to wishlist</strong> on products you want to keep an eye on.</p>";
|
|
952
|
+
var body =
|
|
953
|
+
"<section class=\"account-wishlist\">" +
|
|
954
|
+
"<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
|
|
955
|
+
"<li><a href=\"/account\">Account</a></li>" +
|
|
956
|
+
"<li aria-current=\"page\">Saved items</li>" +
|
|
957
|
+
"</ol></nav>" +
|
|
958
|
+
"<h1 class=\"account-wishlist__title\">Saved items</h1>" +
|
|
959
|
+
inner +
|
|
960
|
+
"</section>";
|
|
961
|
+
return _wrap({
|
|
962
|
+
title: "Saved items",
|
|
963
|
+
shop_name: opts.shop_name || "blamejs.shop",
|
|
964
|
+
cart_count: opts.cart_count == null ? 0 : opts.cart_count,
|
|
965
|
+
theme_css: opts.theme_css,
|
|
966
|
+
body: body,
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// Account "Saved for later" page. `opts.items` is a resolved list:
|
|
971
|
+
// { save, product, hero_media } for live products, or { save,
|
|
972
|
+
// product: null } when the variant/product behind a saved row was
|
|
973
|
+
// archived (orphan-tolerant — render "no longer available").
|
|
974
|
+
function renderSaved(opts) {
|
|
975
|
+
var esc = _b().template.escapeHtml;
|
|
976
|
+
var items = opts.items || [];
|
|
977
|
+
var prefix = opts.asset_prefix || "/assets/";
|
|
978
|
+
var rowsHtml = "";
|
|
979
|
+
for (var i = 0; i < items.length; i += 1) {
|
|
980
|
+
var it = items[i];
|
|
981
|
+
var save = it.save;
|
|
982
|
+
var moveForm =
|
|
983
|
+
"<form method=\"post\" action=\"/saved/" + esc(save.id) + "/move-to-cart\">" +
|
|
984
|
+
"<button type=\"submit\" class=\"btn-secondary btn-secondary--sm\">Move to cart</button></form>";
|
|
985
|
+
var removeForm =
|
|
986
|
+
"<form method=\"post\" action=\"/saved/" + esc(save.id) + "/remove\">" +
|
|
987
|
+
"<button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">Remove</button></form>";
|
|
988
|
+
if (!it.product) {
|
|
989
|
+
// Archived/unavailable product — only Remove. Move-to-cart can't
|
|
990
|
+
// succeed (no current price / stock), so don't offer it.
|
|
991
|
+
rowsHtml +=
|
|
992
|
+
"<li class=\"saved-item saved-item--gone\">" +
|
|
993
|
+
"<span class=\"saved-item__title\">" + esc(save.sku) + " — no longer available</span>" +
|
|
994
|
+
"<div class=\"saved-item__actions\">" + removeForm + "</div>" +
|
|
995
|
+
"</li>";
|
|
996
|
+
continue;
|
|
997
|
+
}
|
|
998
|
+
var actions = "<div class=\"saved-item__actions\">" + moveForm + removeForm + "</div>";
|
|
999
|
+
var slug = esc(it.product.slug);
|
|
1000
|
+
var thumb = it.hero_media
|
|
1001
|
+
? "<img src=\"" + esc(prefix + it.hero_media.r2_key) + "\" alt=\"" + esc(it.hero_media.alt_text || it.product.title) + "\" loading=\"lazy\">"
|
|
1002
|
+
: "<span class=\"saved-item__mark\" aria-hidden=\"true\">" + esc((it.product.title || "?").trim().charAt(0).toUpperCase() || "?") + "</span>";
|
|
1003
|
+
var priceStr = pricing.format(Number(save.snapshot_price_minor) || 0, "USD");
|
|
1004
|
+
rowsHtml +=
|
|
1005
|
+
"<li class=\"saved-item\">" +
|
|
1006
|
+
"<a class=\"saved-item__media\" href=\"/products/" + slug + "\">" + thumb + "</a>" +
|
|
1007
|
+
"<div class=\"saved-item__body\">" +
|
|
1008
|
+
"<a class=\"saved-item__title\" href=\"/products/" + slug + "\">" + esc(it.product.title) + "</a>" +
|
|
1009
|
+
"<span class=\"saved-item__meta\">Qty " + (Number(save.quantity) || 1) + " · " + esc(priceStr) + " <span class=\"saved-item__snapshot\">(saved price)</span></span>" +
|
|
1010
|
+
"</div>" +
|
|
1011
|
+
actions +
|
|
1012
|
+
"</li>";
|
|
1013
|
+
}
|
|
1014
|
+
var inner = rowsHtml
|
|
1015
|
+
? "<ul class=\"saved-list\">" + rowsHtml + "</ul>"
|
|
1016
|
+
: "<p class=\"saved-empty\">Nothing saved for later. Use <strong>Save for later</strong> on a cart item to move it here without losing it.</p>";
|
|
1017
|
+
var body =
|
|
1018
|
+
"<section class=\"account-saved\">" +
|
|
1019
|
+
"<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
|
|
1020
|
+
"<li><a href=\"/account\">Account</a></li>" +
|
|
1021
|
+
"<li aria-current=\"page\">Saved for later</li>" +
|
|
1022
|
+
"</ol></nav>" +
|
|
1023
|
+
"<h1 class=\"account-saved__title\">Saved for later</h1>" +
|
|
1024
|
+
inner +
|
|
1025
|
+
"</section>";
|
|
1026
|
+
return _wrap({
|
|
1027
|
+
title: "Saved for later",
|
|
1028
|
+
shop_name: opts.shop_name || "blamejs.shop",
|
|
1029
|
+
cart_count: opts.cart_count == null ? 0 : opts.cart_count,
|
|
1030
|
+
theme_css: opts.theme_css,
|
|
1031
|
+
body: body,
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// Product-level "Save to wishlist" control + social-proof count.
|
|
1036
|
+
// Byte-compatible with the edge renderer (`worker/render/product.js`)
|
|
1037
|
+
// so both paths emit identical markup. Action-only label — the toggle
|
|
1038
|
+
// route resolves add/remove server-side against the sealed session.
|
|
1039
|
+
function _buildWishlist(productId, count) {
|
|
1040
|
+
var esc = _b().template.escapeHtml;
|
|
1041
|
+
var n = Number(count) || 0;
|
|
1042
|
+
var countHtml = n > 0
|
|
1043
|
+
? "<span class=\"wishlist__count\">" + n + (n === 1 ? " shopper saved this" : " shoppers saved this") + "</span>"
|
|
1044
|
+
: "";
|
|
1045
|
+
return "<div class=\"wishlist\">" +
|
|
1046
|
+
"<form class=\"wishlist__form\" method=\"post\" action=\"/wishlist/toggle\">" +
|
|
1047
|
+
"<input type=\"hidden\" name=\"product_id\" value=\"" + esc(productId) + "\">" +
|
|
1048
|
+
"<button type=\"submit\" class=\"btn-secondary wishlist__btn\">" +
|
|
1049
|
+
"<span class=\"wishlist__heart\" aria-hidden=\"true\">♡</span> Save to wishlist" +
|
|
1050
|
+
"</button>" +
|
|
1051
|
+
"</form>" +
|
|
1052
|
+
countHtml +
|
|
1053
|
+
"</div>";
|
|
1054
|
+
}
|
|
1055
|
+
|
|
903
1056
|
// Schema.org JSON-LD block. JSON.stringify covers the standard escapes;
|
|
904
1057
|
// the `</` → `<\/` rewrite neutralises any literal `</script>` in a
|
|
905
1058
|
// value. Mirrors the edge renderer's `jsonLdScript`.
|
|
@@ -933,6 +1086,7 @@ function renderProduct(opts) {
|
|
|
933
1086
|
// theme's `{{{ reviews_html }}}` raw slot. The bundled themes
|
|
934
1087
|
// include the slot; a custom theme opts in by adding it.
|
|
935
1088
|
reviews_html: _buildReviews(opts.review_summary, opts.reviews, opts.review_cta),
|
|
1089
|
+
wishlist_html: _buildWishlist(opts.product.id, opts.wishlist_count),
|
|
936
1090
|
asset_css_main: opts.theme.assetUrl("css/main.css"),
|
|
937
1091
|
});
|
|
938
1092
|
}
|
|
@@ -942,6 +1096,7 @@ function renderProduct(opts) {
|
|
|
942
1096
|
if (!rows) rows = "<tr><td colspan=\"4\" class=\"empty\">No variants available.</td></tr>";
|
|
943
1097
|
var galleryHtml = _buildPdpGallery(opts.product, opts.media || [], opts.asset_prefix || "/assets/");
|
|
944
1098
|
var reviewsHtml = _buildReviews(opts.review_summary, opts.reviews, opts.review_cta);
|
|
1099
|
+
var wishlistHtml = _buildWishlist(opts.product.id, opts.wishlist_count);
|
|
945
1100
|
var body = _render(PRODUCT_PAGE, {
|
|
946
1101
|
title: opts.product.title,
|
|
947
1102
|
description: description,
|
|
@@ -949,6 +1104,7 @@ function renderProduct(opts) {
|
|
|
949
1104
|
})
|
|
950
1105
|
.replace("RAW_GALLERY_PLACEHOLDER", galleryHtml)
|
|
951
1106
|
.replace("RAW_ROWS_PLACEHOLDER", rows)
|
|
1107
|
+
.replace("RAW_WISHLIST_PLACEHOLDER", wishlistHtml)
|
|
952
1108
|
.replace("RAW_REVIEWS_PLACEHOLDER", reviewsHtml);
|
|
953
1109
|
// Product-specific OpenGraph + Twitter Card values so shares
|
|
954
1110
|
// unfurl as "Operator Tee — blamejs.shop" with the SVG hero, not
|
|
@@ -1074,6 +1230,7 @@ var CART_LINE_EDITABLE =
|
|
|
1074
1230
|
" <td class=\"price\">{{unit}}</td>\n" +
|
|
1075
1231
|
" <td class=\"price\">{{total}}</td>\n" +
|
|
1076
1232
|
" <td class=\"cart-line__remove-cell\">\n" +
|
|
1233
|
+
" RAW_CART_LINE_SAVE" +
|
|
1077
1234
|
" <form method=\"post\" action=\"/cart/lines/{{line_id}}/remove\">\n" +
|
|
1078
1235
|
" <button type=\"submit\" class=\"cart-line__btn cart-line__btn--remove\" aria-label=\"Remove line\">Remove</button>\n" +
|
|
1079
1236
|
" </form>\n" +
|
|
@@ -1420,10 +1577,18 @@ function renderCart(opts) {
|
|
|
1420
1577
|
if (rendered.length === 0) {
|
|
1421
1578
|
body = CART_EMPTY_PAGE;
|
|
1422
1579
|
} else {
|
|
1580
|
+
var canSave = !!opts.can_save;
|
|
1423
1581
|
var rows = rendered.map(function (l) {
|
|
1424
1582
|
var thumb = l.image_url
|
|
1425
1583
|
? "<span class=\"cart-line__thumb\"><img src=\"" + _escAttr(l.image_url) + "\" alt=\"" + _escAttr(l.image_alt) + "\" loading=\"lazy\"></span>"
|
|
1426
1584
|
: "<span class=\"cart-line__thumb cart-line__thumb--empty\" aria-hidden=\"true\"></span>";
|
|
1585
|
+
// "Save for later" moves the line into the customer's saved list.
|
|
1586
|
+
// Rendered only when the feature is wired (and account auth is
|
|
1587
|
+
// present); the route itself enforces login, redirecting a guest
|
|
1588
|
+
// to sign in.
|
|
1589
|
+
var saveBtn = canSave
|
|
1590
|
+
? "<form method=\"post\" action=\"/cart/lines/" + _escAttr(l.id) + "/save\"><button type=\"submit\" class=\"cart-line__btn cart-line__btn--save\">Save for later</button></form>"
|
|
1591
|
+
: "";
|
|
1427
1592
|
return _render(CART_LINE_EDITABLE, {
|
|
1428
1593
|
sku: l.sku,
|
|
1429
1594
|
qty: l.qty,
|
|
@@ -1432,7 +1597,7 @@ function renderCart(opts) {
|
|
|
1432
1597
|
line_id: l.id,
|
|
1433
1598
|
product_title: l.product_title,
|
|
1434
1599
|
product_url: l.product_url,
|
|
1435
|
-
}).replace("RAW_CART_LINE_THUMB", thumb);
|
|
1600
|
+
}).replace("RAW_CART_LINE_THUMB", thumb).replace("RAW_CART_LINE_SAVE", saveBtn);
|
|
1436
1601
|
}).join("");
|
|
1437
1602
|
body = _render(CART_PAGE, {
|
|
1438
1603
|
line_rows: "RAW_LINES",
|
|
@@ -1795,7 +1960,11 @@ var ACCOUNT_DASH_PAGE =
|
|
|
1795
1960
|
" <h1 class=\"section-head__title\">Hi, {{display_name}}</h1>\n" +
|
|
1796
1961
|
" <p class=\"section-head__lede\">Your orders + account controls. Every order ships from origin with a Stripe-secured receipt.</p>\n" +
|
|
1797
1962
|
" </div>\n" +
|
|
1798
|
-
" <
|
|
1963
|
+
" <div class=\"account-dash__actions\">\n" +
|
|
1964
|
+
" <a class=\"btn-secondary\" href=\"/account/wishlist\">Wishlist</a>\n" +
|
|
1965
|
+
" <a class=\"btn-secondary\" href=\"/account/saved\">Saved for later</a>\n" +
|
|
1966
|
+
" <form method=\"post\" action=\"/account/logout\"><button type=\"submit\" class=\"btn-ghost\">Sign out</button></form>\n" +
|
|
1967
|
+
" </div>\n" +
|
|
1799
1968
|
" </header>\n" +
|
|
1800
1969
|
" <dl class=\"account-dash__stats\">\n" +
|
|
1801
1970
|
" <div><dt>Orders</dt><dd>{{order_count}}</dd></div>\n" +
|
|
@@ -2061,6 +2230,13 @@ function mount(router, deps) {
|
|
|
2061
2230
|
reviewCta = "<a class=\"btn-secondary reviews__cta\" href=\"/products/" +
|
|
2062
2231
|
_b().template.escapeHtml(product.slug) + "/review\">Write a review</a>";
|
|
2063
2232
|
}
|
|
2233
|
+
// Wishlist social-proof count — degrades to 0 on a read failure
|
|
2234
|
+
// (e.g. table not yet migrated) rather than 500-ing the PDP.
|
|
2235
|
+
var wishlistCount = 0;
|
|
2236
|
+
if (deps.wishlist) {
|
|
2237
|
+
try { wishlistCount = await deps.wishlist.countForProduct(product.id); }
|
|
2238
|
+
catch (_e) { wishlistCount = 0; }
|
|
2239
|
+
}
|
|
2064
2240
|
var html = renderProduct({
|
|
2065
2241
|
product: product,
|
|
2066
2242
|
variants: variants,
|
|
@@ -2069,6 +2245,7 @@ function mount(router, deps) {
|
|
|
2069
2245
|
review_summary: reviewSummary,
|
|
2070
2246
|
reviews: reviewRows,
|
|
2071
2247
|
review_cta: reviewCta,
|
|
2248
|
+
wishlist_count: wishlistCount,
|
|
2072
2249
|
shop_name: shopName,
|
|
2073
2250
|
cart_count: cartCount,
|
|
2074
2251
|
theme: theme,
|
|
@@ -2114,6 +2291,7 @@ function mount(router, deps) {
|
|
|
2114
2291
|
lines: lines,
|
|
2115
2292
|
totals: totals,
|
|
2116
2293
|
product_lookup: productLookup,
|
|
2294
|
+
can_save: !!(deps.saveForLater && deps.customers),
|
|
2117
2295
|
shop_name: shopName,
|
|
2118
2296
|
theme: theme,
|
|
2119
2297
|
}));
|
|
@@ -2607,6 +2785,209 @@ function mount(router, deps) {
|
|
|
2607
2785
|
return res.end ? res.end() : res.send("");
|
|
2608
2786
|
});
|
|
2609
2787
|
|
|
2788
|
+
// Wishlist — saved products scoped to the logged-in customer.
|
|
2789
|
+
// Mounts when the wishlist primitive is wired.
|
|
2790
|
+
if (deps.wishlist) {
|
|
2791
|
+
// POST /wishlist/toggle — add the product if not saved, remove it
|
|
2792
|
+
// if already saved. Login required (the wishlist is per-customer).
|
|
2793
|
+
// Redirects to `return_to` when it's a safe same-origin path
|
|
2794
|
+
// (the account page's Remove uses it), otherwise back to the
|
|
2795
|
+
// product PDP (the canonical slug is resolved from product_id, so
|
|
2796
|
+
// a forged slug can't drive an open redirect).
|
|
2797
|
+
router.post("/wishlist/toggle", async function (req, res) {
|
|
2798
|
+
var auth;
|
|
2799
|
+
try { auth = _currentCustomer(req); }
|
|
2800
|
+
catch (e) {
|
|
2801
|
+
if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
|
|
2802
|
+
throw e;
|
|
2803
|
+
}
|
|
2804
|
+
if (!auth) {
|
|
2805
|
+
res.status(303); res.setHeader && res.setHeader("location", "/account/login");
|
|
2806
|
+
return res.end ? res.end() : res.send("");
|
|
2807
|
+
}
|
|
2808
|
+
var productId = (req.body || {}).product_id;
|
|
2809
|
+
try {
|
|
2810
|
+
var already = await deps.wishlist.isWishlisted({ customer_id: auth.customer_id, product_id: productId });
|
|
2811
|
+
if (already) await deps.wishlist.remove({ customer_id: auth.customer_id, product_id: productId });
|
|
2812
|
+
else await deps.wishlist.add({ customer_id: auth.customer_id, product_id: productId });
|
|
2813
|
+
} catch (e) {
|
|
2814
|
+
res.status(e instanceof TypeError ? 400 : 500);
|
|
2815
|
+
return res.end ? res.end((e && e.message) || "Error") : res.send((e && e.message) || "Error");
|
|
2816
|
+
}
|
|
2817
|
+
var rt = (req.body || {}).return_to;
|
|
2818
|
+
var dest;
|
|
2819
|
+
if (typeof rt === "string" && /^\/[^/]/.test(rt)) {
|
|
2820
|
+
dest = rt;
|
|
2821
|
+
} else {
|
|
2822
|
+
var product = null;
|
|
2823
|
+
try { product = await deps.catalog.products.get(productId); } catch (_e) { product = null; }
|
|
2824
|
+
dest = product ? ("/products/" + encodeURIComponent(product.slug)) : "/account/wishlist";
|
|
2825
|
+
}
|
|
2826
|
+
res.status(303); res.setHeader && res.setHeader("location", dest);
|
|
2827
|
+
return res.end ? res.end() : res.send("");
|
|
2828
|
+
});
|
|
2829
|
+
|
|
2830
|
+
// GET /account/wishlist — the customer's saved items. Each entry
|
|
2831
|
+
// resolves its product + hero image; an entry whose product was
|
|
2832
|
+
// archived renders as "unavailable" (the row is orphan-tolerant).
|
|
2833
|
+
router.get("/account/wishlist", async function (req, res) {
|
|
2834
|
+
var auth;
|
|
2835
|
+
try { auth = _currentCustomer(req); }
|
|
2836
|
+
catch (e) {
|
|
2837
|
+
if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
|
|
2838
|
+
throw e;
|
|
2839
|
+
}
|
|
2840
|
+
if (!auth) {
|
|
2841
|
+
res.status(303); res.setHeader && res.setHeader("location", "/account/login");
|
|
2842
|
+
return res.end ? res.end() : res.send("");
|
|
2843
|
+
}
|
|
2844
|
+
var page = await deps.wishlist.listForCustomer(auth.customer_id, { limit: 50 });
|
|
2845
|
+
var items = [];
|
|
2846
|
+
for (var i = 0; i < page.rows.length; i += 1) {
|
|
2847
|
+
var entry = page.rows[i];
|
|
2848
|
+
var product = null;
|
|
2849
|
+
try { product = await deps.catalog.products.get(entry.product_id); } catch (_e) { product = null; }
|
|
2850
|
+
if (!product) { items.push({ product: null, product_id: entry.product_id }); continue; }
|
|
2851
|
+
var media = await deps.catalog.media.listForProduct(product.id);
|
|
2852
|
+
items.push({ product: product, hero_media: media.length ? media[0] : null });
|
|
2853
|
+
}
|
|
2854
|
+
var cartCount = await _cartCountForReq(req);
|
|
2855
|
+
_send(res, 200, renderWishlist({
|
|
2856
|
+
items: items,
|
|
2857
|
+
shop_name: shopName,
|
|
2858
|
+
cart_count: cartCount,
|
|
2859
|
+
asset_prefix: deps.asset_prefix || "/assets/",
|
|
2860
|
+
}));
|
|
2861
|
+
});
|
|
2862
|
+
}
|
|
2863
|
+
|
|
2864
|
+
// Save for later — move a cart line into a per-customer holding
|
|
2865
|
+
// list and back. Login required (the list is per-customer).
|
|
2866
|
+
if (deps.saveForLater) {
|
|
2867
|
+
function _savedAuth(req, res) {
|
|
2868
|
+
var auth;
|
|
2869
|
+
try { auth = _currentCustomer(req); }
|
|
2870
|
+
catch (e) {
|
|
2871
|
+
if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
|
|
2872
|
+
throw e;
|
|
2873
|
+
}
|
|
2874
|
+
if (!auth) {
|
|
2875
|
+
res.status(303); res.setHeader && res.setHeader("location", "/account/login");
|
|
2876
|
+
res.end ? res.end() : res.send("");
|
|
2877
|
+
return null;
|
|
2878
|
+
}
|
|
2879
|
+
return auth;
|
|
2880
|
+
}
|
|
2881
|
+
|
|
2882
|
+
// POST /cart/lines/:line_id/save — move the line out of the cart
|
|
2883
|
+
// into the customer's saved list. Redirects back to /cart.
|
|
2884
|
+
router.post("/cart/lines/:line_id/save", async function (req, res) {
|
|
2885
|
+
var auth = _savedAuth(req, res);
|
|
2886
|
+
if (!auth) return;
|
|
2887
|
+
var sid = _readSidCookie(req);
|
|
2888
|
+
var cart = sid ? await deps.cart.bySession(sid) : null;
|
|
2889
|
+
if (!cart) {
|
|
2890
|
+
res.status(303); res.setHeader && res.setHeader("location", "/cart");
|
|
2891
|
+
return res.end ? res.end() : res.send("");
|
|
2892
|
+
}
|
|
2893
|
+
try {
|
|
2894
|
+
await deps.saveForLater.moveFromCart({
|
|
2895
|
+
customer_id: auth.customer_id,
|
|
2896
|
+
cart_id: cart.id,
|
|
2897
|
+
line_id: req.params && req.params.line_id,
|
|
2898
|
+
});
|
|
2899
|
+
} catch (e) {
|
|
2900
|
+
res.status(e instanceof TypeError ? 400 : 500);
|
|
2901
|
+
return res.end ? res.end((e && e.message) || "Error") : res.send((e && e.message) || "Error");
|
|
2902
|
+
}
|
|
2903
|
+
res.status(303); res.setHeader && res.setHeader("location", "/cart");
|
|
2904
|
+
return res.end ? res.end() : res.send("");
|
|
2905
|
+
});
|
|
2906
|
+
|
|
2907
|
+
// GET /account/saved — the customer's saved-for-later list.
|
|
2908
|
+
router.get("/account/saved", async function (req, res) {
|
|
2909
|
+
var auth = _savedAuth(req, res);
|
|
2910
|
+
if (!auth) return;
|
|
2911
|
+
var page = await deps.saveForLater.listForCustomer({ customer_id: auth.customer_id, limit: 50 });
|
|
2912
|
+
var items = [];
|
|
2913
|
+
for (var i = 0; i < page.rows.length; i += 1) {
|
|
2914
|
+
var row = page.rows[i];
|
|
2915
|
+
var product = null;
|
|
2916
|
+
if (row.variant_id) {
|
|
2917
|
+
try {
|
|
2918
|
+
var v = await deps.catalog.variants.get(row.variant_id);
|
|
2919
|
+
if (v) product = await deps.catalog.products.get(v.product_id);
|
|
2920
|
+
} catch (_e) { product = null; }
|
|
2921
|
+
}
|
|
2922
|
+
if (!product) { items.push({ save: row, product: null }); continue; }
|
|
2923
|
+
var media = await deps.catalog.media.listForProduct(product.id);
|
|
2924
|
+
items.push({ save: row, product: product, hero_media: media.length ? media[0] : null });
|
|
2925
|
+
}
|
|
2926
|
+
var cartCount = await _cartCountForReq(req);
|
|
2927
|
+
_send(res, 200, renderSaved({
|
|
2928
|
+
items: items,
|
|
2929
|
+
shop_name: shopName,
|
|
2930
|
+
cart_count: cartCount,
|
|
2931
|
+
asset_prefix: deps.asset_prefix || "/assets/",
|
|
2932
|
+
}));
|
|
2933
|
+
});
|
|
2934
|
+
|
|
2935
|
+
// POST /saved/:save_id/move-to-cart — move a saved row back into
|
|
2936
|
+
// the session cart (created if absent). Redirects to /cart.
|
|
2937
|
+
router.post("/saved/:save_id/move-to-cart", async function (req, res) {
|
|
2938
|
+
var auth = _savedAuth(req, res);
|
|
2939
|
+
if (!auth) return;
|
|
2940
|
+
var resolved = await _getOrCreateCart(req, res, "USD");
|
|
2941
|
+
try {
|
|
2942
|
+
await deps.saveForLater.moveToCart({
|
|
2943
|
+
customer_id: auth.customer_id,
|
|
2944
|
+
save_id: req.params && req.params.save_id,
|
|
2945
|
+
cart_id: resolved.cart.id,
|
|
2946
|
+
// Reprice to the live catalog price so the cart never carries
|
|
2947
|
+
// a stale snapshot; the saved page shows the snapshot for
|
|
2948
|
+
// reference only.
|
|
2949
|
+
use_price: "current",
|
|
2950
|
+
});
|
|
2951
|
+
} catch (e) {
|
|
2952
|
+
if (e instanceof TypeError) {
|
|
2953
|
+
res.status(400);
|
|
2954
|
+
return res.end ? res.end((e && e.message) || "Error") : res.send((e && e.message) || "Error");
|
|
2955
|
+
}
|
|
2956
|
+
// The cart enforces one line per (cart_id, variant_id). If the
|
|
2957
|
+
// shopper re-added this variant before moving the saved copy,
|
|
2958
|
+
// moveToCart's INSERT collides — but the end state they want
|
|
2959
|
+
// (variant in the cart) already holds. Drop the saved row and
|
|
2960
|
+
// treat it as success instead of surfacing a 500. The lib
|
|
2961
|
+
// leaves the save row intact on collision, so removing it here
|
|
2962
|
+
// is what completes the "move".
|
|
2963
|
+
if (/unique|constraint/i.test((e && e.message) || "")) {
|
|
2964
|
+
try {
|
|
2965
|
+
await deps.saveForLater.remove({ customer_id: auth.customer_id, save_id: req.params && req.params.save_id });
|
|
2966
|
+
} catch (_e) { /* drop-silent — the move is already effectively done */ }
|
|
2967
|
+
res.status(303); res.setHeader && res.setHeader("location", "/cart");
|
|
2968
|
+
return res.end ? res.end() : res.send("");
|
|
2969
|
+
}
|
|
2970
|
+
throw e;
|
|
2971
|
+
}
|
|
2972
|
+
res.status(303); res.setHeader && res.setHeader("location", "/cart");
|
|
2973
|
+
return res.end ? res.end() : res.send("");
|
|
2974
|
+
});
|
|
2975
|
+
|
|
2976
|
+
// POST /saved/:save_id/remove — drop a saved row.
|
|
2977
|
+
router.post("/saved/:save_id/remove", async function (req, res) {
|
|
2978
|
+
var auth = _savedAuth(req, res);
|
|
2979
|
+
if (!auth) return;
|
|
2980
|
+
try {
|
|
2981
|
+
await deps.saveForLater.remove({ customer_id: auth.customer_id, save_id: req.params && req.params.save_id });
|
|
2982
|
+
} catch (e) {
|
|
2983
|
+
res.status(e instanceof TypeError ? 400 : 500);
|
|
2984
|
+
return res.end ? res.end((e && e.message) || "Error") : res.send((e && e.message) || "Error");
|
|
2985
|
+
}
|
|
2986
|
+
res.status(303); res.setHeader && res.setHeader("location", "/account/saved");
|
|
2987
|
+
return res.end ? res.end() : res.send("");
|
|
2988
|
+
});
|
|
2989
|
+
}
|
|
2990
|
+
|
|
2610
2991
|
// Product reviews — submission requires a logged-in customer AND a
|
|
2611
2992
|
// verified purchase of the product (the gate, not just a badge).
|
|
2612
2993
|
// Only mounts when both the reviews primitive and an order handle
|
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.12.
|
|
7
|
-
"tag": "v0.12.
|
|
6
|
+
"version": "0.12.29",
|
|
7
|
+
"tag": "v0.12.29",
|
|
8
8
|
"license": "Apache-2.0",
|
|
9
9
|
"author": "blamejs contributors",
|
|
10
10
|
"source": "https://github.com/blamejs/blamejs",
|
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.12.x
|
|
10
10
|
|
|
11
|
+
- v0.12.29 (2026-05-24) — **`b.ai.dp` — float-safe differential privacy: snapping-mechanism Laplace + discrete Gaussian + Rényi-DP budgets.** Differential privacy adds calibrated noise so an aggregate is provably insensitive to any single record — but the guarantee is fragile: Mironov (2012) showed that a Laplace mechanism sampled with naive double-precision floats lets an attacker distinguish neighbouring datasets with > 35% probability from a single output, silently destroying the promise. `b.ai.dp` ships only mechanisms whose sampling is hardened against that attack class: Laplace via the snapping mechanism (clamp + CSPRNG sign + full-mantissa uniform + power-of-two-grid rounding) and the discrete Gaussian (Canonne–Kamath–Steinke 2020) via integer-exact rejection sampling built from Bernoulli(exp(−γ)) over exact rationals — no floating-point noise at all. All randomness comes from `b.crypto.generateBytes` (SHAKE256 over the OS CSPRNG), never `Math.random`. `b.ai.dp.budget({ scope, epsilon, delta })` tracks a privacy budget per scope and refuses a `consume` that would exceed it, accounting composition either by basic summation (default) or a Rényi-DP accountant (Mironov 2017) for a much tighter bound under repeated Gaussian releases. NIST SP 800-226 (2025) is the evaluation standard; Dwork & Roth is the canonical reference. The exponential and sparse-vector mechanisms are deferred-with-condition — their float-safe constructions (base-2 / permute-and-flip; snapped SVT) re-open on operator demand, since shipping them float-unsafe would defeat the module's purpose. **Added:** *`b.ai.dp.mechanism({ type, sensitivity, epsilon, ... })` — float-safe noise mechanisms* — `type: "laplace"` is the snapping mechanism (pure ε-DP, real-valued, requires a clamp `bound` the guarantee depends on); `type: "gaussian"` is the discrete Gaussian (integer-valued, (ε, δ)-DP, requires `delta`). The Gaussian uses the classic calibration σ = √(2 ln(1.25/δ))·Δ/ε, proven for ε ≤ 1 — larger ε is refused with a pointer to splitting the release under an rdp budget. Descriptors are validated + frozen at construction so a malformed parameter fails fast. · *`b.ai.dp.budget({ scope, epsilon, delta, accounting })` — per-scope privacy budget* — Returns `{ consume, remaining, spent, reset }`. `consume(mechanism, value)` adds the mechanism's noise, charges the accountant, and throws `aiDp/budget-exhausted` if the release would push the scope past its (ε, δ). `accounting: "basic"` (default) sums per-release ε and δ; `accounting: "rdp"` runs a Rényi-DP accountant across a grid of orders and converts to (ε, δ) at the scope's δ for a tight composition bound under repeated Gaussian releases (requires `delta > 0`). The scope budget is enforced on both ε and δ independently. **Security:** *`b.crypto.generateBytes` uniformity fix at 1-byte length* — Node's SHAKE256 XOF is non-uniform at `outputLength: 1` — the byte values 0x00 and 0xff never occur and the low bit skews to ~0.54. `b.crypto.generateBytes(1)` (and the underlying `random(1)`) now draws at least 2 bytes and slices, so a single-byte CSPRNG request is uniform. Surfaced by `b.ai.dp` per-byte noise sampling; any per-byte consumer of `generateBytes` inherits the fix. A regression test asserts 0x00 / 0xff occur and the low bit is balanced.
|
|
12
|
+
|
|
11
13
|
- v0.12.28 (2026-05-24) — **`b.ai.capability` — model-capability registry + cheapest-satisfying-model router.** `b.ai.capability.create({ models })` turns a fleet of AI model descriptors into a routing decision: given a set of requirements (context window, input/output modalities, tool use, structured output, reasoning tier, citation support, prompt-caching size), it picks the cheapest model that satisfies all of them. NIST AI RMF (AI 100-1) MAP 2.x requires documenting each model's capabilities and limitations; the Model Cards convention (Mitchell et al., 2019) formalizes that descriptor — this primitive makes the descriptor actionable. Routing to the cheapest sufficient model is a front-line defense against over-provisioning spend and composes directly with `b.ai.quota`'s `cost-usd` dimension (the chosen descriptor's rate feeds the budget charge); refusing to route a request to a model that cannot satisfy it (missing modality, too-small context window, no tool use) catches a capability mismatch before the inference call burns tokens on a guaranteed-bad result. Cost ranking uses a supplied `costBasis` (`{ inputTokens, outputTokens }`) for real per-call spend, else the sum of the per-1k rates; ties break by model id so the choice is deterministic across calls and nodes. **Added:** *`b.ai.capability.create({ models })` — capability registry + router* — Returns `{ describe, list, register, satisfies, route }`. A descriptor carries `maxContextTokens`, `maxOutputTokens`, `modalitiesIn` / `modalitiesOut` (arrays), `toolUse`, `structuredOutput`, `fineTunable`, `reasoningTier` (`none` / `basic` / `standard` / `advanced`, ordered), `citationSupport`, `promptCachingMaxTokens`, and the cost rates `costPer1kInputTokens` / `costPer1kOutputTokens`. Descriptors are validated + frozen at registration so a typo (negative cost, unknown reasoning tier, non-array modality list) surfaces at config time rather than as a silent mis-route. `describe(modelId)` returns the frozen descriptor; `register(modelId, descriptor)` adds or replaces one at runtime. · *`route({ requirements, fallback?, costBasis? })` — cheapest-satisfying selection* — Collects every model whose descriptor satisfies all requirements, then returns the cheapest (`{ modelId, descriptor, estimatedCost, reason }`). Requirements: `minContextTokens`, `minOutputTokens`, `modalitiesIn` / `modalitiesOut` (model must support every listed modality), `toolUse`, `structuredOutput`, `fineTunable`, `minReasoningTier` (tier ordering — `standard` is met by `standard` or `advanced`), `citationSupport`, `minPromptCachingTokens`. When no model matches, `fallback` (a registered model id) is returned with `reason: "fallback"`, or the call refuses with `aiCapability/no-candidate` if no fallback was supplied. Routing decisions emit `ai/capability-routed` / `ai/capability-fallback` / `ai/capability-no-candidate` through the drop-silent audit chain. · *`satisfies(modelId, requirements)` — precise capability-mismatch reasons* — Returns `{ ok, failures }` where each failure names the `requirement`, the `need`, and what the model `have`s — so a caller surfaces a precise reason (e.g. `minReasoningTier need advanced have basic`) instead of a bare boolean. Use it to explain a routing miss or to gate a request against a specific model before calling it.
|
|
12
14
|
|
|
13
15
|
- v0.12.27 (2026-05-24) — **`b.ai.quota` — per-tenant, per-model AI usage budgets with atomic consume-and-check.** `b.ai.quota.create(opts)` builds an enforcer that caps AI inference usage per `(tenant, model, dimension, period)` and defends OWASP LLM Top 10 2025 LLM10 (Unbounded Consumption) — the class that includes denial-of-wallet, where an attacker drives a high volume of pay-per-use inferences until the bill itself is the attack. Meter by `tokens`, `requests`, `cost-usd`, or `compute-hours` over a calendar-aligned UTC window (`second` through `month`). `consume(tenant, model, amount)` is a single atomic check-and-charge: under the default `hard` enforcement it reserves the amount only if it fits under the ceiling, otherwise it refuses without charging — the limit test and the charge are one indivisible operation, so there is no charge-then-refund window for a concurrent call to observe. The in-memory counter is per-process; multi-node deployments supply an `opts.store` adapter whose `reserve` (an atomic conditional test-and-charge — a Redis Lua script, a SQL `UPDATE ... WHERE used + :amt <= :limit RETURNING used`) and `add` are atomic on the shared backend to enforce one aggregate ceiling across the cluster without false denials under contention. Limit resolution is most-specific-first: `perTenantModel` over `perTenant` over `perModel` over the default `limit`; tenant and model identifiers are percent-encoded into the counter key so a hostile tenant name cannot collide with another tenant's budget. **Added:** *`b.ai.quota.create(opts)` — per-tenant AI usage-budget enforcer* — Returns `{ consume, check, snapshot, reset }` scoped to one `dimension` (`tokens` / `requests` / `cost-usd` / `compute-hours`) and one `period` (`second` / `minute` / `hour` / `day` / `week` (Monday-aligned) / `month` (1st-of-month), all UTC-aligned). `consume(tenant, model, amount, opts?)` returns `{ used, limit, remaining, allowed, exceeded, windowStart, resetsAt, ... }`. `check(tenant, model)` is the read-only snapshot. Spin up one enforcer per dimension you meter — a monthly `cost-usd` budget and a per-minute `tokens` burst cap coexist as two `create()` calls sharing one store. Defends OWASP LLM10:2025 Unbounded Consumption / denial-of-wallet; maps to NIST AI RMF (AI 100-1) MANAGE 2.x and EU AI Act Art. 15 (robustness / resource-exhaustion resilience). · *`hard` / `soft` / `warn` enforcement* — `hard` (default) refuses the over-budget call and throws `aiQuota/exceeded` without charging — the rejected reservation is refunded so the counter is untouched. `soft` admits the charge but reports `allowed: false` so the caller decides whether to honor it. `warn` admits and allows (advisory), flagging `exceeded: true`. A per-call `consume(..., { enforcement })` override lets one endpoint soften the mode for a trusted internal caller without a second enforcer. Every over-budget event emits `ai/quota-exceeded` through the drop-silent audit chain (`ai/quota-applied` on success), tagged with the active cluster node id for attribution. · *Cross-node aggregate budgets via `opts.store`* — The default counter is in-memory (per-process). Supply `opts.store` exposing atomic `reserve` / `add` / `get` / `reset` (a Redis Lua script, a shared SQL row) and the ceiling is enforced on the cluster-wide aggregate. `hard` mode goes through `reserve`, an atomic conditional test-and-charge that adds the amount only if it fits — so a concurrent over-budget call cannot transiently inflate the counter and falsely deny a smaller call that should fit. Per-tenant and per-model limit overrides (`perTenant` / `perModel` / `perTenantModel`) are validated at config time so a malformed cap surfaces at boot, not as a silent fall-through to the default.
|
|
@@ -182,6 +182,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
|
|
|
182
182
|
- **Audit + segregation** — 21 CFR Part 11 §11.10(e) audit-content gate + §11.50(b) electronicSignature (`b.fda21cfr11`); PCI DSS 4.0 Req 10.4.1.1 daily-review automation (`b.auditDailyReview`); SOX §404 + SOC 2 CC1.3 segregation-of-duties via Postgres trigger DDL (`b.audit.bindActor`, `b.audit.assertSegregation`)
|
|
183
183
|
- **Change control + WORM** — m-of-n approver DDL change-control with maintenance-window + ML-DSA-87 signed proposals (`b.ddlChangeControl`); row-level WORM triggers boot-asserted under `sec-17a-4` / `finra-4511` / `fda-21cfr11` (`b.db.declareWorm`); dual-control physical delete + crypto-erase + REINDEX in one transaction (`b.db.declareRequireDualControl`, `b.db.eraseHard`)
|
|
184
184
|
- **Consumer-protection** — FTC click-to-cancel UX-parity attestation (`ftc-2024` / `ca-sb942` / `strict`) (`b.darkPatterns`)
|
|
185
|
+
- **Differential privacy** — float-safe DP for aggregate releases: snapping-mechanism Laplace (Mironov 2012) + discrete Gaussian (Canonne–Kamath–Steinke 2020), CSPRNG noise, per-scope ε/δ budgets with basic + Rényi-DP accounting; defends the floating-point distinguishing attack that breaks naive Laplace samplers (NIST SP 800-226) (`b.ai.dp`)
|
|
185
186
|
- **Privacy / DSR** — GDPR Articles 15–22 / CCPA / CPRA / LGPD / PIPEDA data-subject-rights workflow (`b.dsr`); IAB TCF v2.3 consent-string parser + `disclosedVendors` validator (`b.iabTcf`); IAB MSPA / GPP universal-opt-out (USNAT / USCA / USVA / USCO / USCT / USUT) + GPC mirror (`b.iabMspa`); generic consent capture + withdrawal (`b.consent`)
|
|
186
187
|
- **Incident reporters** — EU DORA Article 17 ICT-incident workflow per Commission Delegated Regulation 2024/1772 (`b.dora`); EU NIS2 (`b.nis2`); EU Cyber Resilience Act SBOM + secure-software-attestation (`b.cra`); SEC Form 8-K Item 1.05 cybersecurity-incident materiality-disclosure (`b.secCyber`); incident lifecycle coordinator (`b.incident`)
|
|
187
188
|
- **Outbound DLP** — interceptor-installed on httpClient + mail + webhook with built-in detectors for PAN (Luhn), SSN, EIN, IBAN (mod-97), api-key shapes, PEM, SSH private keys, JWTs, AWS access keys, PHI composite; refuse / redact / audit-only verdicts under pci-dss / hipaa / fapi2 / soc2 / gdpr presets (`b.redact.installOutboundDlp`)
|
|
@@ -342,6 +342,7 @@ This is the minimum-viable security posture for a production deployment. The fra
|
|
|
342
342
|
- [ ] For OAuth / OIDC RP callbacks: call `b.auth.oauth.parseCallback(query, opts?)` BEFORE consuming `code` — validates RFC 9207 AS Issuer Identifier (refuses iss-mismatch + OP `error=` redirects + state-mismatch CSRF). For FAPI 2.0 deployments add `b.fapi2.assertCallback(query)` (refuses missing iss; refuses bare-param under `fapi-2.0-message-signing` posture, requiring JARM `response`) and `b.fapi2.assertAuthzRequest(authzParams)` BEFORE issuing the redirect (refuses non-JAR authorization requests). For refresh-token flows, pass `seen({ jti, iss })` to `b.auth.oauth.refreshAccessToken` so reuse of an already-rotated token refuses BEFORE the HTTP exchange (RFC 9700 §4.13 / OAuth 2.1 §6.1)
|
|
343
343
|
- [ ] For Model Context Protocol servers exposing tools to LLM agents: wire `b.mcp.toolResult.sanitize(result, { posture: "refuse" })` over EVERY tool output before returning it to the model — defends OWASP LLM07 (sensitive tool output / prompt-injection echo back into the agent loop), refuses dangerous-HTML + off-allowlist URLs. Wrap each tool's input handler with `b.mcp.validateToolInput(toolName, input, schema)` (JSON Schema 2020-12 subset — `type` / `properties` / `required` / `enum` / `const` / length + range caps) so an LLM-supplied argument shape that doesn't match refuses BEFORE the tool runs. Define each tool's required scopes via `b.mcp.capability.create(scopes)` and gate execution on `cap.satisfiedBy(grantedScopes)` (LLM08 least-privilege)
|
|
344
344
|
- [ ] For AI inference endpoints (especially pay-per-use provider models): wire `b.ai.quota.create({ dimension, period, limit, enforcement: "hard" })` and call `quota.consume(tenant, model, amount)` BEFORE every inference — caps tokens / requests / cost-usd / compute-hours per tenant per window so one tenant cannot run up an unbounded bill (OWASP LLM10:2025 unbounded consumption / denial-of-wallet). For multi-node deployments supply an `opts.store` whose `reserve` (atomic conditional test-and-charge) + `add` are atomic on the shared backend (Redis Lua / a SQL `UPDATE ... WHERE used + :amt <= :limit`) so the ceiling is enforced on the cluster-wide aggregate, not per-process
|
|
345
|
+
- [ ] For endpoints returning aggregate statistics over personal data (counts / sums / means / histograms): add calibrated noise with `b.ai.dp` and gate spend with `b.ai.dp.budget({ scope, epsilon, delta })` — use `type: "laplace"` (snapping mechanism) or `type: "gaussian"` (discrete Gaussian), NEVER a hand-rolled `Math.random`-based noise generator: a naive double-precision Laplace sampler lets an attacker distinguish neighbouring datasets from a single output (Mironov 2012), silently breaking the guarantee. Track the per-scope ε/δ budget so repeated queries can't be averaged to cancel the noise
|
|
345
346
|
- [ ] For data with a TTL (GDPR Art. 17, PCI 3.1, retention windows): declare retention rules via `b.retention.create({ db, audit }).declare({ name, table, ageField, ttlMs, action: "erase" })` and run on a `b.scheduler` cadence; honour legal-hold via `legalHoldField`
|
|
346
347
|
- [ ] For write-once-read-many object archives (SEC 17a-4, FINRA, HIPAA-shaped retention): create the bucket with `b.objectStore.bucketOps.create(name, { objectLockEnabled: true })` (Object Lock can ONLY be flipped at create time), apply a default retention via `setObjectLockConfiguration(name, { mode: "COMPLIANCE", years })`, and pin individual objects with `setObjectRetention(name, key, { mode, retainUntil })` or `setObjectLegalHold(name, key, "ON")` — `COMPLIANCE` cannot be shortened or bypassed by anyone (including root); pick deliberately
|
|
347
348
|
- [ ] At boot, before any outbound socket opens: call `b.network.bootFromEnv({ env: process.env, audit: b.audit })` so operator-supplied NTP / DNS / proxy / DPI-trust / TCP socket settings (`BLAMEJS_NTP_*`, `BLAMEJS_DNS_*`, `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`, `BLAMEJS_EXTRA_CA_CERTS`, `BLAMEJS_SOCKET_*`) apply uniformly
|