@blamejs/blamejs-shop 0.0.123 → 0.0.126
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/lib/storefront.js +431 -2
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +6 -0
- package/lib/vendor/blamejs/README.md +2 -0
- package/lib/vendor/blamejs/SECURITY.md +3 -0
- package/lib/vendor/blamejs/api-snapshot.json +40 -2
- package/lib/vendor/blamejs/index.js +2 -0
- package/lib/vendor/blamejs/lib/auth/jar.js +168 -0
- package/lib/vendor/blamejs/lib/backup/index.js +96 -0
- package/lib/vendor/blamejs/lib/cbor.js +478 -0
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.12.30.json +18 -0
- package/lib/vendor/blamejs/release-notes/v0.12.31.json +18 -0
- package/lib/vendor/blamejs/release-notes/v0.12.32.json +27 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/auth-jar.test.js +153 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/backup-key-rotation.test.js +141 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/cbor.test.js +177 -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.126 (2026-05-24) — **Address book — saved shipping and billing addresses on the account page.** Signed-in customers can now keep a book of addresses at /account/addresses: add, edit, set a default shipping or billing address, and remove. Addresses are per-customer; every action that targets a specific address first confirms it belongs to the signed-in customer, so a guessed id can't read or change someone else's address. **Added:** *Address book at `/account/addresses`* — List, add, and edit saved addresses (recipient, company, two street lines, city, region, postal code, ISO country, phone). The account dashboard links to it. A blank product catalog isn't required — this is account surface only. · *Default shipping / billing* — Mark one address as the default shipping and one as the default billing; promoting a new default clears the previous one. Surfaced as badges on each card. · *Ownership-checked mutations* — Edit, update, set-default, and remove all resolve the address by id and verify it belongs to the signed-in customer before acting — a foreign or guessed id returns 404, never another customer's data. Add/edit re-render the form with the validation message on bad input.
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
|
|
11
15
|
- 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.
|
|
12
16
|
|
|
13
17
|
- 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.
|
package/README.md
CHANGED
|
@@ -65,6 +65,8 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
|
|
|
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
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`. |
|
|
69
|
+
| **`lib/addresses.js`** | Per-customer address book at `/account/addresses` — add / edit / set default shipping or billing / remove. One-default-per-role invariant (promoting clears the prior). Every by-id route confirms the address belongs to the signed-in customer before acting (a guessed id returns 404). `b.guardUuid` ids, 2-char ISO country. |
|
|
68
70
|
| **`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. |
|
|
69
71
|
| **`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. |
|
|
70
72
|
| **`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. |
|
|
@@ -84,6 +86,8 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
|
|
|
84
86
|
- `migrations-d1/0010_newsletter_signups.sql` — email signups with hash-based dedup
|
|
85
87
|
- `migrations-d1/0011_reviews.sql` — operator-moderated product reviews (hash-only author identity)
|
|
86
88
|
- `migrations-d1/0012_wishlist.sql` — per-customer saved products (unique customer + product + variant)
|
|
89
|
+
- `migrations-d1/0041_save_for_later.sql` — per-customer cart holding list (price snapshot + source line)
|
|
90
|
+
- `migrations-d1/0026_customer_addresses.sql` — per-customer address book (default shipping/billing flags)
|
|
87
91
|
|
|
88
92
|
### Demo seed
|
|
89
93
|
|
package/lib/storefront.js
CHANGED
|
@@ -967,6 +967,173 @@ function renderWishlist(opts) {
|
|
|
967
967
|
});
|
|
968
968
|
}
|
|
969
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
|
+
// One labelled text input for the address form. `required` and other
|
|
1036
|
+
// attrs are passed through; `value` is pre-filled (escaped) for edit.
|
|
1037
|
+
function _addrField(name, labelText, value, opts) {
|
|
1038
|
+
var esc = _b().template.escapeHtml;
|
|
1039
|
+
opts = opts || {};
|
|
1040
|
+
var attrs = "";
|
|
1041
|
+
if (opts.required) attrs += " required";
|
|
1042
|
+
if (opts.maxlength) attrs += " maxlength=\"" + opts.maxlength + "\"";
|
|
1043
|
+
if (opts.pattern) attrs += " pattern=\"" + esc(opts.pattern) + "\"";
|
|
1044
|
+
if (opts.autocomplete) attrs += " autocomplete=\"" + esc(opts.autocomplete) + "\"";
|
|
1045
|
+
var req = opts.required ? " <span class=\"form-field__req\" aria-hidden=\"true\">*</span>" : "";
|
|
1046
|
+
return "<label class=\"form-field\">" +
|
|
1047
|
+
"<span class=\"form-field__label\">" + esc(labelText) + req + "</span>" +
|
|
1048
|
+
"<input type=\"text\" name=\"" + esc(name) + "\" value=\"" + esc(value == null ? "" : String(value)) + "\"" + attrs + ">" +
|
|
1049
|
+
"</label>";
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// Shared add/edit address form. `addr` pre-fills for edit (null = add).
|
|
1053
|
+
function _addressForm(action, addr, submitLabel) {
|
|
1054
|
+
var esc = _b().template.escapeHtml;
|
|
1055
|
+
addr = addr || {};
|
|
1056
|
+
function _checked(v) { return Number(v) === 1 ? " checked" : ""; }
|
|
1057
|
+
return "<form class=\"address-form form-stack\" method=\"post\" action=\"" + esc(action) + "\">" +
|
|
1058
|
+
_addrField("recipient_name", "Recipient name", addr.recipient_name, { required: true, maxlength: 120, autocomplete: "name" }) +
|
|
1059
|
+
_addrField("label", "Label (e.g. Home, Work)", addr.label, { maxlength: 60 }) +
|
|
1060
|
+
_addrField("company", "Company", addr.company, { maxlength: 120, autocomplete: "organization" }) +
|
|
1061
|
+
_addrField("street_line1", "Street address", addr.street_line1, { required: true, maxlength: 200, autocomplete: "address-line1" }) +
|
|
1062
|
+
_addrField("street_line2", "Apt / suite / unit", addr.street_line2, { maxlength: 200, autocomplete: "address-line2" }) +
|
|
1063
|
+
"<div class=\"form-row form-row--inline\">" +
|
|
1064
|
+
_addrField("city", "City", addr.city, { required: true, maxlength: 120, autocomplete: "address-level2" }) +
|
|
1065
|
+
_addrField("region", "State / region", addr.region, { maxlength: 120, autocomplete: "address-level1" }) +
|
|
1066
|
+
"</div>" +
|
|
1067
|
+
"<div class=\"form-row form-row--inline\">" +
|
|
1068
|
+
_addrField("postal_code", "Postal code", addr.postal_code, { required: true, maxlength: 32, autocomplete: "postal-code" }) +
|
|
1069
|
+
_addrField("country", "Country (ISO 3166-1)", addr.country || "US", { required: true, maxlength: 2, pattern: "[A-Za-z]{2}", autocomplete: "country" }) +
|
|
1070
|
+
"</div>" +
|
|
1071
|
+
_addrField("phone", "Phone", addr.phone, { maxlength: 40, autocomplete: "tel" }) +
|
|
1072
|
+
"<label class=\"address-form__check\"><input type=\"checkbox\" name=\"is_default_shipping\" value=\"1\"" + _checked(addr.is_default_shipping) + "> Default shipping address</label>" +
|
|
1073
|
+
"<label class=\"address-form__check\"><input type=\"checkbox\" name=\"is_default_billing\" value=\"1\"" + _checked(addr.is_default_billing) + "> Default billing address</label>" +
|
|
1074
|
+
"<button type=\"submit\" class=\"btn-primary\">" + esc(submitLabel) + "</button>" +
|
|
1075
|
+
"</form>";
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
// Account address book. `opts.addresses` is the customer's non-archived
|
|
1079
|
+
// rows; `opts.edit` (when set) pre-fills the form for editing that row,
|
|
1080
|
+
// otherwise the form is a blank "add" form.
|
|
1081
|
+
function renderAddresses(opts) {
|
|
1082
|
+
var esc = _b().template.escapeHtml;
|
|
1083
|
+
var list = opts.addresses || [];
|
|
1084
|
+
var rowsHtml = "";
|
|
1085
|
+
for (var i = 0; i < list.length; i += 1) {
|
|
1086
|
+
var a = list[i];
|
|
1087
|
+
var badges =
|
|
1088
|
+
(Number(a.is_default_shipping) === 1 ? "<span class=\"address-card__badge\">Default shipping</span>" : "") +
|
|
1089
|
+
(Number(a.is_default_billing) === 1 ? "<span class=\"address-card__badge\">Default billing</span>" : "");
|
|
1090
|
+
var lines = [a.recipient_name, a.company, a.street_line1, a.street_line2,
|
|
1091
|
+
[a.city, a.region, a.postal_code].filter(Boolean).join(", "), a.country, a.phone]
|
|
1092
|
+
.filter(function (x) { return x != null && String(x).length; })
|
|
1093
|
+
.map(function (x) { return "<span>" + esc(String(x)) + "</span>"; }).join("");
|
|
1094
|
+
rowsHtml +=
|
|
1095
|
+
"<li class=\"address-card\">" +
|
|
1096
|
+
(a.label ? "<p class=\"address-card__label\">" + esc(a.label) + "</p>" : "") +
|
|
1097
|
+
(badges ? "<p class=\"address-card__badges\">" + badges + "</p>" : "") +
|
|
1098
|
+
"<address class=\"address-card__body\">" + lines + "</address>" +
|
|
1099
|
+
"<div class=\"address-card__actions\">" +
|
|
1100
|
+
"<a class=\"btn-ghost btn-ghost--sm\" href=\"/account/addresses/" + esc(a.id) + "/edit\">Edit</a>" +
|
|
1101
|
+
(Number(a.is_default_shipping) === 1 ? "" : "<form method=\"post\" action=\"/account/addresses/" + esc(a.id) + "/default-shipping\"><button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">Set default shipping</button></form>") +
|
|
1102
|
+
(Number(a.is_default_billing) === 1 ? "" : "<form method=\"post\" action=\"/account/addresses/" + esc(a.id) + "/default-billing\"><button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">Set default billing</button></form>") +
|
|
1103
|
+
"<form method=\"post\" action=\"/account/addresses/" + esc(a.id) + "/archive\"><button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">Remove</button></form>" +
|
|
1104
|
+
"</div>" +
|
|
1105
|
+
"</li>";
|
|
1106
|
+
}
|
|
1107
|
+
var listHtml = rowsHtml
|
|
1108
|
+
? "<ul class=\"address-list\">" + rowsHtml + "</ul>"
|
|
1109
|
+
: "<p class=\"address-empty\">No saved addresses yet. Add one below to speed up checkout.</p>";
|
|
1110
|
+
var notice = opts.notice
|
|
1111
|
+
? "<p class=\"form-notice form-notice--error\" role=\"alert\">" + esc(String(opts.notice)) + "</p>"
|
|
1112
|
+
: "";
|
|
1113
|
+
var editing = opts.edit || null;
|
|
1114
|
+
var formHeading = editing ? "Edit address" : "Add an address";
|
|
1115
|
+
var formAction = editing ? ("/account/addresses/" + editing.id) : "/account/addresses";
|
|
1116
|
+
var body =
|
|
1117
|
+
"<section class=\"account-addresses\">" +
|
|
1118
|
+
"<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
|
|
1119
|
+
"<li><a href=\"/account\">Account</a></li>" +
|
|
1120
|
+
"<li aria-current=\"page\">Addresses</li>" +
|
|
1121
|
+
"</ol></nav>" +
|
|
1122
|
+
"<h1 class=\"account-addresses__title\">Addresses</h1>" +
|
|
1123
|
+
listHtml +
|
|
1124
|
+
"<h2 class=\"account-addresses__form-title\">" + esc(formHeading) + "</h2>" +
|
|
1125
|
+
notice +
|
|
1126
|
+
_addressForm(formAction, editing, editing ? "Save changes" : "Add address") +
|
|
1127
|
+
"</section>";
|
|
1128
|
+
return _wrap({
|
|
1129
|
+
title: "Addresses",
|
|
1130
|
+
shop_name: opts.shop_name || "blamejs.shop",
|
|
1131
|
+
cart_count: opts.cart_count == null ? 0 : opts.cart_count,
|
|
1132
|
+
theme_css: opts.theme_css,
|
|
1133
|
+
body: body,
|
|
1134
|
+
});
|
|
1135
|
+
}
|
|
1136
|
+
|
|
970
1137
|
// Product-level "Save to wishlist" control + social-proof count.
|
|
971
1138
|
// Byte-compatible with the edge renderer (`worker/render/product.js`)
|
|
972
1139
|
// so both paths emit identical markup. Action-only label — the toggle
|
|
@@ -1165,6 +1332,7 @@ var CART_LINE_EDITABLE =
|
|
|
1165
1332
|
" <td class=\"price\">{{unit}}</td>\n" +
|
|
1166
1333
|
" <td class=\"price\">{{total}}</td>\n" +
|
|
1167
1334
|
" <td class=\"cart-line__remove-cell\">\n" +
|
|
1335
|
+
" RAW_CART_LINE_SAVE" +
|
|
1168
1336
|
" <form method=\"post\" action=\"/cart/lines/{{line_id}}/remove\">\n" +
|
|
1169
1337
|
" <button type=\"submit\" class=\"cart-line__btn cart-line__btn--remove\" aria-label=\"Remove line\">Remove</button>\n" +
|
|
1170
1338
|
" </form>\n" +
|
|
@@ -1511,10 +1679,18 @@ function renderCart(opts) {
|
|
|
1511
1679
|
if (rendered.length === 0) {
|
|
1512
1680
|
body = CART_EMPTY_PAGE;
|
|
1513
1681
|
} else {
|
|
1682
|
+
var canSave = !!opts.can_save;
|
|
1514
1683
|
var rows = rendered.map(function (l) {
|
|
1515
1684
|
var thumb = l.image_url
|
|
1516
1685
|
? "<span class=\"cart-line__thumb\"><img src=\"" + _escAttr(l.image_url) + "\" alt=\"" + _escAttr(l.image_alt) + "\" loading=\"lazy\"></span>"
|
|
1517
1686
|
: "<span class=\"cart-line__thumb cart-line__thumb--empty\" aria-hidden=\"true\"></span>";
|
|
1687
|
+
// "Save for later" moves the line into the customer's saved list.
|
|
1688
|
+
// Rendered only when the feature is wired (and account auth is
|
|
1689
|
+
// present); the route itself enforces login, redirecting a guest
|
|
1690
|
+
// to sign in.
|
|
1691
|
+
var saveBtn = canSave
|
|
1692
|
+
? "<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>"
|
|
1693
|
+
: "";
|
|
1518
1694
|
return _render(CART_LINE_EDITABLE, {
|
|
1519
1695
|
sku: l.sku,
|
|
1520
1696
|
qty: l.qty,
|
|
@@ -1523,7 +1699,7 @@ function renderCart(opts) {
|
|
|
1523
1699
|
line_id: l.id,
|
|
1524
1700
|
product_title: l.product_title,
|
|
1525
1701
|
product_url: l.product_url,
|
|
1526
|
-
}).replace("RAW_CART_LINE_THUMB", thumb);
|
|
1702
|
+
}).replace("RAW_CART_LINE_THUMB", thumb).replace("RAW_CART_LINE_SAVE", saveBtn);
|
|
1527
1703
|
}).join("");
|
|
1528
1704
|
body = _render(CART_PAGE, {
|
|
1529
1705
|
line_rows: "RAW_LINES",
|
|
@@ -1887,7 +2063,9 @@ var ACCOUNT_DASH_PAGE =
|
|
|
1887
2063
|
" <p class=\"section-head__lede\">Your orders + account controls. Every order ships from origin with a Stripe-secured receipt.</p>\n" +
|
|
1888
2064
|
" </div>\n" +
|
|
1889
2065
|
" <div class=\"account-dash__actions\">\n" +
|
|
1890
|
-
" <a class=\"btn-secondary\" href=\"/account/wishlist\">
|
|
2066
|
+
" <a class=\"btn-secondary\" href=\"/account/wishlist\">Wishlist</a>\n" +
|
|
2067
|
+
" <a class=\"btn-secondary\" href=\"/account/saved\">Saved for later</a>\n" +
|
|
2068
|
+
" <a class=\"btn-secondary\" href=\"/account/addresses\">Addresses</a>\n" +
|
|
1891
2069
|
" <form method=\"post\" action=\"/account/logout\"><button type=\"submit\" class=\"btn-ghost\">Sign out</button></form>\n" +
|
|
1892
2070
|
" </div>\n" +
|
|
1893
2071
|
" </header>\n" +
|
|
@@ -2216,6 +2394,7 @@ function mount(router, deps) {
|
|
|
2216
2394
|
lines: lines,
|
|
2217
2395
|
totals: totals,
|
|
2218
2396
|
product_lookup: productLookup,
|
|
2397
|
+
can_save: !!(deps.saveForLater && deps.customers),
|
|
2219
2398
|
shop_name: shopName,
|
|
2220
2399
|
theme: theme,
|
|
2221
2400
|
}));
|
|
@@ -2785,6 +2964,256 @@ function mount(router, deps) {
|
|
|
2785
2964
|
});
|
|
2786
2965
|
}
|
|
2787
2966
|
|
|
2967
|
+
// Save for later — move a cart line into a per-customer holding
|
|
2968
|
+
// list and back. Login required (the list is per-customer).
|
|
2969
|
+
if (deps.saveForLater) {
|
|
2970
|
+
function _savedAuth(req, res) {
|
|
2971
|
+
var auth;
|
|
2972
|
+
try { auth = _currentCustomer(req); }
|
|
2973
|
+
catch (e) {
|
|
2974
|
+
if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
|
|
2975
|
+
throw e;
|
|
2976
|
+
}
|
|
2977
|
+
if (!auth) {
|
|
2978
|
+
res.status(303); res.setHeader && res.setHeader("location", "/account/login");
|
|
2979
|
+
res.end ? res.end() : res.send("");
|
|
2980
|
+
return null;
|
|
2981
|
+
}
|
|
2982
|
+
return auth;
|
|
2983
|
+
}
|
|
2984
|
+
|
|
2985
|
+
// POST /cart/lines/:line_id/save — move the line out of the cart
|
|
2986
|
+
// into the customer's saved list. Redirects back to /cart.
|
|
2987
|
+
router.post("/cart/lines/:line_id/save", async function (req, res) {
|
|
2988
|
+
var auth = _savedAuth(req, res);
|
|
2989
|
+
if (!auth) return;
|
|
2990
|
+
var sid = _readSidCookie(req);
|
|
2991
|
+
var cart = sid ? await deps.cart.bySession(sid) : null;
|
|
2992
|
+
if (!cart) {
|
|
2993
|
+
res.status(303); res.setHeader && res.setHeader("location", "/cart");
|
|
2994
|
+
return res.end ? res.end() : res.send("");
|
|
2995
|
+
}
|
|
2996
|
+
try {
|
|
2997
|
+
await deps.saveForLater.moveFromCart({
|
|
2998
|
+
customer_id: auth.customer_id,
|
|
2999
|
+
cart_id: cart.id,
|
|
3000
|
+
line_id: req.params && req.params.line_id,
|
|
3001
|
+
});
|
|
3002
|
+
} catch (e) {
|
|
3003
|
+
res.status(e instanceof TypeError ? 400 : 500);
|
|
3004
|
+
return res.end ? res.end((e && e.message) || "Error") : res.send((e && e.message) || "Error");
|
|
3005
|
+
}
|
|
3006
|
+
res.status(303); res.setHeader && res.setHeader("location", "/cart");
|
|
3007
|
+
return res.end ? res.end() : res.send("");
|
|
3008
|
+
});
|
|
3009
|
+
|
|
3010
|
+
// GET /account/saved — the customer's saved-for-later list.
|
|
3011
|
+
router.get("/account/saved", async function (req, res) {
|
|
3012
|
+
var auth = _savedAuth(req, res);
|
|
3013
|
+
if (!auth) return;
|
|
3014
|
+
var page = await deps.saveForLater.listForCustomer({ customer_id: auth.customer_id, limit: 50 });
|
|
3015
|
+
var items = [];
|
|
3016
|
+
for (var i = 0; i < page.rows.length; i += 1) {
|
|
3017
|
+
var row = page.rows[i];
|
|
3018
|
+
var product = null;
|
|
3019
|
+
if (row.variant_id) {
|
|
3020
|
+
try {
|
|
3021
|
+
var v = await deps.catalog.variants.get(row.variant_id);
|
|
3022
|
+
if (v) product = await deps.catalog.products.get(v.product_id);
|
|
3023
|
+
} catch (_e) { product = null; }
|
|
3024
|
+
}
|
|
3025
|
+
if (!product) { items.push({ save: row, product: null }); continue; }
|
|
3026
|
+
var media = await deps.catalog.media.listForProduct(product.id);
|
|
3027
|
+
items.push({ save: row, product: product, hero_media: media.length ? media[0] : null });
|
|
3028
|
+
}
|
|
3029
|
+
var cartCount = await _cartCountForReq(req);
|
|
3030
|
+
_send(res, 200, renderSaved({
|
|
3031
|
+
items: items,
|
|
3032
|
+
shop_name: shopName,
|
|
3033
|
+
cart_count: cartCount,
|
|
3034
|
+
asset_prefix: deps.asset_prefix || "/assets/",
|
|
3035
|
+
}));
|
|
3036
|
+
});
|
|
3037
|
+
|
|
3038
|
+
// POST /saved/:save_id/move-to-cart — move a saved row back into
|
|
3039
|
+
// the session cart (created if absent). Redirects to /cart.
|
|
3040
|
+
router.post("/saved/:save_id/move-to-cart", async function (req, res) {
|
|
3041
|
+
var auth = _savedAuth(req, res);
|
|
3042
|
+
if (!auth) return;
|
|
3043
|
+
var resolved = await _getOrCreateCart(req, res, "USD");
|
|
3044
|
+
try {
|
|
3045
|
+
await deps.saveForLater.moveToCart({
|
|
3046
|
+
customer_id: auth.customer_id,
|
|
3047
|
+
save_id: req.params && req.params.save_id,
|
|
3048
|
+
cart_id: resolved.cart.id,
|
|
3049
|
+
// Reprice to the live catalog price so the cart never carries
|
|
3050
|
+
// a stale snapshot; the saved page shows the snapshot for
|
|
3051
|
+
// reference only.
|
|
3052
|
+
use_price: "current",
|
|
3053
|
+
});
|
|
3054
|
+
} catch (e) {
|
|
3055
|
+
if (e instanceof TypeError) {
|
|
3056
|
+
res.status(400);
|
|
3057
|
+
return res.end ? res.end((e && e.message) || "Error") : res.send((e && e.message) || "Error");
|
|
3058
|
+
}
|
|
3059
|
+
// The cart enforces one line per (cart_id, variant_id). If the
|
|
3060
|
+
// shopper re-added this variant before moving the saved copy,
|
|
3061
|
+
// moveToCart's INSERT collides — but the end state they want
|
|
3062
|
+
// (variant in the cart) already holds. Drop the saved row and
|
|
3063
|
+
// treat it as success instead of surfacing a 500. The lib
|
|
3064
|
+
// leaves the save row intact on collision, so removing it here
|
|
3065
|
+
// is what completes the "move".
|
|
3066
|
+
if (/unique|constraint/i.test((e && e.message) || "")) {
|
|
3067
|
+
try {
|
|
3068
|
+
await deps.saveForLater.remove({ customer_id: auth.customer_id, save_id: req.params && req.params.save_id });
|
|
3069
|
+
} catch (_e) { /* drop-silent — the move is already effectively done */ }
|
|
3070
|
+
res.status(303); res.setHeader && res.setHeader("location", "/cart");
|
|
3071
|
+
return res.end ? res.end() : res.send("");
|
|
3072
|
+
}
|
|
3073
|
+
throw e;
|
|
3074
|
+
}
|
|
3075
|
+
res.status(303); res.setHeader && res.setHeader("location", "/cart");
|
|
3076
|
+
return res.end ? res.end() : res.send("");
|
|
3077
|
+
});
|
|
3078
|
+
|
|
3079
|
+
// POST /saved/:save_id/remove — drop a saved row.
|
|
3080
|
+
router.post("/saved/:save_id/remove", async function (req, res) {
|
|
3081
|
+
var auth = _savedAuth(req, res);
|
|
3082
|
+
if (!auth) return;
|
|
3083
|
+
try {
|
|
3084
|
+
await deps.saveForLater.remove({ customer_id: auth.customer_id, save_id: req.params && req.params.save_id });
|
|
3085
|
+
} catch (e) {
|
|
3086
|
+
res.status(e instanceof TypeError ? 400 : 500);
|
|
3087
|
+
return res.end ? res.end((e && e.message) || "Error") : res.send((e && e.message) || "Error");
|
|
3088
|
+
}
|
|
3089
|
+
res.status(303); res.setHeader && res.setHeader("location", "/account/saved");
|
|
3090
|
+
return res.end ? res.end() : res.send("");
|
|
3091
|
+
});
|
|
3092
|
+
}
|
|
3093
|
+
|
|
3094
|
+
// Address book — per-customer saved addresses. Every by-id route
|
|
3095
|
+
// verifies the address belongs to the authed customer before acting
|
|
3096
|
+
// (the primitive operates by id alone, so ownership is enforced here
|
|
3097
|
+
// to prevent cross-customer access via a guessed id).
|
|
3098
|
+
if (deps.addresses) {
|
|
3099
|
+
function _addrAuth(req, res) {
|
|
3100
|
+
var auth;
|
|
3101
|
+
try { auth = _currentCustomer(req); }
|
|
3102
|
+
catch (e) {
|
|
3103
|
+
if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
|
|
3104
|
+
throw e;
|
|
3105
|
+
}
|
|
3106
|
+
if (!auth) {
|
|
3107
|
+
res.status(303); res.setHeader && res.setHeader("location", "/account/login");
|
|
3108
|
+
res.end ? res.end() : res.send("");
|
|
3109
|
+
return null;
|
|
3110
|
+
}
|
|
3111
|
+
return auth;
|
|
3112
|
+
}
|
|
3113
|
+
async function _ownedAddress(req, res, auth) {
|
|
3114
|
+
var addr;
|
|
3115
|
+
try {
|
|
3116
|
+
addr = await deps.addresses.get(req.params && req.params.id);
|
|
3117
|
+
} catch (e) {
|
|
3118
|
+
// `get` throws TypeError on a non-UUID id — a malformed path
|
|
3119
|
+
// segment is a 404, not a 500.
|
|
3120
|
+
if (e instanceof TypeError) { _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme })); return null; }
|
|
3121
|
+
throw e;
|
|
3122
|
+
}
|
|
3123
|
+
if (!addr || addr.customer_id !== auth.customer_id || Number(addr.is_archived) === 1) {
|
|
3124
|
+
_send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
|
|
3125
|
+
return null;
|
|
3126
|
+
}
|
|
3127
|
+
return addr;
|
|
3128
|
+
}
|
|
3129
|
+
async function _renderAddrPage(req, res, auth, editAddr, notice, code) {
|
|
3130
|
+
var rows = await deps.addresses.listForCustomer(auth.customer_id, { limit: 50 });
|
|
3131
|
+
var cartCount = await _cartCountForReq(req);
|
|
3132
|
+
_send(res, code || 200, renderAddresses({
|
|
3133
|
+
addresses: rows,
|
|
3134
|
+
edit: editAddr || null,
|
|
3135
|
+
notice: notice || null,
|
|
3136
|
+
shop_name: shopName,
|
|
3137
|
+
cart_count: cartCount,
|
|
3138
|
+
}));
|
|
3139
|
+
}
|
|
3140
|
+
function _addrInput(body, customerId) {
|
|
3141
|
+
return {
|
|
3142
|
+
customer_id: customerId,
|
|
3143
|
+
label: body.label,
|
|
3144
|
+
recipient_name: body.recipient_name,
|
|
3145
|
+
company: body.company,
|
|
3146
|
+
street_line1: body.street_line1,
|
|
3147
|
+
street_line2: body.street_line2,
|
|
3148
|
+
city: body.city,
|
|
3149
|
+
region: body.region,
|
|
3150
|
+
postal_code: body.postal_code,
|
|
3151
|
+
country: body.country,
|
|
3152
|
+
phone: body.phone,
|
|
3153
|
+
// Checkboxes arrive as "1" when ticked, absent otherwise — the
|
|
3154
|
+
// primitive's _bool wants an integer, so coerce here.
|
|
3155
|
+
is_default_shipping: body.is_default_shipping === "1" ? 1 : 0,
|
|
3156
|
+
is_default_billing: body.is_default_billing === "1" ? 1 : 0,
|
|
3157
|
+
};
|
|
3158
|
+
}
|
|
3159
|
+
|
|
3160
|
+
router.get("/account/addresses", async function (req, res) {
|
|
3161
|
+
var auth = _addrAuth(req, res); if (!auth) return;
|
|
3162
|
+
await _renderAddrPage(req, res, auth, null);
|
|
3163
|
+
});
|
|
3164
|
+
|
|
3165
|
+
router.get("/account/addresses/:id/edit", async function (req, res) {
|
|
3166
|
+
var auth = _addrAuth(req, res); if (!auth) return;
|
|
3167
|
+
var addr = await _ownedAddress(req, res, auth); if (!addr) return;
|
|
3168
|
+
await _renderAddrPage(req, res, auth, addr);
|
|
3169
|
+
});
|
|
3170
|
+
|
|
3171
|
+
router.post("/account/addresses", async function (req, res) {
|
|
3172
|
+
var auth = _addrAuth(req, res); if (!auth) return;
|
|
3173
|
+
try {
|
|
3174
|
+
await deps.addresses.add(_addrInput(req.body || {}, auth.customer_id));
|
|
3175
|
+
} catch (e) {
|
|
3176
|
+
if (e instanceof TypeError) return _renderAddrPage(req, res, auth, null, (e && e.message) || "Please check the address.", 400);
|
|
3177
|
+
throw e;
|
|
3178
|
+
}
|
|
3179
|
+
res.status(303); res.setHeader && res.setHeader("location", "/account/addresses");
|
|
3180
|
+
return res.end ? res.end() : res.send("");
|
|
3181
|
+
});
|
|
3182
|
+
|
|
3183
|
+
router.post("/account/addresses/:id", async function (req, res) {
|
|
3184
|
+
var auth = _addrAuth(req, res); if (!auth) return;
|
|
3185
|
+
var addr = await _ownedAddress(req, res, auth); if (!addr) return;
|
|
3186
|
+
try {
|
|
3187
|
+
await deps.addresses.update(addr.id, _addrInput(req.body || {}, auth.customer_id));
|
|
3188
|
+
} catch (e) {
|
|
3189
|
+
if (e instanceof TypeError) {
|
|
3190
|
+
var merged = Object.assign({}, addr, _addrInput(req.body || {}, auth.customer_id));
|
|
3191
|
+
return _renderAddrPage(req, res, auth, merged, (e && e.message) || "Please check the address.", 400);
|
|
3192
|
+
}
|
|
3193
|
+
throw e;
|
|
3194
|
+
}
|
|
3195
|
+
res.status(303); res.setHeader && res.setHeader("location", "/account/addresses");
|
|
3196
|
+
return res.end ? res.end() : res.send("");
|
|
3197
|
+
});
|
|
3198
|
+
|
|
3199
|
+
function _addrAction(verb, fn) {
|
|
3200
|
+
router.post("/account/addresses/:id/" + verb, async function (req, res) {
|
|
3201
|
+
var auth = _addrAuth(req, res); if (!auth) return;
|
|
3202
|
+
var addr = await _ownedAddress(req, res, auth); if (!addr) return;
|
|
3203
|
+
try { await fn(addr.id); }
|
|
3204
|
+
catch (e) {
|
|
3205
|
+
res.status(e instanceof TypeError ? 400 : 500);
|
|
3206
|
+
return res.end ? res.end((e && e.message) || "Error") : res.send((e && e.message) || "Error");
|
|
3207
|
+
}
|
|
3208
|
+
res.status(303); res.setHeader && res.setHeader("location", "/account/addresses");
|
|
3209
|
+
return res.end ? res.end() : res.send("");
|
|
3210
|
+
});
|
|
3211
|
+
}
|
|
3212
|
+
_addrAction("default-shipping", function (id) { return deps.addresses.setDefaultShipping(id); });
|
|
3213
|
+
_addrAction("default-billing", function (id) { return deps.addresses.setDefaultBilling(id); });
|
|
3214
|
+
_addrAction("archive", function (id) { return deps.addresses.archive(id); });
|
|
3215
|
+
}
|
|
3216
|
+
|
|
2788
3217
|
// Product reviews — submission requires a logged-in customer AND a
|
|
2789
3218
|
// verified purchase of the product (the gate, not just a badge).
|
|
2790
3219
|
// 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.32",
|
|
7
|
+
"tag": "v0.12.32",
|
|
8
8
|
"license": "Apache-2.0",
|
|
9
9
|
"author": "blamejs contributors",
|
|
10
10
|
"source": "https://github.com/blamejs/blamejs",
|
|
@@ -8,6 +8,12 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.12.x
|
|
10
10
|
|
|
11
|
+
- v0.12.32 (2026-05-24) — **`b.cbor` — bounded, deterministic in-tree CBOR codec (RFC 8949).** CBOR is the binary serialization underneath COSE (RFC 9052), CWT, SCITT, and WebAuthn attestation — a foundational substrate the framework needs in-tree to build signed-statement primitives without a third-party parser. `b.cbor` is that codec, bounded by default like every parser the framework ships: a binary decoder is attack surface, so the defaults refuse the shapes a hostile encoder uses to exhaust memory or stack. The encoder emits Deterministically Encoded CBOR (RFC 8949 §4.2) — shortest-form heads, definite lengths, map keys sorted by encoded bytes, no indefinite-length items — so two semantically-equal values encode to byte-identical output, the property COSE signatures and SCITT receipts depend on. **Added:** *`b.cbor.encode(value, opts?)` / `b.cbor.decode(buffer, opts?)` / `b.cbor.Tag`* — `encode` produces deterministic CBOR from numbers (integers + float64), bigint (64-bit range), strings, `Buffer` / `Uint8Array`, arrays, `Map` or plain objects, `b.cbor.Tag`, and the simple values. `decode` returns the value with maps decoded to a `Map` (CBOR keys may be integers — COSE header labels are) and byte strings to `Buffer`. `b.cbor.Tag(tag, value)` carries a major-type-6 tagged item. `decode(buf, { requireDeterministic: true })` additionally asserts the input was itself canonically encoded (decode → re-encode → byte-compare), refusing a non-canonical re-encoding on a signature-verify path where it would be a malleability vector. **Security:** *Bounded-by-default decoder* — `maxDepth` (default 64, ceiling 256) caps nesting against stack exhaustion; `maxBytes` (default 16 MiB, ceiling 64 MiB) caps total input, and a declared string / array / map length exceeding the remaining bytes is refused before any allocation (no length-prefix memory bomb). Indefinite-length items (additional-info 31) are refused — a streaming-complexity / DoS vector forbidden by deterministic encoding. Reserved additional-info (28–30) is refused. Tags are refused unless allowlisted via `allowedTags` (a tag triggers semantic reprocessing — an un-vetted tag is a confused-deputy vector). Duplicate map keys (RFC 8949 §5.6) and trailing bytes after the data item are refused.
|
|
12
|
+
|
|
13
|
+
- v0.12.31 (2026-05-24) — **`b.auth.jar.parse` — verify RFC 9101 JWT-Secured Authorization Requests (server side).** A plain OAuth authorization request carries its parameters in the URL query string, where a browser, proxy, or referer log can tamper with or leak them. RFC 9101 JAR packs those parameters into a JWT the client signs — the request object — so the authorization server can confirm they arrived exactly as sent. `b.auth.jar.parse(jar, opts)` is the server-side verifier and the request-side counterpart to the existing JARM response handling (`b.auth.oauth.parseJarmResponse`). It delegates the signature check to `b.auth.jwt.verifyExternal` — which already enforces a mandatory `algorithms` allowlist and refuses the alg-confusion (`alg: "none"`, HMAC-vs-RSA) and JWE-on-a-JWS-verifier shapes against a JWKS public-key trust source — then pins `iss` and the `client_id` claim to the expected client, pins `aud` to this server's issuer identifier, refuses a nested `request` / `request_uri` (RFC 9101 §6.3 recursion / confused-deputy vector), and returns the authorization parameters with the JWT envelope claims stripped. **Added:** *`b.auth.jar.parse(jar, opts)` — request-object verification* — `opts.clientId` (the expected client — pins `iss` + the `client_id` claim), `opts.audience` (this server's issuer identifier — pins `aud`), `opts.algorithms` (required signature allowlist — no defaults, the alg-confusion defense), and one of `opts.jwks` / `opts.jwksUri` / `opts.keyResolver` (the client's verification key). Returns `{ params, claims }` where `params` is the authorization parameters (`response_type`, `redirect_uri`, `scope`, `state`, `nonce`, …) with the JWT envelope claims (`iss`, `aud`, `exp`, `iat`, `nbf`, `jti`) removed. A request object whose `client_id` claim disagrees with `opts.clientId`, or that nests a `request` / `request_uri`, is refused. Emitting a request object (the client side) is deferred-with-condition: it requires signing with the client's key under a classical JWS algorithm, and the framework's own JWT signer is PQC-only for the tokens it issues — a PQC-signed request object would not interoperate with a standard authorization server; client-side emission re-opens when a classical JWS signer lands or operators surface the need. Until then clients sign request objects with their existing JOSE tooling.
|
|
14
|
+
|
|
15
|
+
- v0.12.30 (2026-05-24) — **`bundleAdapterStorage.keyRotation(opts)` — verified whole-repository envelope key rotation.** Rotating the key that wraps a backup repository is only safe if you can prove every bundle still reads under the new key — a rotation that silently corrupts one bundle is a time-bomb the operator discovers at restore time, exactly when they can least afford it. `storage.keyRotation(opts)` rotates every bundle's envelope from the old key to the new key (composing `rewrapAllBundles`) and then re-reads every bundle under the NEW key (composing `verifyAllBundles`), so a bad rotation surfaces as `verifyFailed > 0` immediately instead of at restore. It emits a `backup/key-rotated` audit event with the rotation id + per-status counts — a key-rotation event is a compliance record (SOC 2 CC6.1, PCI DSS 3.6.4) operators wire into their signed audit chain. Works for both `recipient` (hybrid PQC envelope) and `passphrase` (Argon2id) storage; refused cleanly on plaintext (`cryptoStrategy: "none"`) storage and when the new key is missing. **Added:** *`bundleAdapterStorage.keyRotation(opts)` — rotate then prove* — `opts.newRecipient` / `opts.newPassphrase` is the key bundles rotate TO (matched to the storage's `cryptoStrategy`); `opts.oldRecipient` / `opts.oldPassphrase` unwraps the current envelope when it differs from the configured key. Returns `{ rotationId, rotatedAt, total, rotated, skipped, failed, verified, verifyFailed, rotateResults, verifyResults }`. `opts.verify` (default true) runs the post-rotation read-back under the new key; `opts.concurrency` / `opts.stopOnFirstFailure` forward to the batch passes. Plaintext bundles + non-wrappable formats are skipped cleanly; a rotation that leaves any bundle unreadable reports `verifyFailed > 0` and emits the audit event with `outcome: "failure"`. A true overlap window where BOTH the old and new key decrypt a bundle (`dualWrap: true`) is refused with `backup/dual-wrap-unsupported` — it needs multi-recipient archive envelopes `b.archive.wrap` does not yet emit, and re-opens when the wrap layer gains them; until then stage a rotation by keeping the old key available to readers until `keyRotation` reports `failed: 0` + `verifyFailed: 0`, then retire it.
|
|
16
|
+
|
|
11
17
|
- 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
18
|
|
|
13
19
|
- 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.
|
|
@@ -75,6 +75,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
|
|
|
75
75
|
- RP-Initiated / Front-Channel / Back-Channel Logout 1.0 (`parseFrontchannelLogoutRequest` + `verifyBackchannelLogoutToken` with jti-replay defense)
|
|
76
76
|
- RFC 9207 AS Issuer Identifier validation on callbacks (`parseCallback` — refuses iss mismatch + OP `error=` redirect)
|
|
77
77
|
- OAuth 2.0 JARM signed-response decode (`parseJarmResponse`)
|
|
78
|
+
- RFC 9101 JWT-Secured Authorization Request verification — server-side request-object parse with mandatory alg allowlist + iss/client_id/aud binding + anti-nesting (`b.auth.jar.parse`)
|
|
78
79
|
- One-time-use refresh-token rotation with operator-supplied replay-defense callback (RFC 9700 §4.13 / OAuth 2.1 §6.1 — `refreshAccessToken({ seen })`)
|
|
79
80
|
- **Federation / VC** — CIBA Core 1.0 (`b.auth.ciba`, poll/ping/push); OpenID Federation 1.0 trust chain + metadata_policy (`b.auth.openidFederation`); SAML 2.0 SP with XMLDSig signature-wrapping defense + RFC 9525 server-identity (`b.auth.saml`); OpenID4VCI 1.0 issuer (`b.auth.oid4vci`); OpenID4VP 1.0 verifier with DCQL (`b.auth.oid4vp`); SD-JWT VC with `key_attestation` extension (`b.auth.sdJwtVc`)
|
|
80
81
|
- **Sessions** — `b.session`
|
|
@@ -124,6 +125,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
|
|
|
124
125
|
|
|
125
126
|
- **JSON / SQL / schema** — `b.safeJson` (with `maxKeys` cap defending CVE-2026-21717 V8 HashDoS), `b.safeBuffer`, `b.safeSql`, `b.safeSchema`
|
|
126
127
|
- **URL + path** — `b.safeUrl` (IDN mixed-script / homograph refuse); `b.safeJsonPath` (refuses filter `?(...)`, deep-scan `$..`, script-shape `(@.x)` for safe Postgres JSONB ops)
|
|
128
|
+
- **Binary codec** — `b.cbor` bounded deterministic CBOR (RFC 8949 §4.2): depth/size caps, indefinite-length + reserved-info + tag + duplicate-key refusal, `requireDeterministic` canonical-form check; the in-tree substrate under COSE / CWT / SCITT / WebAuthn attestation
|
|
127
129
|
- **Document parsers** — `b.parsers` (XML / TOML / YAML / .env); `b.config` (schema-validated env)
|
|
128
130
|
- **File-type detection** — `b.fileType` magic-byte content classification with deny-on-upload categories (image / document / archive / executable / etc.)
|
|
129
131
|
### Content-safety gates
|