@blamejs/blamejs-shop 0.4.93 → 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,8 @@ 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
+
11
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.
12
14
 
13
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.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.93",
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.93",
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": {