@blamejs/blamejs-shop 0.5.13 → 0.5.15

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.5.x
10
10
 
11
+ - 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.
12
+
13
+ - v0.5.14 (2026-07-25) — **A sales-tax refund is now credited in the filing period it occurs even when the refunded order was placed in an earlier period, closing a systematic over-remittance.** Fixes a sales-tax over-remittance (CWE-682, incorrect calculation) in sales_tax_filings.computeFiling. A partial or full refund reduces the filing for the period it occurs in, but the computation only consulted refunds for orders that were ALSO created inside that filing window. Because a refund almost always lands in a later period than the sale — the norm under monthly filing — a refund of a prior-period order was silently dropped from every filing: the sale period had already remitted the full tax, and the refund period never subtracted it. The operator over-remitted the refunded tax to the jurisdiction. computeFiling now nets every in-window refund against the current period regardless of when the refunded order was placed, apportioning the refunded cash to subtotal and tax by the order's own structure, matching it to the order's jurisdiction and exemption, and attributing the reduction to the tax rate effective at the order's sale time so both collected and owed reflect it. A window whose refunds exceed its own sales floors at zero rather than storing a negative filing (the schema does not carry a net-credit forward; the guarantee is that the fix never over-remits and never violates the constraint). **Fixed:** *Cross-period sales-tax refunds are credited in the period they occur* — computeFiling netted a refund only when the refunded order fell inside the same filing window as the refund. A refund recorded in a later period than the sale — the common case under monthly or quarterly filing — was therefore attributed to no period at all: the sale period had already remitted the tax in full, and the refund period skipped the order because it was created earlier. The refunded tax was remitted and never credited, so the operator over-paid the jurisdiction on every returned prior-period sale. The filing now processes every refund recorded inside the window against the current period, whether or not the refunded order was created in it — apportioning the refunded cash to taxable subtotal and tax by the order's own totals, honoring the order's jurisdiction and customer exemption, and reducing the rate bucket effective at the order's sale time so tax_collected and tax_owed both net the refund. A window whose in-window sales are outweighed by refunds of earlier-period orders reports zero rather than a negative filing.
14
+
11
15
  - v0.5.13 (2026-07-25) — **Vendored framework refreshed from 0.16.3 to 0.17.22, inheriting a batch of security and correctness fixes across the rate-limit, CSP, cookie, data-subject-request, webhook, TLS, and SQL primitives the store composes.** Refreshes the framework the store is built on from 0.16.3 to 0.17.22, a broad security and robustness update to primitives the store wires into every request. All of the following are inherited without shop code changes. Per-route rate-limit buckets now key on the request path only, not the query string, so an abuser can no longer mint a fresh bucket per request with a rotating query parameter to slip past a login or checkout limiter. CSP source screening, cookie-prefix checks, and the CSRF cookie branch are now ASCII case-insensitive, closing a case-variant bypass of a Content-Security-Policy keyword and of the __Secure-/__Host- cookie-prefix guarantees. A data-subject-request (privacy export/erasure) whose subject has no indexable key now fails closed instead of matching every ticket, removing a cross-subject disclosure and erasure path. The shared outbound HTTP cache no longer serves an Authorization-bearing response across principals, the strict TLS hostname verifier no longer accepts a name smuggled through a quoted subjectAltName, and inbound webhook verification rejects a non-canonical signed timestamp. The minimum Node.js engine is unchanged at 24.18.0. **Changed:** *Vendored framework refreshed to 0.17.22* — Advances the vendored framework from 0.16.3 to 0.17.22. Beyond the security fixes above, the store inherits SQLite/D1 robustness — a paginated query with an OFFSET but no LIMIT now runs instead of raising a syntax error, and a CSV export degrades an out-of-range timestamp for one row instead of aborting the whole export — along with prototype-safe own-property lookups across the crypto, internationalization, and content-guard primitives and a current Public Suffix List snapshot for domain-scoped cookie and email-alignment decisions. The minimum Node.js engine is unchanged at 24.18.0. **Security:** *Per-route rate limiters can no longer be evaded with a rotating query string* — The framework's per-route rate limiters — mounted on the abusable login, signup, checkout, and gift-card endpoints — bucketed a client by the full request target including the query string, so a request stream carrying a rotating parameter such as ?nonce=1, ?nonce=2, ... minted a fresh bucket each time and never tripped the limit. Buckets now key on the path only, so the limit counts every hit to a route regardless of its query string. Inherited from the framework refresh; no configuration change. · *Case-variant bypass of CSP keywords and cookie prefixes closed* — Content-Security-Policy source screening and the __Secure-/__Host- cookie-prefix checks compared tokens case-sensitively, so a case-variant of a policy keyword or a cookie-prefix marker could slip past the guard; the CSRF cookie path was also missing the __Secure- branch. These comparisons are now ASCII case-insensitive and the CSRF branch is present, so the store's CSP and its prefixed session/CSRF cookies enforce their intended guarantees against a mixed-case forgery. Inherited from the framework refresh. · *Data-subject-request filter fails closed on a subject with no indexable key* — The data-subject-request store (the privacy export and erasure path) built its subject filter such that a subject carrying no indexable key matched every ticket, which could disclose or erase records across subjects. The filter now fails closed — a subject that cannot be keyed matches nothing — removing the cross-subject path. The store also now composes the shared SQL builder, inheriting its reserved-word and sealed-column handling. Inherited from the framework refresh. · *Shared outbound HTTP cache no longer crosses principals; TLS and webhook verifiers hardened* — Three verifier and cache fixes ride in with the refresh: the shared outbound HTTP response cache no longer returns an Authorization-bearing response to a different principal; the strict RFC 9525 TLS hostname verifier now parses subjectAltName quote-aware, so a hostname smuggled through a quoted SAN value can no longer be accepted; and inbound webhook signature verification rejects a non-canonical signed timestamp (scientific/hex/leading-zero forms) instead of treating it as valid. The declared algorithm on a verified JWT is now bound to the key's real type, closing an algorithm-confusion acceptance. Inherited from the framework refresh.
