@blamejs/blamejs-shop 0.4.92 → 0.4.94

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.94 (2026-06-25) — **A print-on-demand fulfillment status change is now an atomic claim — concurrent updates can no longer clobber each other or record a phantom transition.** Advancing a print-on-demand fulfillment through its lifecycle (submitted → shipped / failed / cancelled) read the row's current status, validated the move in memory, and then wrote the new status with an UPDATE keyed on the row id alone. Two updates racing the same fulfillment — a worker marking it shipped while an operator marks it failed, or a re-delivered supplier webhook — both read the same starting status, both passed the in-memory check, and both wrote unconditionally, so the later write silently overwrote the earlier one's status and columns (a lost update), and each emitted a 'success' transition into the audit trail even though only one write reflected reality. The status write is now a single-statement atomic claim gated on the row still being in the state it was read in (the same compare-and-swap the dropship-forwarding and returns lifecycles already use): exactly one update wins, the loser changes zero rows and is refused, and the audit record is written only for the update that actually changed the row. No configuration changes. **Fixed:** *Concurrent print-on-demand fulfillment transitions no longer clobber each other or log a phantom transition* — The fulfillment status update was keyed on the row id with no guard on the starting state, so two concurrent transitions both wrote unconditionally — last-writer-wins overwrote the other's status and stamped columns, and both emitted a 'success' transition audit. The update is now an atomic compare-and-swap gated on the row still being in the from-state, with the destination resolved side-effect-free and the audit emitted only on the winning write. The losing transition changes zero rows and is refused with a clear concurrency error instead of corrupting the record.
12
+
13
+ - v0.4.93 (2026-06-25) — **Per-IP rate limits now key an IPv6 client by its /64 prefix, closing an address-rotation bypass of the login, checkout, and webhook throttles.** The request-throttling layer derived its per-client key from the full client IP address. For an IPv4 client that is one host, but an IPv6 end-site is allocated an entire /64 prefix (RFC 6177 / RFC 4291) and can freely rotate the low 64 bits, presenting a fresh address — and therefore a brand-new rate-limit bucket — on every request. That let a single IPv6 site walk straight through every per-IP ceiling: the global request limiter, the tight per-IP limiter on login / register / passkey / checkout, the PayPal webhook verification budget, and the per-IP captcha budget. The client key now collapses an IPv6 address to its routing-significant /64 (an IPv4 address is still keyed verbatim, and an IPv4-mapped IPv6 keys as its dotted host), so one end-site shares one bucket while distinct sites stay distinct. A value that is not a parseable IP falls back to itself, so two unidentifiable clients never collapse into one shared bucket. No configuration changes. **Fixed:** *IPv6 clients can no longer rotate addresses to bypass per-IP rate limits* — Every per-IP throttle — the global request limiter, the tight limiter on authentication and checkout routes, the PayPal webhook verification budget, and the captcha budget — keyed off the full client IP. An IPv6 client controls a whole /64 and could rotate the low 64 bits to mint an unlimited supply of fresh buckets, defeating each ceiling. The per-client key now masks an IPv6 address to its /64 prefix (IPv4 is unchanged), so a single end-site is held to one bucket. The fix lives in the client-key derivation itself, because the framework's rate-limit key mode is bypassed whenever a custom key function is supplied — as every limiter here does.
14
+
11
15
  - v0.4.92 (2026-06-25) — **Refresh the vendored framework to 0.15.23 — a state transition whose entry hook throws is now always recorded in the audit trail.** Updates the vendored blamejs framework from 0.15.20 to 0.15.23. The behavioural change operators will notice is in the order, return, redemption, and dropship state machines, which compose the framework's finite-state-machine primitive: previously, if a destination state's entry hook threw, the state change committed but no audit record was written — leaving a privileged transition unaudited. The framework now always records the transition (stamped with a failure outcome and the hook error) and still re-raises the error, so a state change can never be silently unaudited. The refresh also carries upstream correctness and hardening work in primitives the storefront does not currently reach (a websocket-client reconnect fix, a cross-platform path-containment fix), plus additive primitives the application will adopt in follow-up changes. No configuration changes. **Changed:** *State transitions are always audited, even when an entry hook fails* — The order, return, redemption, and dropship-forwarding state machines record every transition on the framework's tamper-evident audit chain. A transition whose destination entry hook threw previously committed the state change without emitting that record. The refreshed framework always writes the audit record — stamped with a failure outcome and the entry-hook error — and still re-raises the error to the caller, so a privileged state change is never left unaudited.
