@blamejs/blamejs-shop 0.5.16 → 0.5.18
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 +4 -0
- package/lib/asset-manifest.json +1 -1
- package/lib/backorder.js +29 -9
- package/lib/customer-impersonation.js +3 -3
- package/lib/operator-roles.js +12 -1
- package/lib/stock-transfers.js +10 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.5.x
|
|
10
10
|
|
|
11
|
+
- v0.5.18 (2026-07-25) — **The operator role-permission check fails closed on an unknown capability instead of throwing, and customer impersonation's capability is grantable through the RBAC system.** Fixes an authorization-control defect in operatorRoles.hasPermission and the customer-impersonation capability gate. hasPermission — the runtime authorization check that operator features gate on — routed the requested permission through the same strict validator the config-time surfaces use, which throws on any token outside the closed permission allow-list. A feature that gated on a capability the allow-list did not carry therefore made the check CRASH rather than return allow or deny: with the roles peer wired, every such check threw and the gated action was inoperable; the correct posture for an authorization check is to fail closed. hasPermission now returns false (deny) for an unknown or garbage permission token; the config-time surfaces (defineRole / patchRole) still validate their permission lists strictly. Separately, the customer-impersonation capability was a token the allow-list did not carry, so it could never be granted to a role NOR checked — impersonation could not be gated through the RBAC system at all. It is now a first-class permission (customers.impersonate) that a role can grant and the check resolves, so an operator without it is denied and one with it is allowed. The same root fix turns the two-person-approval workflow's capability check from a crash into a fail-closed deny when a workflow names a capability outside the allow-list. **Security:** *The RBAC permission check fails closed on an unknown capability instead of throwing* — operatorRoles.hasPermission validated the requested permission with the strict allow-list check the configuration surfaces use, which throws on any token outside the closed allow-list. A feature gating on a capability the allow-list did not carry (customer impersonation's capability, or a two-person-approval workflow's required capability) therefore crashed the check instead of getting an allow/deny answer — inoperable with the roles peer wired, and never the intended fail-closed behavior. hasPermission now returns false for an unknown or garbage permission token, so a caller gating on an unrecognized capability is denied rather than crashed. defineRole / patchRole still reject an unknown permission at configuration time, where a strict error is correct. · *Customer-impersonation capability is a first-class, grantable permission* — The capability that customer impersonation checks defaulted to a token the permission allow-list did not carry, so it could neither be granted to an operator role nor resolved by the check — impersonation could not be gated through the RBAC system, and composing the real roles primitive made the gate throw. The capability is now customers.impersonate, a member of the closed allow-list: a role can grant it, an operator holding that role is allowed to impersonate, and an operator without it is refused with a typed error at the primitive layer.
|
|
12
|
+
|
|
13
|
+
- v0.5.17 (2026-07-25) — **Backorder capacity is enforced atomically under concurrent requests, and a stock-transfer over-receipt no longer mints net inventory.** Two inventory-quantity integrity fixes. recordBackorder checked the per-SKU supplier cap (max_backorder_quantity) against a snapshot read and then incremented the pending counter unconditionally, so two concurrent backorders of the same SKU could both pass the check on the same starting value and both apply their increment, pushing the pending total past the cap and over-committing to the supplier. The increment now folds the cap into a single guarded update — only an increment that keeps the pending total within the cap applies — and is gated on the row count: a losing claim removes the line it had inserted and refuses, so the counter can never exceed the cap regardless of interleaving. Separately, stock-transfer reconcile credited the destination location with the full received quantity, so a mis-scan that received MORE than was shipped credited the destination beyond what the origin was debited at ship time — minting net sellable inventory the transfer created nowhere. The destination credit is now clamped to the shipped (debited) quantity, and the over-count is recorded as a negative discrepancy for the operator; a normal or short receipt is unchanged. **Fixed:** *Backorder supplier cap holds under concurrent requests* — recordBackorder enforced max_backorder_quantity by reading the current pending total, checking pending + requested <= cap, and then unconditionally incrementing the counter. Two concurrent backorders of the same SKU could each read the same pending value, each pass the check, and each apply its increment, leaving the pending total above the cap — over-committing beyond supplier capacity. The counter increment now carries the cap in its WHERE clause so only an increment that keeps the total within the cap applies, and the call checks that exactly one row changed; a losing claim (a concurrent line took the remaining capacity, or the SKU was deactivated) removes the line it inserted and refuses with the current reason. The early snapshot check is kept as a fast-fail for the common case. · *Stock-transfer over-receipt no longer mints net inventory* — When a transfer was reconciled, the destination location was credited with the full received quantity. A mis-scan (or a genuinely found unit) that recorded more received than was shipped therefore credited the destination beyond what the origin had been debited when the transfer opened — creating net sellable stock the transfer never moved. The destination credit is now clamped to the shipped quantity (what the origin was debited), so an over-receipt can no longer mint inventory; the discrepancy column records the true shipped-minus-received delta (negative for an over-receipt) so the operator still sees the mis-count. Exact and short receipts are unaffected.
|
|
14
|
+
|
|
11
15
|
- v0.5.16 (2026-07-25) — **Vendored framework refreshed to 0.17.23: session refresh paths enforce the idle/absolute timeout floor and two verifier fail-closed contracts are hardened.** Refreshes the vendored framework from 0.17.22 to 0.17.23, a security patch to primitives the store composes; all of the following are inherited without shop code changes. The encrypted-session primitive now enforces its idle and absolute timeout floors on the refresh paths, not just on read: rotating a session or writing session data previously reset the activity clock without re-checking the idle floor, so a session already past its idle timeout — one a normal verify would expire and delete — could be revived with a fresh idle window; both refresh paths now fail closed on a breach. A strict device-fingerprint policy configured without a request to compute the fingerprint now fails closed instead of silently admitting the session from any device. The audit-signature verifier now returns false on a malformed public key instead of throwing, honoring its never-throw contract for callers that branch on the boolean. The bundled Public Suffix List is refreshed to the current snapshot. The minimum Node.js engine is unchanged at 24.18.0. **Changed:** *Vendored framework refreshed to 0.17.23* — Advances the vendored framework from 0.17.22 to 0.17.23. Beyond the session and verifier fixes above, the bundled Public Suffix List is refreshed to the current upstream snapshot so registrable-domain and public-suffix checks across URL, cookie, and SSRF handling reflect the latest registry delegations. The minimum Node.js engine is unchanged at 24.18.0. **Security:** *Session refresh paths can no longer resurrect an idle-expired session* — The encrypted-session primitive enforces two floors independent of the configured TTL — an idle timeout and an absolute timeout. Read paths already enforced them, but rotating a session (on a privilege change) or writing session data reset the activity clock without re-checking the idle floor, so a session already past its idle timeout — which a normal verify would expire and delete — could be revived with a fresh window by rotating or writing to it. Both refresh paths now route through the same floor check and fail closed on a breach, and the idle/absolute-expiry audit signal is emitted consistently. Inherited from the framework refresh; the store's sessions gain the tighter enforcement with no configuration change. · *A strict device-fingerprint policy with no request now fails closed; audit-signature verify no longer throws on a malformed key* — Two verifier fail-closed fixes ride in with the refresh. A strict device-binding policy (require-fingerprint-match / max-anomaly-score) that was configured without a request to compute the current fingerprint previously skipped the binding check and admitted the session from any device; it now fails closed. And the audit-signature verifier, documented never to throw so callers can branch on its boolean, threw on a malformed / non-PEM public key (an untrusted key can reach it from a signature block); it now catches that and returns false, so a verifier-only consumer cleanly rejects instead of crashing. Inherited from the framework refresh.
|
|
12
16
|
|
|
13
17
|
- v0.5.15 (2026-07-25) — **`giftCardLedger.history` and `loyaltyRedemption.redemptionsForCustomer` no longer return a next-page cursor that resolves to an empty page.** Fixes a cursor-pagination contract defect in two ledger/history primitives. giftCardLedger.history and loyaltyRedemption.redemptionsForCustomer emitted a next-page cursor whenever a page came back full — its row count equalled the requested limit — instead of first peeking one row past the page. When the history length is an exact multiple of the page size, the last full page still handed back a cursor; following it queried for strictly older rows and returned nothing, so a caller paginating with the cursor would land on an empty page. Both methods now fetch one extra row, emit the cursor only when a genuinely older row exists, and return the page trimmed to the requested size — matching the store-credit and loyalty-transaction history that already peek. This is an API-level correctness fix: the shop's current callers of these two methods read only the returned rows (the admin gift-card ledger view and the account loyalty-redemptions list do not paginate on this cursor), so no live page changes today; the fix corrects the primitives' cursor contract for any consumer that does paginate and removes a latent phantom-page. A codebase check now covers each method so the peek can't be dropped again. **Fixed:** *Gift-card and loyalty-redemption history return an honest next-page cursor* — giftCardLedger.history and loyaltyRedemption.redemptionsForCustomer keyed their next-page cursor off "the page came back full" (row count === limit) rather than peeking one row beyond the page, so a history whose length is an exact multiple of the page size returned a cursor on its last full page that resolves to an empty page when followed. Both now fetch limit + 1 rows, emit the cursor only when an older row actually exists, and return the page trimmed to the requested limit — the same peek the store-credit and loyalty-transaction history already use. The change is to the primitives' pagination contract (the shop's present callers of these two methods consume only the rows, so no rendered page changes); it removes a latent phantom next page for any consumer that paginates on the cursor. A codebase-patterns check now guards each method against a regression that drops the peek.
|
package/lib/asset-manifest.json
CHANGED
package/lib/backorder.js
CHANGED
|
@@ -271,6 +271,27 @@ function create(opts) {
|
|
|
271
271
|
|
|
272
272
|
var id = b.uuid.v7();
|
|
273
273
|
var ts = _now();
|
|
274
|
+
// Claim capacity FIRST, atomically, THEN create the line. The early
|
|
275
|
+
// check above reads a snapshot; two concurrent calls can both pass it,
|
|
276
|
+
// so the cap is folded into this guarded update — only an increment that
|
|
277
|
+
// keeps pending within the cap (on an active sku) applies, gated on
|
|
278
|
+
// rowCount. Claiming before the INSERT means a line only exists once its
|
|
279
|
+
// capacity is committed: a concurrent retry's dedup check never sees a
|
|
280
|
+
// line that a cap loss would then have to delete (which would let the
|
|
281
|
+
// retry report a phantom success for a backorder that no longer exists).
|
|
282
|
+
var claim = await query(
|
|
283
|
+
"UPDATE backorder_skus SET pending_quantity = pending_quantity + ?1, updated_at = ?2 " +
|
|
284
|
+
"WHERE sku = ?3 AND active = 1 AND (max_quantity IS NULL OR pending_quantity + ?1 <= max_quantity)",
|
|
285
|
+
[input.quantity, ts, input.sku],
|
|
286
|
+
);
|
|
287
|
+
if (Number((claim && claim.rowCount) || 0) !== 1) {
|
|
288
|
+
var cur = await _getConfig(input.sku);
|
|
289
|
+
if (!cur || cur.active !== 1) {
|
|
290
|
+
throw new TypeError("backorder.recordBackorder: sku " + JSON.stringify(input.sku) + " is not currently backorderable");
|
|
291
|
+
}
|
|
292
|
+
throw new TypeError("backorder.recordBackorder: would exceed max_backorder_quantity " +
|
|
293
|
+
"(cap=" + cur.max_quantity + ", pending=" + cur.pending_quantity + ", requested=" + input.quantity + ")");
|
|
294
|
+
}
|
|
274
295
|
try {
|
|
275
296
|
await query(
|
|
276
297
|
"INSERT INTO backorder_lines (id, order_id, customer_id, sku, quantity, status, " +
|
|
@@ -278,10 +299,14 @@ function create(opts) {
|
|
|
278
299
|
[id, input.order_id, input.customer_id, input.sku, input.quantity, ts],
|
|
279
300
|
);
|
|
280
301
|
} catch (e) {
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
302
|
+
// A concurrent call for the same (order_id, sku) already recorded the
|
|
303
|
+
// line (UNIQUE violation) after we claimed capacity. Release the
|
|
304
|
+
// capacity we just claimed so the line is counted exactly once, then
|
|
305
|
+
// return the dedup shape (or re-raise a non-duplicate error).
|
|
306
|
+
await query(
|
|
307
|
+
"UPDATE backorder_skus SET pending_quantity = pending_quantity - ?1, updated_at = ?2 WHERE sku = ?3",
|
|
308
|
+
[input.quantity, _now(), input.sku],
|
|
309
|
+
);
|
|
285
310
|
var redup = await query(
|
|
286
311
|
"SELECT id, status FROM backorder_lines WHERE order_id = ?1 AND sku = ?2 LIMIT 1",
|
|
287
312
|
[input.order_id, input.sku],
|
|
@@ -291,11 +316,6 @@ function create(opts) {
|
|
|
291
316
|
}
|
|
292
317
|
throw e;
|
|
293
318
|
}
|
|
294
|
-
await query(
|
|
295
|
-
"UPDATE backorder_skus SET pending_quantity = pending_quantity + ?1, updated_at = ?2 " +
|
|
296
|
-
"WHERE sku = ?3",
|
|
297
|
-
[input.quantity, ts, input.sku],
|
|
298
|
-
);
|
|
299
319
|
return { id: id, status: "recorded" };
|
|
300
320
|
},
|
|
301
321
|
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* 1. The operator calls `startImpersonation({ operator_id,
|
|
23
23
|
* customer_id, reason })`. When the optional `operatorRoles`
|
|
24
24
|
* peer is wired, the call gates on `hasPermission(operator_id,
|
|
25
|
-
* "
|
|
25
|
+
* "customers.impersonate")` — operators without the
|
|
26
26
|
* capability are refused at the primitive layer, not in
|
|
27
27
|
* application code. The primitive mints a 32-byte base64url
|
|
28
28
|
* plaintext bearer via `b.crypto.generateBytes(32)` +
|
|
@@ -146,7 +146,7 @@ var MAX_ENDED_BY_LEN = 64;
|
|
|
146
146
|
var MAX_ACTION_LEN = 128;
|
|
147
147
|
var MAX_RESOURCE_KIND_LEN = 64;
|
|
148
148
|
var MAX_RESOURCE_ID_LEN = 256;
|
|
149
|
-
var DEFAULT_CAPABILITY = "
|
|
149
|
+
var DEFAULT_CAPABILITY = "customers.impersonate";
|
|
150
150
|
|
|
151
151
|
var STATUS_VALUES = Object.freeze([
|
|
152
152
|
"active", "ended", "expired", "revoked",
|
|
@@ -299,7 +299,7 @@ function create(opts) {
|
|
|
299
299
|
//
|
|
300
300
|
// Mints a fresh impersonation session. When `operatorRoles` is
|
|
301
301
|
// wired, gates on `hasPermission(operator_id,
|
|
302
|
-
// requires_capability ?? "
|
|
302
|
+
// requires_capability ?? "customers.impersonate")` — operators
|
|
303
303
|
// without the capability are refused with a typed error. The
|
|
304
304
|
// plaintext bearer is returned ONCE; the database stores only the
|
|
305
305
|
// namespaceHash. Default 60-minute TTL caps the blast radius if
|
package/lib/operator-roles.js
CHANGED
|
@@ -154,6 +154,7 @@ var PERMISSIONS = Object.freeze([
|
|
|
154
154
|
"orders.cancel",
|
|
155
155
|
"customers.read",
|
|
156
156
|
"customers.export",
|
|
157
|
+
"customers.impersonate",
|
|
157
158
|
"catalog.read",
|
|
158
159
|
"catalog.write",
|
|
159
160
|
"inventory.write",
|
|
@@ -640,7 +641,17 @@ function create(opts) {
|
|
|
640
641
|
throw new TypeError("operatorRoles.hasPermission: input object required");
|
|
641
642
|
}
|
|
642
643
|
var operatorId = _operatorId(input.operator_id, "operator_id");
|
|
643
|
-
|
|
644
|
+
// hasPermission is a hot-path authorization CHECK, not a config-time entry
|
|
645
|
+
// point: an unknown or garbage permission token is DENIED (return false),
|
|
646
|
+
// not thrown. Throwing here would crash a caller that gates on a capability
|
|
647
|
+
// the allow-list doesn't carry (e.g. a workflow's required_capability, or a
|
|
648
|
+
// feature capability) — the correct posture for an authorization check is
|
|
649
|
+
// fail-closed. Config-time surfaces (defineRole, patchRole) still validate
|
|
650
|
+
// permissions strictly via _permission.
|
|
651
|
+
if (typeof input.permission !== "string" || PERMISSIONS.indexOf(input.permission) === -1) {
|
|
652
|
+
return false;
|
|
653
|
+
}
|
|
654
|
+
var permission = input.permission;
|
|
644
655
|
|
|
645
656
|
var now = _now();
|
|
646
657
|
var rows = (await query(
|
package/lib/stock-transfers.js
CHANGED
|
@@ -688,11 +688,19 @@ function create(opts) {
|
|
|
688
688
|
var line = transfer.lines[i];
|
|
689
689
|
if (line.discrepancy != null) continue; // already credited + stamped on a prior attempt
|
|
690
690
|
var rx = line.quantity_received == null ? 0 : line.quantity_received;
|
|
691
|
-
|
|
691
|
+
// Credit the destination only up to what origin was debited at open
|
|
692
|
+
// (quantity_shipped). An over-receipt (a mis-scan, or a genuinely
|
|
693
|
+
// found unit) is a real discrepancy worth recording, but crediting
|
|
694
|
+
// the full received amount would mint net inventory the transfer
|
|
695
|
+
// debited nowhere. The discrepancy below still records the true
|
|
696
|
+
// shipped − received delta (negative for an over-receipt) so the
|
|
697
|
+
// operator sees the mis-count.
|
|
698
|
+
var credited = rx > line.quantity_shipped ? line.quantity_shipped : rx;
|
|
699
|
+
if (credited > 0) {
|
|
692
700
|
await locations.creditOnce({
|
|
693
701
|
sku: line.sku,
|
|
694
702
|
location_code: transfer.to_location,
|
|
695
|
-
delta:
|
|
703
|
+
delta: credited,
|
|
696
704
|
reason: "stock-transfer:reconcile:" + id,
|
|
697
705
|
idempotency_key: "stock-transfer:reconcile:" + id + ":" + line.id,
|
|
698
706
|
});
|
package/package.json
CHANGED