12
16
 
13
17
  - v0.5.12 (2026-07-03) — **Operator password sign-in now has a brute-force lockout keyed on both the client IP and the target account.** Wires a brute-force lockout into the operator email+password sign-in (/admin/operators/signin), which previously relied only on the global per-IP rate limit and Argon2 verification. The sign-in now records each failed attempt and, before checking the password, refuses with 429 once the failure count for the request's IP or for the target operator account exceeds its threshold within a rolling 15-minute window. Keying on the account as well as the IP bounds a distributed guess — an attacker rotating IPs against one operator — that a per-IP limit alone can't see. The account threshold is deliberately higher than the per-IP threshold, so a legitimate operator is only ever account-locked under a genuine multi-IP attack, and the lock is a temporary cooldown that auto-expires with the window rather than a permanent lock an attacker could weaponize into a denial of service. The target account is resolved up front for the account key regardless of whether the email matches a real operator, and a lockout returns the same generic sign-in refusal, so no account-existence oracle is added. A successful sign-in clears the account's recent failures so an operator who mistyped isn't held under the window. The lockout composes the existing operator-session primitive and its already-deployed failed-login table — no schema change and no configuration; the failed-attempt log also feeds the operator security timeline. **Security:** *Brute-force lockout on the operator password sign-in* — The operator email+password sign-in had no per-account brute-force protection — only the global per-IP rate limit and Argon2 slowed guessing, so a distributed attack rotating IPs against a single operator account was unbounded. The sign-in now records each failed attempt and refuses with 429 (before verifying the password) once the failures for the request IP or the target account exceed their thresholds in a rolling 15-minute window. The account threshold is higher than the per-IP one, so a legitimate operator is only account-locked under a real multi-IP attack, and the lock auto-expires with the window (a cooldown, not a weaponizable permanent lock). The account is resolved whether or not the email is real and a lockout returns the same generic refusal, so there is no account-existence oracle; a successful sign-in clears the account's recent failures. Composes the existing operator-session primitive and its deployed failed-login table — no schema change, no configuration, and the failed-attempt log continues to feed the operator security timeline.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.5.13",