12
16
 
13
17
  - v0.4.91 (2026-06-24) — **A gift-card refund is now linked to the order it reverses, so an order's gift-card settlement history is complete.** When a refund returns money to a gift card (a cancelled or refunded order that had been paid with the card), the ledger credit now records the order it reverses in the structured order field, not only as a free-text reference. Previously a query for a given order's gift-card movements returned the debits that spent the card but not the refund credits that returned money to it, so the order's gift-card settlement history read as one-sided. The refund credit now carries the order link, so the order's gift-card history shows both the spend and the matching refund. No configuration changes, and existing gift-card behavior — balances, overdraft refusal, and the per-card tamper-evident hash chain — is unchanged. **Fixed:** *Refund-to-gift-card credits carry the order they reverse* — giftCardLedger.credit now accepts an optional order_id, and the order-cancellation/refund path that returns a redeemed amount to a gift card passes the order it reverses. The ledger's transactionsForOrder(order_id) lookup keys on that structured field, so it now returns the refund credits flowing back to a card alongside the debits that drained it for the order — previously the credits were keyed only by a free-text reference and were absent from the lookup. Each refund credit still participates in the per-card hash chain (the order link is covered by the row hash), and credits with no associated order (promotional or manual top-ups) are unaffected.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.92",
2
+ "version": "0.4.94",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-imfe0otYErcB8rr2h6KLSGTtStirysptpXETSPY4zLv3bZoIT75Lo1dOvkOav+xL",
@@ -282,17 +282,24 @@ function create(opts) {
282
282
  history: [],
283
283
  context: {},
284
284
  });
