@blamejs/blamejs-shop 0.4.83 → 0.4.84
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 +2 -0
- package/lib/asset-manifest.json +1 -1
- package/lib/payment.js +53 -7
- package/lib/subscription-analytics.js +5 -19
- package/package.json +1 -1
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.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.
|
|
12
|
+
|
|
11
13
|
- 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
14
|
|
|
13
15
|
- 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.
|
package/lib/asset-manifest.json
CHANGED
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
|
-
|
|
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(
|
|
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)
|
|
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]] =
|
|
386
|
+
obj[keys[k]] = _dropUndefinedMembers(val);
|
|
380
387
|
}
|
|
381
388
|
return obj;
|
|
382
389
|
}
|
|
383
390
|
|
|
384
391
|
function _canonicalHash(obj) {
|
|
385
|
-
|
|
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
|
|
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
|
|
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.
|
|
@@ -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
|
-
//
|
|
321
|
-
//
|
|
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,
|
|
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