@blamejs/blamejs-shop 0.4.83 → 0.4.85

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.85 (2026-06-22) — **Stock-transfer reconciliation credits the destination exactly once, even when retried after a mid-operation failure.** Fixes an inventory-integrity bug in stock-transfer reconciliation. Reconciling a received transfer credits each line's received quantity to the destination shelf and then records the line's discrepancy. If that discrepancy write failed partway through the transfer, reconciliation rolled its own state back so the operator could retry — but the retry credited the already-credited lines a second time, inflating the destination shelf with stock that was never received and could then be oversold. The destination credit is now keyed and idempotent: a retry recognizes a line it has already credited and skips it, so the shelf is credited exactly once no matter how many times reconciliation runs. The release adds one additive, backward-compatible database migration; apply pending migrations on upgrade. **Fixed:** *Stock-transfer reconciliation no longer double-credits the destination on retry* — Reconciling a received transfer credited each line to the destination shelf before stamping the line's discrepancy. A failure between those two steps rolled the transfer back to 'received' for a clean retry, but the retry re-credited every line whose discrepancy had not yet been stamped — including lines already credited on the failed attempt — minting phantom inventory at the destination. The per-line destination credit is now idempotent and keyed to the transfer and line, so re-running reconciliation after any partial failure credits each line exactly once; the discrepancy stamp is no longer load-bearing for correctness. **Migration:** *inventory_adjustments gains a nullable idempotency key* — Adds a nullable idempotency_key column to inventory_adjustments and a unique index over it, so a keyed stock credit records that it has already been applied and a retry that re-presents the key credits nothing. The column is NULL for every existing row and for plain stock adjustments, which already apply atomically; only keyed credits set it. The change is additive and backward-compatible — apply pending D1 migrations on upgrade.
12
+
13
+ - v0.4.84 (2026-06-22) — **Payment idempotency and analytics cache keys now use the framework's canonical-JSON encoder.** Internal consistency change with no configuration to apply. The Stripe idempotency replay-protection hash and the subscription-analytics cache key each carried their own copy of a sorted-key JSON serializer; both now compose the framework's canonical-JSON encoder, so a single serializer — with one set of key-ordering, string-escaping, and number-format rules — backs every replay and cache hash. Idempotency replays issued across the upgrade keep working: a request hash written by the prior release is still recognized for the row's lifetime, so a payment retried across the upgrade boundary replays its cached response rather than being refused as a mismatch. Upgrade to pick it up. **Changed:** *Replay and cache hashes share one canonical-JSON encoder* — Payment idempotency-key replay protection and the subscription-analytics cache key now build their hashes with the framework's canonical-JSON encoder instead of a locally maintained serializer. The encoder emits object members in one stable key order, so two requests — or two analytics queries — that differ only in member order resolve to the same hash, and the order is now stable even for integer-like keys, which the previous serializer reordered. Idempotency rows written by the prior release are still matched for their lifetime, so a payment retried across the upgrade boundary replays its cached response and is never refused as a false mismatch; the compatibility path expires with those rows.
14
+
11
15
  - v0.4.83 (2026-06-22) — **Downloading a subject-access export now requires the customer-write grant, not just read access.** Closes an access-control gap on the data-subject-request surface. Downloading a fulfilled subject-access export streams the customer's full personal-data bundle, but the download route was gated as a plain read, so a read-only operator (a viewer with no customers-write grant) could pull the entire PII bundle. The download now gates on the customers-write capability — the same grant fulfilling, dispatching, and erasing a request already require — so a read-only viewer is refused on the verb and the denial is recorded in the operator audit log, on both the API and browser paths. No configuration changes; upgrade to pick it up. **Security:** *Subject-access export download requires customers-write* — The DSR export-download route streams a customer's full PII bundle, so it is a customers-write-class capability like fulfilling, dispatching, or erasing a request — not a read. It now gates on customers-write on both the bearer-API and cookie/browser paths, so a read-only viewer can no longer download the bundle; the refused attempt is chained through the operator audit log like the other DSR write denials. Viewing the DSR queue (a read) is unchanged.