285
- try {
286
- await instance.transition(event, null);
287
- } catch (e) {
288
- var err = new Error("print-on-demand: fulfillment transition refused " + (e && e.message || e));
289
- err.code = (e && e.code) || "POD_FULFILLMENT_TRANSITION_REFUSED";
290
- err.cause = e;
285
+ // Resolve the destination state IN MEMORY, side-effect-free, with
286
+ // `target` — it runs the same edge + guard check `transition` does but
287
+ // emits NO audit and mutates no state (returns null for an illegal edge
288
+ // or a guard-refused one). We must NOT call `transition` here: it emits a
289
+ // "success" `fsm.pod_fulfillment.transition` audit, and emitting before
290
+ // the atomic claim below would record a phantom success for a caller that
291
+ // goes on to LOSE the race (the conditional UPDATE matches zero rows). The
292
+ // real `transition` emit is deferred to the winning branch.
293
+ var toState = instance.target(event);
294
+ if (toState == null) {
295
+ var err = new Error("print-on-demand: fulfillment transition refused — illegal edge '" +
296
+ event + "' from state '" + current.status + "'");
297
+ err.code = "POD_FULFILLMENT_TRANSITION_REFUSED";
291
298
  throw err;
292
299
  }
293
300
  var ts = _now();
294
301
  var sets = ["status = ?1", "updated_at = ?2"];
295
- var params = [instance.state, ts];
302
+ var params = [toState, ts];
296
303
  var p = 3;
297
304
  var keys = Object.keys(patch || {});
298
305
  for (var i = 0; i < keys.length; i += 1) {
@@ -310,11 +317,35 @@ function create(opts) {
310
317
  params.push(patch[k]);
311
318
  p += 1;
312
319
  }
313
- params.push(fulfillmentId);
314
- await query(
315
- "UPDATE pod_fulfillments SET " + sets.join(", ") + " WHERE id = ?" + p,
320
+ params.push(fulfillmentId); // ?p
321
+ params.push(current.status); // ?(p+1)
322
+ // Cross-instance atomic claim the transaction substitute on the D1
323
+ // bridge. `target` above only validated the edge IN MEMORY (its lock is
324
+ // per-instance, and each request restores a fresh instance), so two
325
+ // concurrent transitions on the same row both pass it. Gating the write on
326
+ // the row STILL being in the from-state we resolved against
327
+ // (`AND status = <from>`) is the single-statement serialization point:
328
+ // exactly one writer changes the row, the loser changes zero rows and is
329
+ // refused (no last-writer-wins clobber of the winner's state + columns).
330
+ var upd = await query(
331
+ "UPDATE pod_fulfillments SET " + sets.join(", ") +
332
+ " WHERE id = ?" + p + " AND status = ?" + (p + 1),
316
333
  params,
317
334
  );
335
+ if (Number(upd.rowCount || 0) !== 1) {
336
+ var raced = new Error("print-on-demand: fulfillment " + fulfillmentId +
337
+ " changed state concurrently (no longer " + current.status + ") — transition refused");
338
+ raced.code = "POD_FULFILLMENT_TRANSITION_REFUSED";
339
+ throw raced;
340
+ }
341
+ // We won the atomic claim — the row really did advance current.status ->
342
+ // toState. NOW drive the transition through b.fsm so its
343
+ // `fsm.pod_fulfillment.transition=success` audit event fires exactly once
344
+ // and only for the writer that actually changed the row. `target` above
345
+ // already validated this edge, so `transition` cannot refuse; the row is
346
+ // already persisted, so a throw here must never fail the caller.
347
+ try { await instance.transition(event, null); }
348
+ catch (_emitErr) { /* edge pre-validated by target(); audit emit is best-effort */ }
318
349
  return await _getFulfillmentRow(fulfillmentId);
319
350
  }
320
351
 
@@ -283,15 +283,33 @@ var PUBLIC_ORIGINS = (process.env.SHOP_PUBLIC_ORIGINS || "https://blamejs.shop")
283
283
  * non-empty string so two un-identifiable clients never collapse into
284
284
  * the same bucket as a real IP — request-shape reader, returns a
285
285
  * default, never throws.
286
+ *
287
+ * The resolved address is collapsed to its rate-limit-significant key via
288
+ * `b.requestHelpers.ipKey`: an IPv4 host is kept verbatim, but an IPv6
289
+ * client is masked to its /64 prefix. A single end-site is allocated a
290
+ * whole IPv6 /64 (RFC 6177 / RFC 4291) and freely rotates the low 64 bits,
291
+ * so keying on the full 128-bit address would let one site present a fresh
292
+ * source on every request and mint unlimited buckets — walking every
293
+ * per-IP limiter (and the captcha-IP budget) that keys off this value.
294
+ * Keying on the /64 closes that while still distinguishing real end-sites.
286
295
  */
287
296
  function clientKey(req) {
288
297
  var headers = (req && req.headers) || {};
289
298
  var cf = headers["cf-connecting-ip"];
290
- if (typeof cf === "string" && cf.length > 0) return cf.trim();
299
+ if (typeof cf === "string" && cf.length > 0) return _ipBucket(cf.trim());
291
300
  var real = headers["x-real-ip"];
292
- if (typeof real === "string" && real.length > 0) return real.trim();
301
+ if (typeof real === "string" && real.length > 0) return _ipBucket(real.trim());
293
302
  var sock = b.requestHelpers.clientIp(req);
294
- return sock || "unknown";
303
+ return sock ? _ipBucket(sock) : "unknown";
304
+ }
305
+
306
+ // Collapse one resolved client IP to its per-IP bucket key. ipKey returns
307
+ // "" for a value that isn't a parseable IP (a malformed header, "unknown"),
308
+ // so we fall back to the original non-empty string — keeping two
309
+ // un-identifiable clients in distinct buckets rather than collapsing every
310
+ // garbage value into one shared bucket.
311
+ function _ipBucket(ip) {
312
+ return b.requestHelpers.ipKey(ip, { ipv6Bits: 64 }) || ip;
295
313
  }
296
314
 
297
315
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.92",
3
+ "version": "0.4.94",
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": {