@blamejs/blamejs-shop 0.4.126 → 0.4.128

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 CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.4.x
10
10
 
11
+ - v0.4.128 (2026-06-27) — **Email sign-in links are now rate-limited per recipient, bounding inbox-flood and token-minting abuse.** The email magic-link sign-in — the no-passkey backup on the login screen — minted a new single-use sign-in token and sent an email on every POST for a registered address, bounded only by the per-IP request limiter. An attacker submitting a victim's address from many IP addresses could therefore flood that inbox with sign-in links and mint an unbounded number of valid single-use tokens. The route now applies a per-recipient cooldown: at most one sign-in link (and one minted token) per registered address per minute. The cooldown runs after the unchanged, enumeration-safe confirmation response, so a registered and an unregistered address stay indistinguishable by the reply; a transient lookup failure falls through to sending, keeping availability ahead of the rate cap. **Fixed:** *Email sign-in links are rate-limited per recipient* — Requesting an email sign-in link minted a single-use portal token and sent a message every time it was submitted for a registered address, with only the per-IP limiter in front of it — which a distributed sender (many IPs, one victim address) sails past. The request now checks whether a sign-in link was already minted for that account within the last minute and, if so, sends nothing further. The check runs on the request's background path, after the identical confirmation response is returned, so it does not reintroduce an account-existence oracle (a registered and an unregistered address still get the same reply at the same speed); an address with no account still triggers no mint or send at all. A transient failure of the cooldown lookup falls through to sending, so a degraded datastore can't lock legitimate shoppers out of signing in.
12
+
13
+ - v0.4.127 (2026-06-27) — **Customer-segment-restricted automatic discounts now work instead of breaking the discount quote for signed-in shoppers.** An automatic-discount rule limited to a customer segment (for example a members-only offer) never functioned: the discount engine was constructed without the customer-segments handle it needs to test membership, so the moment any segment-restricted rule existed, computing the cart and checkout discount threw an error for every signed-in shopper — breaking the discount quote for authenticated customers — while the discount applied to no one. Guests were unaffected because a segment rule is skipped for a shopper with no account. The engine is now wired with the segments handle, so a segment-restricted rule correctly applies to shoppers who are in the segment, is skipped for shoppers who are not, and is skipped for guests; rules that are not segment-restricted are entirely unaffected. **Fixed:** *Customer-segment-restricted automatic discounts apply correctly and no longer break the quote* — The automatic-discount engine is now created with the customer-segments handle. Previously the storefront and checkout wired the engine without it, so evaluating a rule carrying a customer-segment restriction threw the moment a signed-in shopper reached the cart or checkout — the discount engine requires the handle to answer "is this customer in the segment?" and had none. The result was that a segment-restricted offer applied to nobody and broke the discount calculation for every authenticated shopper whenever such a rule was active. With the handle wired, a segment-restricted rule applies to shoppers in the segment, is cleanly skipped for shoppers outside it and for guests, and rules without a segment restriction behave exactly as before.
14
+
11
15
  - v0.4.126 (2026-06-27) — **Storefront error responses no longer echo internal error detail, and order/account pages render images from the configured asset prefix.** Two storefront correctness fixes. First, around fifteen storefront routes (cart line edit/remove, product compare, saved-for-later, account actions and more) returned the raw error message to the client on an unexpected server error — a string that can carry an internal database column, a vault/crypto detail, or other implementation internals. Those routes now distinguish a client-shape validation error (a 400 that still shows its operator-authored message) from an unexpected server error (a 500 that logs the real error server-side, correlated by the request id, and returns only a generic message plus that id) — the same redaction the WebAuthn ceremony routes already used. Second, the order-detail and account-dashboard pages were rendering product thumbnails from the default "/assets/" path instead of the operator's configured asset prefix, so on a deployment that serves assets from a CDN or object-store prefix those two pages showed broken images; both pages now pass the configured prefix like every other page. **Fixed:** *Storefront routes no longer echo the internal error message on a 500* — About fifteen storefront routes — cart line update/remove, product-compare add/clear, saved-for-later move/remove, and several account actions — handled a caught error with `status(TypeError ? 400 : 500)` and then returned the raw error message for both cases, so an unexpected (non-validation) server error sent its internal message string to the client. These routes now return that message only for a client-shape validation error (a 400); any other error is a 500 that records the real error server-side against the request id and returns a generic "Something went wrong" message plus that id for support correlation. This matches the redaction the WebAuthn register/login routes already applied. · *Order-detail and account pages render images from the configured asset prefix* — The order-detail (`/orders/:id`) and account-dashboard (`/account`) renderers were not passed the configured asset prefix, so they fell back to the default `/assets/` path for product thumbnails while every other page used the operator's prefix. On a deployment that serves static assets from a CDN or object-store prefix, those two pages showed broken images. Both now pass the configured prefix, so their images load from the same place as the rest of the storefront.