12
16
 
13
17
  - v0.4.82 (2026-06-22) — **Return-authorization and shipping-label state transitions are exactly-once under concurrency.** Two fulfillment surfaces gain the atomic-claim discipline the refund path already uses. Approving, receiving, and rejecting a return authorization now gate the transition on the observed status inside the UPDATE, so two operators (or a double-clicked action) acting on the same return can no longer both apply — exactly one transition wins and the other is refused, instead of a read-then-write letting both pass last-write-wins. Likewise, recording a shipping label as purchased, voided, or used is now a single guarded claim, so two concurrent broker callbacks for the same label can't both record (which previously left conflicting tracking numbers and costs last-write-wins). The losing caller gets the same transition-refused error a sequential illegal transition already produced. No configuration changes; upgrade to pick them up. **Fixed:** *Return-authorization transitions are atomic claims* — returns approve, markReceived, and reject now fold the expected source status into the UPDATE (approve from pending, markReceived from approved, reject from pending or approved) and treat the row count as the claim signal, matching the refund path. Two concurrent transitions on the same return — two operators, or a double-submit — can no longer both write last-write-wins; exactly one wins and the loser is refused with the same transition-refused error, mapped to a 409. Sequential not-found and illegal-transition errors are unchanged. · *Shipping-label transitions are atomic claims* — shipping-labels markPurchased, voidLabel, and markUsed now gate on the observed status inside the UPDATE. Two concurrent broker callbacks marking the same pending label purchased (a worker-drain double-fire) can no longer both record, leaving conflicting tracking numbers and costs by last-write-wins — exactly one records and the other is refused. The void 30-day window and not-found / wrong-status refusals are unchanged.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.83",
2
+ "version": "0.4.85",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-imfe0otYErcB8rr2h6KLSGTtStirysptpXETSPY4zLv3bZoIT75Lo1dOvkOav+xL",
@@ -79,6 +79,7 @@ var CODE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
79
79
  var SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
80
80
  var NAME_RE = /^[\S\s]{1,128}$/;
81
81
  var MAX_REASON = 256;
82
+ var MAX_IDEMPOTENCY_KEY = 256;
82
83
  var MAX_LINES = 1000;
83
84
  var TYPES = Object.freeze(["warehouse", "retail", "dropship"]);