2
+ "version": "0.5.15",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-imfe0otYErcB8rr2h6KLSGTtStirysptpXETSPY4zLv3bZoIT75Lo1dOvkOav+xL",
@@ -651,10 +651,14 @@ function create(opts) {
651
651
  params.push(cursor);
652
652
  }
653
653
  sql += " ORDER BY occurred_at DESC, id DESC LIMIT ?" + (params.length + 1);
654
- params.push(limit);
654
+ // Peek one row past the page: emitting next_cursor whenever the page
655
+ // came back full (rows.length === limit) advertises an "older" page that
656
+ // renders empty when the ledger length is an exact multiple of the limit.
657
+ params.push(limit + 1);
655
658
  var r = await query(sql, params);
656
- var rows = r.rows;
657
- var nextCursor = rows.length === limit ? rows[rows.length - 1].occurred_at : null;
659
+ var hasMore = r.rows.length > limit;
660
+ var rows = hasMore ? r.rows.slice(0, limit) : r.rows;
661
+ var nextCursor = hasMore ? rows[rows.length - 1].occurred_at : null;
658
662
  return { rows: rows, next_cursor: nextCursor };
659
663
  },
660
664
 
@@ -615,11 +615,16 @@ function create(opts) {
615
615
  params.push(listOpts.cursor);
616
616
  }
617
617
  sql += " ORDER BY redeemed_at DESC, id DESC LIMIT ?" + (params.length + 1);
618
- params.push(limit);
619
- var rows = (await query(sql, params)).rows;
618
+ // Peek one row past the page so a full final page does not advertise a
619
+ // phantom "older" page (empty when the redemption count is an exact
620
+ // multiple of the limit) — mirrors store-credit / loyalty history.
621
+ params.push(limit + 1);
622
+ var fetched = (await query(sql, params)).rows;
623
+ var hasMore = fetched.length > limit;
624
+ var pageRows = hasMore ? fetched.slice(0, limit) : fetched;
620
625
  var hydrated = [];
621
- for (var i = 0; i < rows.length; i += 1) hydrated.push(_hydrateRedemption(rows[i]));
622
- var nextCursor = rows.length === limit ? hydrated[hydrated.length - 1].redeemed_at : null;
626
+ for (var i = 0; i < pageRows.length; i += 1) hydrated.push(_hydrateRedemption(pageRows[i]));
627
+ var nextCursor = hasMore ? hydrated[hydrated.length - 1].redeemed_at : null;
623
628
  return { rows: hydrated, next_cursor: nextCursor };
624
629
  }
625
630
 
@@ -384,6 +384,49 @@ function create(opts) {
384
384
  return out;
385
385
  }
386
386
 