12
16
 
13
17
  - v0.4.125 (2026-06-27) — **Vendored framework refreshed to 0.15.34 — RSA OpenPGP signatures with a high zero byte now verify reliably.** Updates the vendored blamejs framework to 0.15.34. The upstream release fixes the framework's OpenPGP verification: an RSA signature whose value began with one or more zero bytes — about 1 in 256 of all signatures, for any key — was rejected as invalid even though it was correct, because the OpenPGP wire format strips those leading zero bytes and the verifier handed the shortened signature straight to RSA verification (which requires the signature to be exactly the key's modulus length). The signature is now left-padded back to the modulus width before verification, so every valid RSA signature verifies reliably, including signatures produced by other OpenPGP implementations. The release also carries internal test-suite tooling with no runtime effect. **Changed:** *Vendored framework refreshed to 0.15.34 (reliable RSA OpenPGP signature verification)* — Refreshes the vendored blamejs framework to 0.15.34. The upstream fix left-pads an RSA OpenPGP signature back to the key's modulus byte length before verifying it, so a valid signature whose value carried one or more leading zero bytes (~1 in 256, for any key) is no longer intermittently rejected — matching the padding the Ed25519 verification path already applied. Any code path that verifies OpenPGP RSA signatures through the framework now accepts every correct signature. The rest of the release is internal test-suite tooling with no runtime, public-API, or wire-format change.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.126",