84
85
  var STRATEGIES = Object.freeze([
@@ -674,6 +675,91 @@ function create(opts) {
674
675
  return { sku: input.sku, location_code: input.location_code, quantity: nextQty, delta: input.delta };
675
676
  },
676
677
 
678
+ // Idempotent keyed credit. For a positive credit that is one step of a
679
+ // larger non-transactional flow (e.g. stock-transfer reconciliation),
680
+ // adjustStock alone is not safe to retry: a crash between the stock upsert
681
+ // and the next step lets a re-run credit the shelf a SECOND time, minting
682
+ // phantom inventory. creditOnce makes a retry safe by making the audit
683
+ // row's idempotency_key the claim: the first call with a given key inserts
684
+ // the row (winning the claim) and applies the credit; any later call with
685
+ // the same key finds it present and is a no-op. A credit never breaches the
686
+ // zero/holds floor, so this path is positive-delta only.
687
+ //
688
+ // Why claim-first rather than a single atomic write: the truly atomic form
689
+ // would be an AFTER INSERT trigger crediting the shelf in the same INSERT,
690
+ // but `wrangler d1 migrations apply` ships each statement to the D1 HTTP
691
+ // API without its trailing `;`, and D1 rejects a trigger body that way
692
+ // ("incomplete input"), so a trigger cannot be applied through the project's
693
+ // migration tool. Claim-first is therefore the design. D1 has no
694
+ // interactive transactions, so a single-statement gap is unavoidable; the
695
+ // claim (audit row) is written BEFORE the stock upsert and the compensating
696
+ // delete below covers the upsert throwing. The only residual is the host
697
+ // process dying (a container crash / deploy roll — this runs in the
698
+ // persistent container, not a per-request isolate) between the two
699
+ // statements: the key is durable but the stock was not credited, so a retry
700
+ // no-ops and the shelf is UNDER-credited until an operator recount, never
701
+ // double-credited. Under-credit is the safe direction (a recoverable
702
+ // shortfall, not oversellable phantom stock) and matches every other
703
+ // claim-first transition in the codebase.
704
+ creditOnce: async function (input) {
705
+ if (!input || typeof input !== "object") {
706
+ throw new TypeError("inventory-locations.creditOnce: input object required");
707
+ }
708
+ _sku(input.sku);
709
+ _code(input.location_code, "location_code");
710
+ _int(input.delta, "delta");
711
+ if (input.delta <= 0) {
712
+ throw new TypeError("inventory-locations.creditOnce: delta must be a positive credit");
713
+ }
714
+ var key = input.idempotency_key;
715
+ if (typeof key !== "string" || !key.length || key.length > MAX_IDEMPOTENCY_KEY) {
716
+ throw new TypeError("inventory-locations.creditOnce: idempotency_key must be a non-empty string ≤ " +
717
+ MAX_IDEMPOTENCY_KEY + " chars");
718
+ }
719
+ var loc = await _getLocationRow(input.location_code);
720
+ if (!loc) {
721
+ throw new TypeError("inventory-locations.creditOnce: location_code " +
722
+ JSON.stringify(input.location_code) + " not found");
723
+ }
724
+ var ts = _now();
725
+ // Claim: insert the keyed audit row. ON CONFLICT DO NOTHING means a
726
+ // replay (same key) lands zero rows and the credit is skipped entirely.
727
+ var claim = await query(
728
+ "INSERT INTO inventory_adjustments " +
729
+ "(id, sku, location_code, delta, reason, occurred_at, idempotency_key) " +
730
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) ON CONFLICT(idempotency_key) DO NOTHING",
731
+ [b.uuid.v7(), input.sku, input.location_code, input.delta, _reason(input.reason), ts, key],
732
+ );
733
+ var changes = claim && (claim.rowCount != null ? claim.rowCount
734
+ : (claim.meta && claim.meta.changes != null ? claim.meta.changes : claim.changes));
735
+ if (Number(changes || 0) === 0) {
736
+ var existing = await _getStockRow(input.sku, input.location_code);
737
+ return {
738
+ sku: input.sku, location_code: input.location_code,
739
+ quantity: existing ? existing.quantity : 0, delta: 0, applied: false,
740
+ };
741
+ }
742
+ // Won the claim — apply the credit. If the upsert fails, roll the claim
743
+ // back so a retry re-attempts rather than stranding the credit at zero.
744
+ try {
745
+ await query(
746
+ "INSERT INTO inventory_stock (sku, location_code, quantity, updated_at) " +
747
+ "VALUES (?1, ?2, ?3, ?4) " +
748
+ "ON CONFLICT(sku, location_code) DO UPDATE SET " +
749
+ "quantity = quantity + ?3, updated_at = ?4",
750
+ [input.sku, input.location_code, input.delta, ts],
751
+ );
752
+ } catch (e) {
753
+ await query("DELETE FROM inventory_adjustments WHERE idempotency_key = ?1", [key]);
754
+ throw e;
755
+ }
756
+ var creditedRow = await _getStockRow(input.sku, input.location_code);
757
+ return {
758
+ sku: input.sku, location_code: input.location_code,
759
+ quantity: creditedRow ? creditedRow.quantity : 0, delta: input.delta, applied: true,
760
+ };
761
+ },
762
+
677
763
  // Atomic two-row move. Reads the source quantity; if
678
764
  // insufficient, refuses the whole transfer before mutating
679
765
  // anything (no money created, no row half-committed). Writes
package/lib/payment.js CHANGED
@@ -364,28 +364,73 @@ async function _stripeCall(opts, method, path, params, idempotencyKey) {
364
364
  // through b.crypto.namespaceHash (SHA3-512). Arrays preserve order
365
365
  // (their order is semantically meaningful — `items[]` in a Stripe
366
366
  // subscription is an ordered list of line items).
367
- function _canonicalise(v) {
367
+ // Drop undefined object members recursively. Idempotency treats an undefined
368
+ // member as omitted — two requests that differ only by an explicit `undefined`
369
+ // must hash the same — and the vendored canonical primitive would otherwise
370
+ // emit `undefined` as `null` (b.safeJson.canonical throws outright). Array
371
+ // order is preserved (an `items[]` list is semantically ordered). This is a
372
+ // SEMANTIC pre-filter only; the canonical sort + escaping + number format is
373
+ // delegated to the canonical-JSON primitive below, not hand-rolled.
374
+ function _dropUndefinedMembers(v) {
368
375
  if (v === null || typeof v !== "object") return v;
369
376
  if (Array.isArray(v)) {
370
377
  var out = [];
371
- for (var i = 0; i < v.length; i += 1) out.push(_canonicalise(v[i]));
378
+ for (var i = 0; i < v.length; i += 1) out.push(_dropUndefinedMembers(v[i]));
372
379
  return out;
373
380
  }
374
- var keys = Object.keys(v).sort();
381
+ var keys = Object.keys(v);
375
382
  var obj = {};
376
383
  for (var k = 0; k < keys.length; k += 1) {
377
384
  var val = v[keys[k]];
378
385
  if (val === undefined) continue;
379
- obj[keys[k]] = _canonicalise(val);
386
+ obj[keys[k]] = _dropUndefinedMembers(val);
380
387
  }
381
388
  return obj;
382
389
  }
383
390
 
384
391
  function _canonicalHash(obj) {
385
- var canonical = JSON.stringify(_canonicalise(obj == null ? {} : obj));
392
+ // Compose the vendored canonical-JSON primitive (RFC-8785-style sorted-key
393
+ // emission) for deterministic bytes, replacing the hand-rolled key sort.
394
+ var canonical = b.canonicalJson.stringify(_dropUndefinedMembers(obj == null ? {} : obj));
386
395
  return b.crypto.namespaceHash(IDEMPOTENCY_NAMESPACE, canonical);
387
396
  }
388
397
 
398
+ // Pre-primitive request-hash encoding, retained ONLY to recognise idempotency
399
+ // rows that an earlier release wrote with the hand-rolled canonicaliser. The
400
+ // request_hash column is persisted and compared on replay, so without this a
401
+ // same-key retry issued across the upgrade boundary would be refused as a
402
+ // false collision until the row's 1-day TTL elapsed. New rows always store the
403
+ // primitive-composed _canonicalHash; this fallback is compare-only and is safe
404
+ // to delete once a full IDEMPOTENCY_TTL_MS has elapsed after deploy (by then
405
+ // every legacy-encoded row has expired).
406
+ function _legacyCanonicalise(v) {
407
+ if (v === null || typeof v !== "object") return v;
408
+ if (Array.isArray(v)) {
409
+ var out = [];
410
+ for (var i = 0; i < v.length; i += 1) out.push(_legacyCanonicalise(v[i]));
411
+ return out;
412
+ }
413
+ var keys = Object.keys(v).sort();
414
+ var obj = {};
415
+ for (var k = 0; k < keys.length; k += 1) {
416
+ var val = v[keys[k]];
417
+ if (val === undefined) continue;
418
+ obj[keys[k]] = _legacyCanonicalise(val);
419
+ }
420
+ return obj;
421
+ }
422
+
423
+ function _legacyCanonicalHash(obj) {
424
+ return b.crypto.namespaceHash(IDEMPOTENCY_NAMESPACE, JSON.stringify(_legacyCanonicalise(obj == null ? {} : obj)));
425
+ }
426
+
427
+ // A stored request_hash matches when it equals the current primitive-composed
428
+ // hash OR (transition fallback) the legacy encoding's hash for the same body.
429
+ function _requestHashMatches(storedHash, requestObj) {
430
+ if (storedHash === _canonicalHash(requestObj)) return true;
431
+ return storedHash === _legacyCanonicalHash(requestObj);
432
+ }
433
+
389
434
  function _assertIdempotencyKey(k) {
390
435
  if (typeof k !== "string" || k.length < 8 || k.length > 255) {
391
436
  throw new TypeError("payment: idempotency_key must be a string between 8 and 255 characters");
@@ -418,7 +463,7 @@ async function _runIdempotent(state, operation, key, requestObj, doCall) {
418
463
  )).rows[0];
419
464
 
420
465
  if (existing) {
421
- if (existing.request_hash !== requestHash) {
466
+ if (!_requestHashMatches(existing.request_hash, requestObj)) {
422
467
  // Same key, different body — refuse. Stripe itself would reject
423
468
  // this on its own idempotency cache, but we surface a typed
424
469
  // application error so the caller doesn't have to ship the
@@ -462,7 +507,7 @@ async function _runIdempotent(state, operation, key, requestObj, doCall) {
462
507
  "FROM payment_idempotency WHERE idempotency_key = ?1 LIMIT 1",
463
508
  [key],
464
509
  )).rows[0];
465
- if (winner && winner.request_hash !== requestHash) {
510
+ if (winner && !_requestHashMatches(winner.request_hash, requestObj)) {
466
511
  throw new TypeError("payment: idempotency_key collision (different inputs)");
467
512
  }
468
513
  if (winner) {
@@ -1147,6 +1192,7 @@ module.exports = {
1147
1192
  _formEncode: _formEncode,
1148
1193
  _verifyWebhook: _verifyWebhook,
1149
1194
  _canonicalHash: _canonicalHash,
1195
+ _legacyCanonicalHash: _legacyCanonicalHash,
1150
1196
  // Exposed for the checkout webhook mirror + admin refund normalization —
1151
1197
  // exact decimal-string ↔ minor-unit conversion sharing one zero-decimal
1152
1198
  // currency table.
@@ -261,15 +261,18 @@ function _now() { return Date.now(); }
261
261
  function create(opts) {
262
262
  opts = opts || {};
263
263
  // The inventoryLocations primitive is the sole owner of
264
- // `inventory_stock` mutations. This primitive composes its
265
- // adjustStock verb to debit origin / credit destination — refusing
266
- // to wire one through fails loud at boot rather than at first call.
264
+ // `inventory_stock` mutations. This primitive composes its adjustStock
265
+ // verb to debit origin and its creditOnce verb to credit the destination
266
+ // idempotently during reconcile — refusing to wire one through fails loud
267
+ // at boot rather than at first call (a stub missing creditOnce would
268
+ // otherwise boot fine and only break mid-reconcile, after the status claim).
267
269
  if (!opts.inventoryLocations ||
268
270
  typeof opts.inventoryLocations.adjustStock !== "function" ||
271
+ typeof opts.inventoryLocations.creditOnce !== "function" ||
269
272
  typeof opts.inventoryLocations.getLocation !== "function" ||
270
273
  typeof opts.inventoryLocations.stockForSku !== "function") {
271
274
  throw new TypeError("stock-transfers.create: opts.inventoryLocations with " +
272
- "adjustStock + getLocation + stockForSku is required");
275
+ "adjustStock + creditOnce + getLocation + stockForSku is required");
273
276
  }
274
277
  var locations = opts.inventoryLocations;
275
278
  var query = opts.query;
@@ -669,25 +672,29 @@ function create(opts) {
669
672
  " is no longer received (already reconciled by a concurrent call) — refusing to double-credit");
670
673
  }
671
674
  var discrepancies = [];
672
- // Credit the destination one line at a time. The claim above flipped the
673
- // status, so a concurrent call can't reach this loop for the same
674
- // transfer. Each line is skipped once its discrepancy column is stamped,
675
- // so a retry (after the compensation below rolls the claim back) re-credits
676
- // ONLY the lines that hadn't landed yet no double-credit on the lines
677
- // that already succeeded. If any line throws, the terminal claim is rolled
678
- // back to 'received' so the transfer stays eligible for another reconcile
679
- // that finishes the remaining credits, rather than stranding it.
675
+ // Credit the destination one line at a time. The status claim above
676
+ // serializes concurrent reconciles, but the exactly-once guarantee on the
677
+ // destination credit is the KEYED credit itself: creditOnce stamps a
678
+ // per-line idempotency key (stock-transfer:reconcile:<transfer>:<line>),
679
+ // so even if a line is credited and the loop then throws BEFORE the
680
+ // discrepancy column is stamped rolling the status claim back to
681
+ // 'received' and re-enabling a full re-run the re-credit finds the key
682
+ // present and is a no-op. The discrepancy skip-guard is a redundant
683
+ // fast-path, not the safety gate. If any line throws, the terminal claim
684
+ // is rolled back so the transfer stays eligible for another reconcile that
685
+ // finishes the remaining credits rather than stranding it.
680
686
  try {
681
687
  for (var i = 0; i < transfer.lines.length; i += 1) {
682
688
  var line = transfer.lines[i];
683
- if (line.discrepancy != null) continue; // already credited on a prior attempt
689
+ if (line.discrepancy != null) continue; // already credited + stamped on a prior attempt
684
690
  var rx = line.quantity_received == null ? 0 : line.quantity_received;
685
691
  if (rx > 0) {
686
- await locations.adjustStock({
692
+ await locations.creditOnce({
687
693
  sku: line.sku,
688
694
  location_code: transfer.to_location,
689
695
  delta: rx,
690
696
  reason: "stock-transfer:reconcile:" + id,
697
+ idempotency_key: "stock-transfer:reconcile:" + id + ":" + line.id,
691
698
  });
692
699
  }
693
700
  var diff = line.quantity_shipped - rx;
@@ -316,24 +316,10 @@ function _churnKind(value) {
316
316
 
317
317
  // ---- canonical JSON for cache keys -------------------------------------
318
318
  //
319
- // Two cache lookups with the same arguments in a different key order
320
- // must hit the same row. JSON.stringify isn't order-stable across
321
- // engines, so the primitive sorts keys before hashing.
322
-
323
- function _canonicalJson(value) {
324
- if (value === null || typeof value !== "object") return JSON.stringify(value);
325
- if (Array.isArray(value)) {
326
- var parts = [];
327
- for (var i = 0; i < value.length; i += 1) parts.push(_canonicalJson(value[i]));
328
- return "[" + parts.join(",") + "]";
329
- }
330
- var keys = Object.keys(value).sort();
331
- var pairs = [];
332
- for (var k = 0; k < keys.length; k += 1) {
333
- pairs.push(JSON.stringify(keys[k]) + ":" + _canonicalJson(value[keys[k]]));
334
- }
335
- return "{" + pairs.join(",") + "}";
336
- }
319
+ // Two cache lookups with the same arguments in a different key order must
320
+ // hit the same row. JSON.stringify isn't order-stable across engines, so the
321
+ // cache key is built from the vendored canonical-JSON primitive, which emits
322
+ // members in sorted-key order before hashing.
337
323
 
338
324
  // ---- cadence normalization ---------------------------------------------
339
325
  //
@@ -410,7 +396,7 @@ function create(opts) {
410
396
  // ---- cache helpers --------------------------------------------------
411
397
 
412
398
  function _cacheKey(scope, args) {
413
- return b.crypto.namespaceHash(CACHE_NAMESPACE, _canonicalJson(args || {}));
399
+ return b.crypto.namespaceHash(CACHE_NAMESPACE, b.canonicalJson.stringify(args || {}));
414
400
  }
415
401
 
416
402
  async function _cacheRead(scope, args, periodFrom, periodTo, ttlMs) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.83",
3
+ "version": "0.4.85",
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": {