387
+ // Fetch specific orders by id (for netting a refund whose order was
388
+ // created OUTSIDE the filing window — see the cross-period refund pass in
389
+ // computeFiling). Unlike _ordersInWindow this does NOT restrict to the
390
+ // sale-status set: a balance-clearing refund moves the order to the
391
+ // terminal `refunded` state, and that full-refund order is exactly the one
392
+ // the cross-period pass must credit — so `refunded` is included alongside
393
+ // the live sale statuses. `pending` / `cancelled` are excluded (they never
394
+ // counted as a sale, and a real refund transition never lands on them).
395
+ // The jurisdiction match still applies so a refund only reduces its own
396
+ // filing.
397
+ async function _ordersByIds(jurisdiction, ids) {
398
+ if (!ids.length) return [];
399
+ var placeholders = ids.map(function (_v, i) { return "?" + (i + 1); }).join(", ");
400
+ var r = await query(
401
+ "SELECT id, customer_id, currency, subtotal_minor, tax_minor, grand_total_minor, " +
402
+ " ship_to_json, status, created_at " +
403
+ "FROM orders " +
404
+ "WHERE id IN (" + placeholders + ") " +
405
+ " AND status IN ('paid', 'fulfilling', 'shipped', 'delivered', 'refunded')",
406
+ ids,
407
+ );
408
+ var out = [];
409
+ for (var i = 0; i < r.rows.length; i += 1) {
410
+ var row = r.rows[i];
411
+ var shipTo;
412
+ try { shipTo = JSON.parse(row.ship_to_json); }
413
+ catch (_e) { shipTo = null; }
414
+ if (!_shipToMatches(shipTo, jurisdiction)) continue;
415
+ out.push({
416
+ id: row.id,
417
+ customer_id: row.customer_id,
418
+ currency: row.currency,
419
+ subtotal_minor: Number(row.subtotal_minor),
420
+ tax_minor: Number(row.tax_minor),
421
+ grand_total_minor: row.grand_total_minor == null ? 0 : Number(row.grand_total_minor),
422
+ status: row.status,
423
+ created_at: Number(row.created_at),
424
+ ship_to: shipTo,
425
+ });
426
+ }
427
+ return out;
428
+ }
429
+
387
430
  // Total partial-refund cash refunded per order INSIDE the window. A
388
431
  // partial refund is an `on_event = 'refund'` self-loop transition that
389
432
  // leaves the order in its paid/fulfilling/shipped/delivered status (no
@@ -626,6 +669,89 @@ function create(opts) {
626
669
  bkt.order_count += 1;
627
670
  }
628
671
 
672
+ // Cross-period refunds: a refund recorded inside this window against an
673
+ // order created in an EARLIER period is never reached by the loop above
674
+ // (that order is not in `orders`). The returns-reduce-the-current-period
675
+ // convention (_refundsInWindow) requires it to reduce THIS filing anyway
676
+ // — its original sale was already remitted in the sale period, so ONLY
677
+ // the refunded portion applies here, as a pure reduction (no fresh sale).
678
+ // Without this pass the refunded tax is remitted in the sale period and
679
+ // never credited in any period → the operator over-remits.
680
+ var periodStartMs = Number(raw.period_start);
681
+ var inWindowIds = Object.create(null);
682
+ for (var wi = 0; wi < orders.length; wi += 1) { inWindowIds[orders[wi].id] = true; }
683
+ // Candidate = a refund whose order was NOT counted as an in-window sale.
684
+ // That set includes both true prior-period orders AND orders created in
685
+ // this window but already dropped by _ordersInWindow (a full refund moved
686
+ // them to `refunded`). We fetch them all, then keep ONLY those created
687
+ // before the window: an order created AND refunded in-window already nets
688
+ // to zero (its sale was excluded and its refund is not added), so netting
689
+ // its refund here would double-subtract.
690
+ var candidateIds = [];
691
+ var refundOrderIds = Object.keys(refunds);
692
+ for (var ki = 0; ki < refundOrderIds.length; ki += 1) {
693
+ if (!inWindowIds[refundOrderIds[ki]]) candidateIds.push(refundOrderIds[ki]);
694
+ }
695
+ if (candidateIds.length) {
696
+ var candidates = await _ordersByIds(raw.jurisdiction, candidateIds);
697
+ var crossOrders = [];
698
+ var minCreated = periodStartMs;
699
+ for (var cj = 0; cj < candidates.length; cj += 1) {
700
+ if (candidates[cj].created_at >= periodStartMs) continue; // in-window sale — nets to zero, skip
701
+ crossOrders.push(candidates[cj]);
702
+ if (candidates[cj].created_at < minCreated) minCreated = candidates[cj].created_at;
703
+ }
704
+ if (crossOrders.length) {
705
+ // Resolve each refund against the rate effective at the ORDER's own
706
+ // sale time — a rate that changed and expired before this window is
707
+ // NOT in `rates`, so pull the rate rows covering the earliest
708
+ // cross-period sale through this window's end. Without this a
709
+ // post-rate-change refund lands in "__none__", tax_owed omits the
710
+ // credit, and the over-remittance persists.
711
+ var crossRates = await _ratesInWindow(raw.jurisdiction, minCreated, Number(raw.period_end));
712
+ // Resolve exemption for any cross-period customer not already cached.
713
+ if (taxExemptApi && typeof taxExemptApi.isExempt === "function") {
714
+ for (var ce = 0; ce < crossOrders.length; ce += 1) {
715
+ var cxCust = crossOrders[ce].customer_id;
716
+ if (!cxCust || Object.prototype.hasOwnProperty.call(exemptByCustomer, cxCust)) continue;
717
+ try {
718
+ exemptByCustomer[cxCust] = await taxExemptApi.isExempt({
719
+ customer_id: cxCust,
720
+ jurisdiction: raw.jurisdiction,
721
+ });
722
+ } catch (_e) {
723
+ exemptByCustomer[cxCust] = false;
724
+ }
725
+ }
726
+ }
727
+ for (var xi = 0; xi < crossOrders.length; xi += 1) {
728
+ var xo = crossOrders[xi];
729
+ var xRefunded = refunds[xo.id] || 0;
730
+ if (!(xRefunded > 0) || !(xo.grand_total_minor > 0)) continue;
731
+ var xSub = _apportionMinor(xRefunded, xo.subtotal_minor, xo.grand_total_minor);
732
+ var xTax = _apportionMinor(xRefunded, xo.tax_minor, xo.grand_total_minor);
733
+ gross -= xSub;
734
+ collected -= xTax;
735
+ var xExempt = xo.customer_id && exemptByCustomer[xo.customer_id] === true;
736
+ if (xExempt) {
737
+ exempt -= xSub;
738
+ } else {
739
+ taxable -= xSub;
740
+ // The bucket may go negative — a valid per-rate CREDIT that offsets
741
+ // same-rate or other-rate sales when owed is summed across buckets
742
+ // below. It is intentionally NOT floored per-bucket: zeroing one
743
+ // negative bucket would discard the credit and over-charge owed on
744
+ // the surviving buckets.
745
+ var xRate = _rateForOrder(crossRates, xo.created_at);
746
+ var xKey = xRate ? String(xRate.rate_bps) : "__none__";
747
+ var xbkt = _bucket(xKey);
748
+ xbkt.taxable_minor -= xSub;
749
+ xbkt.tax_minor -= xTax;
750
+ }
751
+ }
752
+ }
753
+ }
754
+
629
755
  // Owed: re-derive from per-rate taxable totals using rate_bps.
630
756
  // owed = taxable × bps / 10000 per rate, half-even via b.money
631
757
  // (BigInt — taxable_minor sums every order in the window and can
@@ -652,6 +778,23 @@ function create(opts) {
652
778
  owed = collected;
653
779
  }
654
780
 
781
+ // The filing schema stores non-negative totals (CHECK ..._minor >= 0). The
782
+ // signed accumulation above lets a cross-period refund credit offset sales
783
+ // across rate buckets, so tax_owed is exact for any net-non-negative
784
+ // filing. The stored columns are floored here, kept RECONCILED so
785
+ // taxable + exempt == gross always holds (taxable floors, then exempt is
786
+ // the remainder of gross). The common case (window sales exceed the refunds
787
+ // netted in) never reaches the floor and stays exact. A true net-credit
788
+ // window — refunds outweighing the window's own sales — reports zero
789
+ // rather than a negative filing (carrying the residual credit forward is a
790
+ // separate enhancement the schema does not yet model).
791
+ if (gross < 0) gross = 0;
792
+ if (taxable < 0) taxable = 0;
793
+ if (taxable > gross) taxable = gross;
794
+ exempt = gross - taxable;
795
+ if (collected < 0) collected = 0;
796
+ if (owed < 0) owed = 0;
797
+
655
798
  var ts = _now();
656
799
  await query(
657
800
  "UPDATE sales_tax_filings SET " +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.5.13",
3
+ "version": "0.5.15",
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": {