2
+ "version": "0.4.128",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-imfe0otYErcB8rr2h6KLSGTtStirysptpXETSPY4zLv3bZoIT75Lo1dOvkOav+xL",
@@ -191,14 +191,44 @@ function create(opts) {
191
191
  var now = _now();
192
192
  var expiresAt = now + (ttl * 1000); // allow:raw-time-literal — ttl is a runtime seconds value; *1000 converts to ms
193
193
 
194
- await query(
195
- "INSERT INTO customer_portal_sessions " +
196
- "(id, customer_id, token_hash, scope, status, " +
197
- " consumed_at, revoked_at, revoke_reason, ip_hash, ua_class, " +
198
- " created_at, expires_at) " +
199
- "VALUES (?1, ?2, ?3, ?4, 'issued', NULL, NULL, NULL, ?5, ?6, ?7, ?8)",
200
- [id, customerId, tokenHash, scope, ipHash, uaClass, now, expiresAt],
201
- );
194
+ // Optional per-customer mint throttle. When `min_interval_seconds` is set,
195
+ // the session is minted ONLY if no session was created for this customer
196
+ // within that interval enforced as ONE conditional statement
197
+ // (INSERT ... SELECT ... WHERE NOT EXISTS), so concurrent callers can't
198
+ // each read an empty window before any of them inserts. The public email
199
+ // sign-in-link route passes this to bound inbox-flood / token-mint abuse
200
+ // of a registered address; a rowCount of 0 means a recent session already
201
+ // exists, so this call mints nothing and returns `{ throttled: true }`
202
+ // (no token). Callers that omit the option mint unconditionally as before.
203
+ var minInterval = input.min_interval_seconds;
204
+ if (minInterval != null && (typeof minInterval !== "number" || !isFinite(minInterval) || minInterval < 0)) {
205
+ throw new TypeError("customer-portal.createSession: min_interval_seconds must be a non-negative number");
206
+ }
207
+ if (minInterval) {
208
+ var sinceMs = now - Math.floor(minInterval * 1000); // allow:raw-time-literal — minInterval is a runtime seconds value; *1000 converts to ms
209
+ var ins = await query(
210
+ "INSERT INTO customer_portal_sessions " +
211
+ "(id, customer_id, token_hash, scope, status, " +
212
+ " consumed_at, revoked_at, revoke_reason, ip_hash, ua_class, " +
213
+ " created_at, expires_at) " +
214
+ "SELECT ?1, ?2, ?3, ?4, 'issued', NULL, NULL, NULL, ?5, ?6, ?7, ?8 " +
215
+ "WHERE NOT EXISTS (SELECT 1 FROM customer_portal_sessions " +
216
+ "WHERE customer_id = ?2 AND created_at >= ?9)",
217
+ [id, customerId, tokenHash, scope, ipHash, uaClass, now, expiresAt, sinceMs],
218
+ );
219
+ if (Number(ins.rowCount || 0) === 0) {
220
+ return { throttled: true };
221
+ }
222
+ } else {
223
+ await query(
224
+ "INSERT INTO customer_portal_sessions " +
225
+ "(id, customer_id, token_hash, scope, status, " +
226
+ " consumed_at, revoked_at, revoke_reason, ip_hash, ua_class, " +
227
+ " created_at, expires_at) " +
228
+ "VALUES (?1, ?2, ?3, ?4, 'issued', NULL, NULL, NULL, ?5, ?6, ?7, ?8)",
229
+ [id, customerId, tokenHash, scope, ipHash, uaClass, now, expiresAt],
230
+ );
231
+ }
202
232
 
203
233
  // The plaintext leaves this function exactly once. The row
204
234
  // above is the only durable handle to the session; the hash
@@ -825,6 +825,25 @@ function create(opts) {
825
825
  return out;
826
826
  },
827
827
 
828
+ // Membership predicate: is this customer in the named (non-archived)
829
+ // segment? A single existence check over the same membership join
830
+ // `segmentsForCustomer` reads — so a caller gating one rule on one segment
831
+ // doesn't load the customer's whole segment list. This is the handle shape
832
+ // the autoDiscount engine's `customer_segment_in` gate composes
833
+ // (`isMember(customer_id, segment_slug) -> Promise<bool>`); a slug that
834
+ // names no segment (or an archived one) is simply not a member.
835
+ isMember: async function (customerId, segmentSlug) {
836
+ _uuid(customerId, "customer_id");
837
+ var slug = _slug(segmentSlug);
838
+ var r = await query(
839
+ "SELECT 1 FROM customer_segment_membership csm " +
840
+ "JOIN customer_segments cs ON cs.id = csm.segment_id " +
841
+ "WHERE csm.customer_id = ?1 AND cs.slug = ?2 AND cs.archived_at IS NULL LIMIT 1",
842
+ [customerId, slug],
843
+ );
844
+ return r.rows.length > 0;
845
+ },
846
+
828
847
  listSegments: async function (listOpts) {
829
848
  listOpts = listOpts || {};
830
849
  var includeArchived = !!listOpts.include_archived;
package/lib/storefront.js CHANGED
@@ -10212,6 +10212,14 @@ var ACCOUNT_LOGIN_PAGE =
10212
10212
  " RAW_LOGIN_SCRIPT\n" +
10213
10213
  "</section>\n";
10214
10214
 
10215
+ // Per-recipient cooldown for the email magic-link: at most one sign-in link
10216
+ // (and one minted single-use token) per registered address per window. The
10217
+ // per-IP limiter doesn't bound a DISTRIBUTED attacker POSTing a victim's
10218
+ // address from many IPs, so without this each POST mints a token + sends a
10219
+ // mail — an inbox-flood + unbounded-token-mint vector. Enforced AFTER the
10220
+ // enumeration-safe 303, so it never changes the response.
10221
+ var MAGIC_LINK_RESEND_COOLDOWN_MS = b.constants.TIME.minutes(1);
10222
+
10215
10223
  // The inline email magic-link path on the unified login screen. A distinct
10216
10224
  // server-rendered <form> (its own email field) that POSTs to
10217
10225
  // /account/login/link with no JavaScript — the `_injectCsrfFields` wrap
@@ -16145,10 +16153,22 @@ function mount(router, deps) {
16145
16153
 
16146
16154
  if (customerId) {
16147
16155
  Promise.resolve().then(async function () {
16156
+ // Per-recipient cooldown enforced ATOMICALLY in the mint itself
16157
+ // (createSession with min_interval_seconds is a single conditional
16158
+ // INSERT): at most one sign-in link + one minted token per registered
16159
+ // address per window, even when a distributed attacker fires many
16160
+ // POSTs for the same address concurrently (the per-IP limiter can't
16161
+ // bound that, and a separate read-then-insert check would let the
16162
+ // concurrent burst all pass before any insert). Runs off the
16163
+ // response's critical path, so it never restores the account-
16164
+ // existence oracle. A throttled call mints nothing and returns no
16165
+ // token → no link is sent this window.
16148
16166
  var minted = await deps.customerPortal.createSession({
16149
- customer_id: customerId,
16150
- scope: "full",
16167
+ customer_id: customerId,
16168
+ scope: "full",
16169
+ min_interval_seconds: MAGIC_LINK_RESEND_COOLDOWN_MS / 1000,
16151
16170
  });
16171
+ if (!minted || !minted.plaintext_token) return;
16152
16172
  var linkUrl = origin + "/account/portal/" + encodeURIComponent(minted.plaintext_token);
16153
16173
  // The customer's plaintext address: the portal flow needs a
16154
16174
  // deliverable address. The customers store keeps only the hash,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.126",
3
+ "version": "0.4.128",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {