@blamejs/blamejs-shop 0.5.20 → 0.5.21

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.
Files changed (93) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/README.md +1 -0
  3. package/SECURITY.md +17 -0
  4. package/lib/addresses.js +2 -2
  5. package/lib/admin.js +14 -5
  6. package/lib/affiliates.js +7 -23
  7. package/lib/announcement-bar.js +5 -22
  8. package/lib/api-keys.js +4 -13
  9. package/lib/asset-manifest.json +1 -1
  10. package/lib/banner-ab-tests.js +5 -9
  11. package/lib/blog-articles.js +8 -26
  12. package/lib/captcha-gate.js +7 -17
  13. package/lib/carrier-accounts.js +3 -10
  14. package/lib/catalog-drafts.js +5 -12
  15. package/lib/category-navigation.js +4 -20
  16. package/lib/cms-blocks.js +4 -7
  17. package/lib/compliance-export.js +4 -11
  18. package/lib/consent-ledger.js +2 -4
  19. package/lib/cost-layers.js +56 -21
  20. package/lib/customer-import.js +2 -2
  21. package/lib/customer-notes.js +7 -23
  22. package/lib/customer-roles.js +3 -5
  23. package/lib/customer-segments.js +17 -14
  24. package/lib/customer-surveys.js +7 -14
  25. package/lib/customers.js +2 -4
  26. package/lib/dispute-resolution.js +5 -13
  27. package/lib/dropship-forwarding.js +3 -5
  28. package/lib/dunning.js +2 -7
  29. package/lib/email-ab-tests.js +4 -8
  30. package/lib/email-campaigns.js +25 -14
  31. package/lib/email-warmup.js +2 -7
  32. package/lib/experiments.js +6 -10
  33. package/lib/externaldb-d1.js +47 -23
  34. package/lib/geolocation.js +18 -28
  35. package/lib/gift-options.js +5 -23
  36. package/lib/knowledge-base.js +7 -22
  37. package/lib/line-gift-wrap.js +3 -17
  38. package/lib/live-chat.js +5 -18
  39. package/lib/metered-usage.js +3 -8
  40. package/lib/notifications.js +3 -7
  41. package/lib/operator-accounts.js +3 -5
  42. package/lib/operator-approvals.js +6 -8
  43. package/lib/operator-audit-log.js +2 -7
  44. package/lib/operator-help-center.js +5 -21
  45. package/lib/operator-inbox.js +4 -11
  46. package/lib/operator-roles.js +5 -7
  47. package/lib/order-escalation.js +2 -2
  48. package/lib/order-export.js +24 -20
  49. package/lib/order-notes.js +4 -14
  50. package/lib/order-ratings.js +4 -10
  51. package/lib/payment-methods.js +5 -13
  52. package/lib/payment-retries.js +2 -7
  53. package/lib/pixel-events.js +8 -23
  54. package/lib/plan-changes.js +2 -7
  55. package/lib/product-qa.js +4 -8
  56. package/lib/promo-banners.js +7 -15
  57. package/lib/purchase-orders.js +3 -6
  58. package/lib/push-notifications.js +7 -32
  59. package/lib/pwa-manifest.js +6 -11
  60. package/lib/quotes.js +5 -9
  61. package/lib/reviews.js +4 -10
  62. package/lib/robots-config.js +6 -14
  63. package/lib/sales-tax-filings.js +2 -2
  64. package/lib/search-facets.js +5 -5
  65. package/lib/search-ranking.js +4 -7
  66. package/lib/search-synonyms.js +7 -21
  67. package/lib/security-middleware.js +86 -58
  68. package/lib/seller-signup.js +10 -26
  69. package/lib/sidebar-widgets.js +6 -15
  70. package/lib/site-redirects.js +5 -16
  71. package/lib/sitemap-generator.js +3 -7
  72. package/lib/sms-dispatcher.js +7 -23
  73. package/lib/stock-receipts.js +4 -10
  74. package/lib/storefront-dashboards.js +2 -10
  75. package/lib/storefront-forms.js +9 -22
  76. package/lib/storefront-pages.js +6 -20
  77. package/lib/storefront.js +2 -2
  78. package/lib/subscription-billing.js +3 -8
  79. package/lib/subscription-controls.js +2 -7
  80. package/lib/suggestion-box.js +9 -26
  81. package/lib/support-tickets.js +6 -24
  82. package/lib/tax-remittance.js +2 -2
  83. package/lib/tenants.js +2 -7
  84. package/lib/text-guard.js +137 -35
  85. package/lib/theme-assets.js +4 -16
  86. package/lib/translations.js +3 -6
  87. package/lib/trust-badges.js +4 -7
  88. package/lib/vendor-invoices.js +3 -6
  89. package/lib/vendors.js +5 -19
  90. package/lib/webhook-receiver.js +3 -10
  91. package/lib/webhook-subscriptions.js +3 -13
  92. package/lib/wishlist-sharing.js +5 -10
  93. package/package.json +1 -1
@@ -101,6 +101,7 @@ var STATUSES = Object.freeze(["active", "disabled", "rotating"]);
101
101
 
102
102
  // Framework handle (the vendored blamejs); index.js re-exports this as .framework.
103
103
  var b = require("./vendor/blamejs");
104
+ var textGuard = require("./text-guard");
104
105
 
105
106
  var ROTATION_GRACE_MS = b.constants.TIME.days(1);
106
107
 
@@ -124,10 +125,6 @@ var MAX_KEY_LEN = 512;
124
125
  // reason / operation string. These columns surface on operator
125
126
  // dashboards and inline into log lines — embedded control bytes are a
126
127
  // slipping-class for header injection + visual spoofing.
127
- var CONTROL_BYTE_RE = /[\x00-\x1f\x7f]/;
128
- var ZERO_WIDTH_RE = new RegExp(
129
- "[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
130
- );
131
128
 
132
129
  // account_number alphabet — operators occasionally space-pad or
133
130
  // hyphenate the printed form; we accept alnum + dash + space + dot and
@@ -233,9 +230,7 @@ function _label(s) {
233
230
  if (s.length === 0 || s.length > MAX_LABEL_LEN) {
234
231
  throw new TypeError("carrier-accounts: account_label must be 1.." + MAX_LABEL_LEN + " characters");
235
232
  }
236
- if (CONTROL_BYTE_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
237
- throw new TypeError("carrier-accounts: account_label contains control / zero-width bytes");
238
- }
233
+ textGuard.freeText(s, "carrier-accounts: account_label", { singleLine: "reject", zeroWidth: "reject" });
239
234
  return s;
240
235
  }
241
236
 
@@ -250,9 +245,7 @@ function _reason(s) {
250
245
  if (s.length > MAX_REASON_LEN) {
251
246
  throw new TypeError("carrier-accounts: reason must be <= " + MAX_REASON_LEN + " characters");
252
247
  }
253
- if (CONTROL_BYTE_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
254
- throw new TypeError("carrier-accounts: reason contains control / zero-width bytes");
255
- }
248
+ textGuard.freeText(s, "carrier-accounts: reason", { singleLine: "reject", zeroWidth: "reject" });
256
249
  return s;
257
250
  }
258
251
 
@@ -106,6 +106,7 @@ var MAX_CHANGES_PER_DRAFT = 5000;
106
106
 
107
107
  // Framework handle (the vendored blamejs); index.js re-exports this as .framework.
108
108
  var b = require("./vendor/blamejs");
109
+ var textGuard = require("./text-guard");
109
110
 
110
111
  var ROLLBACK_WINDOW_MS = b.constants.TIME.days(7);
111
112
 
@@ -125,10 +126,6 @@ var PRODUCT_SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,198}[a-z0-9])?$/;
125
126
  // to operator-authored fields — refuse control bytes + zero-width /
126
127
  // direction-override codepoints so a rollback_reason / cancel_reason
127
128
  // can't smuggle UI-confusing glyphs into the audit dashboard.
128
- var CONTROL_BYTE_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
129
- var ZERO_WIDTH_RE = new RegExp(
130
- "[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
131
- );
132
129
 
133
130
 
134
131
  // ---- validators ---------------------------------------------------------
@@ -144,7 +141,7 @@ function _title(s, label) {
144
141
  if (typeof s !== "string" || !s.length || s.length > MAX_TITLE_LEN) {
145
142
  throw new TypeError("catalogDrafts: " + label + " must be a non-empty string <= " + MAX_TITLE_LEN + " chars");
146
143
  }
147
- if (CONTROL_BYTE_RE.test(s)) {
144
+ if (textGuard.hasCodepointThreat(s)) {
148
145
  throw new TypeError("catalogDrafts: " + label + " must not contain control bytes");
149
146
  }
150
147
  return s;
@@ -155,9 +152,7 @@ function _description(s) {
155
152
  if (typeof s !== "string" || s.length > MAX_DESC_LEN) {
156
153
  throw new TypeError("catalogDrafts: description must be a string <= " + MAX_DESC_LEN + " chars when provided");
157
154
  }
158
- if (CONTROL_BYTE_RE.test(s)) {
159
- throw new TypeError("catalogDrafts: description must not contain control bytes");
160
- }
155
+ textGuard.freeText(s, "catalogDrafts: description");
161
156
  return s;
162
157
  }
163
158
 
@@ -169,7 +164,7 @@ function _optReason(s, label) {
169
164
  if (s.length > MAX_REASON_LEN) {
170
165
  throw new TypeError("catalogDrafts: " + label + " must be <= " + MAX_REASON_LEN + " chars");
171
166
  }
172
- if (CONTROL_BYTE_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
167
+ if (textGuard.hasCodepointThreat(s, { zeroWidth: "reject" })) {
173
168
  throw new TypeError("catalogDrafts: " + label + " contains control / zero-width bytes");
174
169
  }
175
170
  return s;
@@ -187,9 +182,7 @@ function _optOwner(s) {
187
182
  if (typeof s !== "string" || !s.length || s.length > MAX_OWNER_LEN) {
188
183
  throw new TypeError("catalogDrafts: owner_id must be a non-empty string <= " + MAX_OWNER_LEN + " chars when provided");
189
184
  }
190
- if (CONTROL_BYTE_RE.test(s)) {
191
- throw new TypeError("catalogDrafts: owner_id must not contain control bytes");
192
- }
185
+ textGuard.freeText(s, "catalogDrafts: owner_id");
193
186
  return s;
194
187
  }
195
188
 
@@ -111,13 +111,9 @@ var MAX_POPULAR_LIMIT = 100;
111
111
  var MAX_REORDER_BATCH = 500;
112
112
 
113
113
  var SLUG_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
114
- var CONTROL_BYTE_STRICT_RE = /[\x00-\x1f\x7f]/;
115
- var CONTROL_BYTE_BODY_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
116
- var ZERO_WIDTH_RE = new RegExp(
117
- "[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
118
- );
119
114
 
120
115
  var b = require("./vendor/blamejs");
116
+ var textGuard = require("./text-guard");
121
117
 
122
118
  // ---- monotonic clock ---------------------------------------------------
123
119
  //
@@ -161,12 +157,7 @@ function _title(s) {
161
157
  if (s.length > MAX_TITLE_LEN) {
162
158
  throw new TypeError("categoryNavigation: title must be <= " + MAX_TITLE_LEN + " characters");
163
159
  }
164
- if (CONTROL_BYTE_STRICT_RE.test(s)) {
165
- throw new TypeError("categoryNavigation: title contains control bytes");
166
- }
167
- if (ZERO_WIDTH_RE.test(s)) {
168
- throw new TypeError("categoryNavigation: title contains zero-width / direction-override characters");
169
- }
160
+ textGuard.freeText(s, "categoryNavigation: title", { singleLine: "reject", zeroWidth: "reject" });
170
161
  return s;
171
162
  }
172
163
 
@@ -178,12 +169,7 @@ function _description(s) {
178
169
  if (s.length > MAX_DESCRIPTION_LEN) {
179
170
  throw new TypeError("categoryNavigation: description must be <= " + MAX_DESCRIPTION_LEN + " characters");
180
171
  }
181
- if (CONTROL_BYTE_BODY_RE.test(s)) {
182
- throw new TypeError("categoryNavigation: description contains control bytes");
183
- }
184
- if (ZERO_WIDTH_RE.test(s)) {
185
- throw new TypeError("categoryNavigation: description contains zero-width / direction-override characters");
186
- }
172
+ textGuard.freeText(s, "categoryNavigation: description", { zeroWidth: "reject" });
187
173
  return s;
188
174
  }
189
175
 
@@ -200,9 +186,7 @@ function _heroImageUrl(s) {
200
186
  if (s.length > MAX_HERO_URL_LEN) {
201
187
  throw new TypeError("categoryNavigation: hero_image_url must be <= " + MAX_HERO_URL_LEN + " characters");
202
188
  }
203
- if (CONTROL_BYTE_BODY_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
204
- throw new TypeError("categoryNavigation: hero_image_url contains control / zero-width bytes");
205
- }
189
+ textGuard.freeText(s, "categoryNavigation: hero_image_url", { zeroWidth: "reject" });
206
190
  if (s.charCodeAt(0) === 47 /* "/" */) {
207
191
  if (s.length > 1 && s.charCodeAt(1) === 47) {
208
192
  throw new TypeError("categoryNavigation: hero_image_url protocol-relative `//host/...` refused — use absolute https://");
package/lib/cms-blocks.js CHANGED
@@ -116,16 +116,13 @@ var LOCALE_RE = /^[a-z]{2,3}(?:-[a-z0-9]{2,8})*$/;
116
116
 
117
117
  // Refuse C0 control bytes + DEL in operator-authored bodies. LF / CR
118
118
  // / tab are allowed — operators author multi-line Markdown.
119
- var CONTROL_BYTE_BLOCK_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
120
119
 
121
120
  // Zero-width / direction-override family. Spelled with \u-escapes
122
121
  // so ESLint's no-irregular-whitespace stays happy. Same defence as
123
122
  // the rest of the shop primitives.
124
- var ZERO_WIDTH_RE = new RegExp(
125
- "[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
126
- );
127
123
 
128
124
  var b = require("./vendor/blamejs");
125
+ var textGuard = require("./text-guard");
129
126
 
130
127
  // ---- validators ---------------------------------------------------------
131
128
 
@@ -141,10 +138,10 @@ function _body(s, label) {
141
138
  if (typeof s !== "string" || !s.length || s.length > MAX_BODY_LEN) {
142
139
  throw new TypeError("cmsBlocks: " + label + " must be a non-empty string <= " + MAX_BODY_LEN + " chars");
143
140
  }
144
- if (CONTROL_BYTE_BLOCK_RE.test(s)) {
141
+ if (textGuard.hasCodepointThreat(s)) {
145
142
  throw new TypeError("cmsBlocks: " + label + " contains control bytes");
146
143
  }
147
- if (ZERO_WIDTH_RE.test(s)) {
144
+ if (textGuard.hasCodepointThreat(s, { zeroWidth: "reject" })) {
148
145
  throw new TypeError("cmsBlocks: " + label + " contains zero-width / direction-override characters");
149
146
  }
150
147
  return s;
@@ -241,7 +238,7 @@ function _esc(s) {
241
238
 
242
239
  function _safeLinkUrl(url) {
243
240
  if (typeof url !== "string" || !url.length || url.length > 2048) return null;
244
- if (CONTROL_BYTE_BLOCK_RE.test(url) || ZERO_WIDTH_RE.test(url)) return null;
241
+ if (textGuard.hasCodepointThreat(url, { zeroWidth: "reject" })) return null;
245
242
  if (url.charCodeAt(0) === 47 /* "/" */) {
246
243
  if (url.length > 1 && url.charCodeAt(1) === 47) return null;
247
244
  if (url.indexOf("..") !== -1) return null;
@@ -114,6 +114,7 @@
114
114
  */
115
115
 
116
116
  var b = require("./vendor/blamejs");
117
+ var textGuard = require("./text-guard");
117
118
 
118
119
  // ---- constants ----------------------------------------------------------
119
120
 
@@ -177,10 +178,6 @@ var DEFAULT_LIST_LIMIT = 50;
177
178
  // Operator-authored prose lands in reason / dismiss_reason and
178
179
  // replays into compliance review screens. Same control-byte +
179
180
  // zero-width refusal posture the sibling primitives carry.
180
- var CONTROL_BYTE_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
181
- var ZERO_WIDTH_RE = new RegExp(
182
- "[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
183
- );
184
181
 
185
182
  // Scope -> which injected readers contribute on export. Keeps the
186
183
  // fulfillRequest logic a single lookup instead of nested if-else.
@@ -256,9 +253,7 @@ function _requestedBy(s) {
256
253
  if (typeof s !== "string" || !s.length || s.length > MAX_REQUESTED_BY_LEN) {
257
254
  throw new TypeError("complianceExport: requested_by must be a non-empty string <= " + MAX_REQUESTED_BY_LEN + " chars");
258
255
  }
259
- if (CONTROL_BYTE_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
260
- throw new TypeError("complianceExport: requested_by must not contain control bytes or zero-width characters");
261
- }
256
+ textGuard.freeText(s, "complianceExport: requested_by", { zeroWidth: "reject" });
262
257
  return s;
263
258
  }
264
259
 
@@ -266,7 +261,7 @@ function _prose(s, label, maxLen) {
266
261
  if (typeof s !== "string" || !s.length || s.length > maxLen) {
267
262
  throw new TypeError("complianceExport: " + label + " must be a non-empty string <= " + maxLen + " chars");
268
263
  }
269
- if (CONTROL_BYTE_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
264
+ if (textGuard.hasCodepointThreat(s, { zeroWidth: "reject" })) {
270
265
  throw new TypeError("complianceExport: " + label + " must not contain control bytes or zero-width characters");
271
266
  }
272
267
  return s;
@@ -286,9 +281,7 @@ function _deliveryAddress(s) {
286
281
  if (typeof s !== "string" || !s.length || s.length > MAX_DELIVERY_ADDR_LEN) {
287
282
  throw new TypeError("complianceExport: delivery_address must be a non-empty string <= " + MAX_DELIVERY_ADDR_LEN + " chars");
288
283
  }
289
- if (CONTROL_BYTE_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
290
- throw new TypeError("complianceExport: delivery_address must not contain control bytes or zero-width characters");
291
- }
284
+ textGuard.freeText(s, "complianceExport: delivery_address", { zeroWidth: "reject" });
292
285
  return s;
293
286
  }
294
287
 
@@ -123,7 +123,6 @@ var JURISDICTION_RE = /^[A-Z]{2}$/;
123
123
  var EVIDENCE_REF_MAX_LEN = 256;
124
124
  var EVIDENCE_REF_RE = /^[A-Za-z0-9][A-Za-z0-9._:/-]*$/;
125
125
 
126
- var CONTROL_BYTE_RE = /[\x00-\x1f\x7f]/;
127
126
 
128
127
  var CSV_COLUMNS = Object.freeze([
129
128
  "id",
@@ -138,6 +137,7 @@ var CSV_COLUMNS = Object.freeze([
138
137
  ]);
139
138
 
140
139
  var b = require("./vendor/blamejs");
140
+ var textGuard = require("./text-guard");
141
141
 
142
142
  // The GDPR Art. 6(1) lawful bases, sourced from the framework's
143
143
  // consent primitive so this ledger's vocabulary tracks the
@@ -269,9 +269,7 @@ function _optEvidenceRef(s) {
269
269
  "consentLedger: evidence_ref must be <= " + EVIDENCE_REF_MAX_LEN + " characters"
270
270
  );
271
271
  }
272
- if (CONTROL_BYTE_RE.test(s)) {
273
- throw new TypeError("consentLedger: evidence_ref must not contain control bytes");
274
- }
272
+ textGuard.freeText(s, "consentLedger: evidence_ref", { singleLine: "reject" });
275
273
  if (!EVIDENCE_REF_RE.test(s)) {
276
274
  throw new TypeError(
277
275
  "consentLedger: evidence_ref must match /^[A-Za-z0-9][A-Za-z0-9._:/-]*$/"
@@ -108,6 +108,16 @@ var b = require("./vendor/blamejs");
108
108
 
109
109
  var SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
110
110
  var CURRENCY_RE = /^[A-Z]{3}$/;
111
+
112
+ // Carrier for the largest-remainder split of a line's COGS across the layers
113
+ // it drew from (see consumeForSale). b.money.allocate distributes a count of
114
+ // MINOR UNITS, so the currency is a type tag and not an input to the result —
115
+ // the split of 10040 across 40/40/20 is 4016/4016/2008 whether the money is
116
+ // 2-decimal, 0-decimal or this. It is deliberately NOT the layers' currency:
117
+ // receipts accept any well-formed ISO 4217 code while b.money accepts a
118
+ // narrower catalog, and pricing layers in one outside it would otherwise make
119
+ // them receivable but never sellable.
120
+ var ALLOCATION_CARRIER = "USD";
111
121
  var METHODS = Object.freeze(["fifo", "lifo", "weighted_average"]);
112
122
  var RECEIPT_SOURCES = Object.freeze(["receipt", "transfer", "adjustment"]);
113
123
  // Internal source value used by `recordReversal` only; operators
@@ -453,11 +463,6 @@ function create(opts) {
453
463
  // don't accumulate a fractional debt.
454
464
  var lineNumerator = sumValue * quantity;
455
465
  var lineCogs = Math.floor(lineNumerator / sumQty);
456
- // Residual carried forward to per-layer attribution rows so
457
- // the sum of attributions matches lineCogs exactly. The first
458
- // layer in the iteration absorbs the residual (smallest
459
- // possible deviation, deterministic placement).
460
- var residual = lineCogs;
461
466
  // Debit every layer proportionally. Use integer division so
462
467
  // each layer's debit is an integer; spread any leftover
463
468
  // single units across the earliest layers (deterministic).
@@ -491,20 +496,45 @@ function create(opts) {
491
496
  remainingToConsume -= 1;
492
497
  }
493
498
  }
494
- // Per-attribution unit cost: weighted-average rounded to the
495
- // nearest integer minor unit. Track the residual so the last
496
- // attribution carries any rounding adjustment.
497
- var avgUnitCost = sumQty > 0 ? Math.round(sumValue / sumQty) : 0;
499
+ // Split the line's COGS across the debits in proportion to the units
500
+ // each one contributes. b.money.allocate distributes by largest
501
+ // remainder: every row lands within a single minor unit of its exact
502
+ // share, and the parts sum to the total with nothing left over.
503
+ //
504
+ // What it replaces priced each debit at the weighted-average unit cost
505
+ // ROUNDED to an integer, then handed the last debit whatever remained.
506
+ // Rounding the average absorbed a fraction of a minor unit per unit
507
+ // debited, and every one of those fractions landed on the final row.
508
+ // Consuming 40 + 40 + 20 units at an average of 100.4 attributed
509
+ // 4000 / 4000 / 2040 where the true shares are 4016 / 4016 / 2008 — a
510
+ // 32-minor-unit misattribution from one sale, growing with the
511
+ // quantities ahead of the last debit. The line total was always right,
512
+ // so nothing was over- or under-charged; it was the per-layer COGS the
513
+ // margin and valuation reports read that was wrong.
514
+ //
515
+ // The split is carried out on a Money in ALLOCATION_CARRIER rather than
516
+ // in the layers' own currency, and the currency genuinely does not
517
+ // enter the arithmetic: allocate distributes a count of minor units by
518
+ // largest remainder, so the exponent never participates. The identical
519
+ // split for a 2-decimal, a 0-decimal and this carrier is asserted in
520
+ // cost-layers.test.js so the assumption cannot rot silently.
521
+ //
522
+ // Using the layers' own currency would read better and is what this
523
+ // wanted to do. It cannot: b.money accepts a deliberately narrow
524
+ // catalog of ISO 4217 codes, while recordReceipt accepts any
525
+ // well-formed one. Pricing layers in a code outside that catalog is
526
+ // accepted today, and passing it here would throw on every
527
+ // weighted-average sale — turning a rounding inaccuracy into inventory
528
+ // that can be received but never sold. Asked upstream for an
529
+ // integer-level allocation in blamejs/blamejs#578.
530
+ var attrShares = debits.length
531
+ ? b.money.fromMinorUnits(BigInt(lineCogs), ALLOCATION_CARRIER)
532
+ .allocate(debits.map(function (dd) { return dd.qty; }))
533
+ .map(function (m) { return Number(m.toMinorUnits()); })
534
+ : [];
498
535
  for (var e = 0; e < debits.length; e += 1) {
499
536
  var dRow = debits[e];
500
- var attrCogs;
501
- if (e === debits.length - 1) {
502
- attrCogs = residual;
503
- } else {
504
- attrCogs = dRow.qty * avgUnitCost;
505
- if (attrCogs > residual) attrCogs = residual;
506
- residual -= attrCogs;
507
- }
537
+ var attrCogs = attrShares[e];
508
538
  // Attribute the debit's cost so `qty * unit_cost_minor` summed over
509
539
  // its attribution rows reconstructs attrCogs EXACTLY — the COGS
510
540
  // reports (cogsForOrder / cogsForPeriod) recover each line's cost
@@ -515,10 +545,15 @@ function create(opts) {
515
545
  // (qty - r) units at floor, r units at floor+1, where
516
546
  // r = attrCogs - floor*qty. (qty-r)*floor + r*(floor+1) =
517
547
  // qty*floor + r = attrCogs — no penny drift between the sale's COGS
518
- // and the persisted attributions (the prior single rounded per-unit
519
- // drifted by up to qty-1 minor units on the residual-bearing row).
520
- var floorUnit = dRow.qty > 0 ? Math.floor(attrCogs / dRow.qty) : avgUnitCost;
521
- var rem = dRow.qty > 0 ? attrCogs - (floorUnit * dRow.qty) : 0;
548
+ // and the persisted attributions (a single rounded per-unit price
549
+ // drifts by up to qty-1 minor units on the row that carries the
550
+ // remainder).
551
+ //
552
+ // A debit is only recorded when its share is at least one unit (see
553
+ // the push above), so the division below always has a positive
554
+ // divisor.
555
+ var floorUnit = Math.floor(attrCogs / dRow.qty);
556
+ var rem = attrCogs - (floorUnit * dRow.qty);
522
557
  var parts = [];
523
558
  if (dRow.qty - rem > 0) parts.push({ qty: dRow.qty - rem, unit_cost_minor: floorUnit });
524
559
  if (rem > 0) parts.push({ qty: rem, unit_cost_minor: floorUnit + 1 });
@@ -50,13 +50,13 @@
50
50
  */
51
51
 
52
52
  var b = require("./vendor/blamejs");
53
+ var textGuard = require("./text-guard");
53
54
 
54
55
  var DEFAULT_MAX_ROWS = 200000;
55
56
  var DEFAULT_MAX_ERRORS = 1000;
56
57
  var SOURCES = Object.freeze(["csv", "ndjson", "api"]);
57
58
  var ON_CONFLICT_MODES = Object.freeze(["update", "skip", "error"]);
58
59
  var CSV_EXPECTED_HEADERS = Object.freeze(["email", "display_name"]);
59
- var CONTROL_BYTE_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
60
60
  var MAX_DISPLAY_NAME_LEN = 128;
61
61
 
62
62
  // ---- factory ------------------------------------------------------------
@@ -143,7 +143,7 @@ function _normalizeRow(raw, rowIndex) {
143
143
  if (displayName.length > MAX_DISPLAY_NAME_LEN) {
144
144
  throw new TypeError("row " + rowIndex + ": display_name must be <= " + MAX_DISPLAY_NAME_LEN + " characters");
145
145
  }
146
- if (CONTROL_BYTE_RE.test(displayName)) {
146
+ if (textGuard.hasCodepointThreat(displayName)) {
147
147
  throw new TypeError("row " + rowIndex + ": display_name contains control bytes");
148
148
  }
149
149
  // guardEmail strict profile — refuses smuggling / header-injection /
@@ -46,6 +46,7 @@
46
46
  */
47
47
 
48
48
  var b = require("./vendor/blamejs");
49
+ var textGuard = require("./text-guard");
49
50
 
50
51
  // ---- constants ----------------------------------------------------------
51
52
 
@@ -63,17 +64,9 @@ var MAX_LIMIT = 200;
63
64
  var DEFAULT_TAG_LIMIT = 20;
64
65
  var MAX_TAG_FETCH = 100;
65
66
 
66
- // Refuse C0 control bytes + DEL across the body, tags, and ids. CR/LF
67
- // + tab survive in `body` so operators can wrap a multi-line note;
68
- // tags + ids are single-line and refuse CR/LF too via the strict re.
69
- var CONTROL_BYTE_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
70
- var CONTROL_BYTE_STRICT_RE = /[\x00-\x1f\x7f]/;
71
- // Zero-width / direction-override / BOM family. Spelled with
72
- // \u-escapes so the source file stays free of irregular-whitespace
73
- // ESLint hits.
74
- var ZERO_WIDTH_RE = new RegExp(
75
- "[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
76
- );
67
+ // Control bytes, bidi overrides and the zero-width family are screened by
68
+ // textGuard.freeText. CR/LF + tab survive in `body` so an operator can wrap a
69
+ // multi-line note; tags + ids are single-line and refuse them.
77
70
 
78
71
  // Tag shape — lowercase alnum + dash + underscore. The operator UI
79
72
  // canonicalises on input (lower-case) so the popularTags aggregate
@@ -110,9 +103,7 @@ function _customerId(s) {
110
103
  if (typeof s !== "string" || !s.length || s.length > MAX_CUSTOMER_ID) {
111
104
  throw new TypeError("customerNotes: customer_id must be a non-empty string (<= " + MAX_CUSTOMER_ID + " chars)");
112
105
  }
113
- if (CONTROL_BYTE_STRICT_RE.test(s)) {
114
- throw new TypeError("customerNotes: customer_id must not contain control bytes");
115
- }
106
+ textGuard.freeText(s, "customerNotes: customer_id", { singleLine: "reject" });
116
107
  return s;
117
108
  }
118
109
 
@@ -128,9 +119,7 @@ function _authorId(s) {
128
119
  if (typeof s !== "string" || !s.length || s.length > MAX_AUTHOR_ID) {
129
120
  throw new TypeError("customerNotes: author_id must be a non-empty string (<= " + MAX_AUTHOR_ID + " chars) when provided");
130
121
  }
131
- if (CONTROL_BYTE_STRICT_RE.test(s)) {
132
- throw new TypeError("customerNotes: author_id must not contain control bytes");
133
- }
122
+ textGuard.freeText(s, "customerNotes: author_id", { singleLine: "reject" });
134
123
  return s;
135
124
  }
136
125
 
@@ -153,12 +142,7 @@ function _body(s) {
153
142
  if (s.length > MAX_BODY_LEN) {
154
143
  throw new TypeError("customerNotes: body must be <= " + MAX_BODY_LEN + " characters");
155
144
  }
156
- if (CONTROL_BYTE_RE.test(s)) {
157
- throw new TypeError("customerNotes: body must not contain control bytes");
158
- }
159
- if (ZERO_WIDTH_RE.test(s)) {
160
- throw new TypeError("customerNotes: body must not contain zero-width / direction-override characters");
161
- }
145
+ textGuard.freeText(s, "customerNotes: body", { zeroWidth: "reject" });
162
146
  return s;
163
147
  }
164
148
 
@@ -119,7 +119,6 @@ var MAX_LIST_LIMIT = 200;
119
119
  // hyphen + underscore + dot, capped length.
120
120
  var SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
121
121
 
122
- var CONTROL_BYTE_RE = /[\x00-\x1f\x7f]/;
123
122
 
124
123
  // Closed capability enum. Adding a new capability is a code change
125
124
  // here + the operator re-defines the affected roles.
@@ -137,6 +136,7 @@ var CAPABILITIES = Object.freeze([
137
136
  var ALLOWED_PATCH_COLUMNS = Object.freeze(["title", "capabilities"]);
138
137
 
139
138
  var b = require("./vendor/blamejs");
139
+ var textGuard = require("./text-guard");
140
140
 
141
141
  // ---- validators ---------------------------------------------------------
142
142
 
@@ -152,9 +152,7 @@ function _title(s) {
152
152
  if (typeof s !== "string" || !s.length || s.length > MAX_TITLE_LEN) {
153
153
  throw new TypeError("customerRoles: title must be a non-empty string <= " + MAX_TITLE_LEN + " chars");
154
154
  }
155
- if (CONTROL_BYTE_RE.test(s)) {
156
- throw new TypeError("customerRoles: title must not contain control bytes");
157
- }
155
+ textGuard.freeText(s, "customerRoles: title", { singleLine: "reject" });
158
156
  return s;
159
157
  }
160
158
 
@@ -192,7 +190,7 @@ function _opaqueId(s, label) {
192
190
  if (typeof s !== "string" || !s.length || s.length > 200) {
193
191
  throw new TypeError("customerRoles: " + label + " must be a non-empty string (<= 200 chars)");
194
192
  }
195
- if (CONTROL_BYTE_RE.test(s)) {
193
+ if (textGuard.hasCodepointThreat(s, { singleLine: "reject" })) {
196
194
  throw new TypeError("customerRoles: " + label + " must not contain control bytes");
197
195
  }
198
196
  return s;
@@ -113,6 +113,7 @@ var C = b.constants;
113
113
 
114
114
  var DEFAULT_LIMIT = 100;
115
115
  var MAX_LIMIT = 1000;
116
+ var CSV_LINE_ENDING = "\r\n"; // RFC 4180
116
117
  var EXPORT_BATCH_SIZE = 500;
117
118
 
118
119
  // CSV column shape for a segment-members export. NO email column —
@@ -347,19 +348,22 @@ function _coerceCell(value) {
347
348
  return JSON.stringify(value);
348
349
  }
349
350
 
350
- function _neutralizeInjection(s) {
351
- return b.guardCsv.escapeCell(s);
352
- }
353
-
354
- function _csvCell(value) {
355
- var s = _neutralizeInjection(_coerceCell(value));
356
- return '"' + s.replace(/"/g, '""') + '"';
357
- }
358
-
351
+ // One member-export row, RFC 4180 — b.guardCsv.serialize writes the whole
352
+ // line: the formula-injection prefix per cell, the unconditional quoting,
353
+ // doubling an embedded quote, the delimiter and the CRLF. Only the injection
354
+ // step was composed before; the quoting rules were written here and again in
355
+ // order-export.js.
356
+ //
357
+ // serialize omits the trailing terminator, which suits a whole document and
358
+ // not a generator yielding a row at a time, so the line ending is appended
359
+ // here.
359
360
  function _csvRow(cells) {
360
- var parts = [];
361
- for (var i = 0; i < cells.length; i += 1) parts.push(_csvCell(cells[i]));
362
- return parts.join(",") + "\r\n";
361
+ return b.guardCsv.serialize([cells.map(_coerceCell)], {
362
+ profile: "strict",
363
+ alwaysQuote: true,
364
+ headers: false,
365
+ lineEnding: CSV_LINE_ENDING,
366
+ }) + CSV_LINE_ENDING;
363
367
  }
364
368
 
365
369
  // ---- factory ------------------------------------------------------------
@@ -975,6 +979,5 @@ module.exports = {
975
979
  MEMBER_EXPORT_COLUMNS: MEMBER_EXPORT_COLUMNS,
976
980
  // Exposed for the test suite to assert CSV cell semantics without a
977
981
  // full DB round-trip.
978
- _csvCell: _csvCell,
979
- _neutralizeInjection: _neutralizeInjection,
982
+ _csvRow: _csvRow,
980
983
  };
@@ -69,6 +69,7 @@
69
69
  */
70
70
 
71
71
  var b = require("./vendor/blamejs");
72
+ var textGuard = require("./text-guard");
72
73
  var C = b.constants;
73
74
 
74
75
  // ---- constants ----------------------------------------------------------
@@ -104,8 +105,6 @@ var MAX_EXPIRES_HOURS = 24 * 365; // 1 year
104
105
  var DEFAULT_LIMIT = 50;
105
106
  var MAX_LIMIT = 500;
106
107
 
107
- var CONTROL_BYTE_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
108
- var CONTROL_BYTE_STRICT_RE = /[\x00-\x1f\x7f]/;
109
108
 
110
109
  // NPS / CSAT / CES enforce a primary-question shape so the rollup
111
110
  // math has something well-defined to bucket against. Custom surveys
@@ -143,9 +142,7 @@ function _title(s) {
143
142
  if (typeof s !== "string" || !s.length || s.length > MAX_TITLE_LEN) {
144
143
  throw new TypeError("customerSurveys: title must be a non-empty string <= " + MAX_TITLE_LEN + " chars");
145
144
  }
146
- if (CONTROL_BYTE_RE.test(s)) {
147
- throw new TypeError("customerSurveys: title must not contain control bytes");
148
- }
145
+ textGuard.freeText(s, "customerSurveys: title");
149
146
  return s;
150
147
  }
151
148
 
@@ -173,9 +170,7 @@ function _sourceEventId(s) {
173
170
  if (typeof s !== "string" || s.length > MAX_SOURCE_EVENT_LEN) {
174
171
  throw new TypeError("customerSurveys: source_event_id must be a string <= " + MAX_SOURCE_EVENT_LEN + " chars");
175
172
  }
176
- if (CONTROL_BYTE_STRICT_RE.test(s)) {
177
- throw new TypeError("customerSurveys: source_event_id must not contain control bytes");
178
- }
173
+ textGuard.freeText(s, "customerSurveys: source_event_id", { singleLine: "reject" });
179
174
  return s;
180
175
  }
181
176
 
@@ -192,9 +187,7 @@ function _closeReason(s) {
192
187
  if (typeof s !== "string" || !s.length || s.length > MAX_CLOSE_REASON_LEN) {
193
188
  throw new TypeError("customerSurveys: reason must be a non-empty string <= " + MAX_CLOSE_REASON_LEN + " chars");
194
189
  }
195
- if (CONTROL_BYTE_RE.test(s)) {
196
- throw new TypeError("customerSurveys: reason must not contain control bytes");
197
- }
190
+ textGuard.freeText(s, "customerSurveys: reason");
198
191
  return s;
199
192
  }
200
193
 
@@ -245,7 +238,7 @@ function _questions(arr, kind) {
245
238
  if (typeof q.label !== "string" || !q.label.length || q.label.length > MAX_LABEL_LEN) {
246
239
  throw new TypeError("customerSurveys: questions[" + i + "].label must be a non-empty string <= " + MAX_LABEL_LEN + " chars");
247
240
  }
248
- if (CONTROL_BYTE_RE.test(q.label)) {
241
+ if (textGuard.hasCodepointThreat(q.label)) {
249
242
  throw new TypeError("customerSurveys: questions[" + i + "].label must not contain control bytes");
250
243
  }
251
244
  var required = q.required == null ? true : q.required;
@@ -284,7 +277,7 @@ function _questions(arr, kind) {
284
277
  throw new TypeError("customerSurveys: questions[" + i + "].options[" + k +
285
278
  "] must be a non-empty string <= " + MAX_OPTION_LEN + " chars");
286
279
  }
287
- if (CONTROL_BYTE_RE.test(opt)) {
280
+ if (textGuard.hasCodepointThreat(opt)) {
288
281
  throw new TypeError("customerSurveys: questions[" + i + "].options[" + k +
289
282
  "] must not contain control bytes");
290
283
  }
@@ -392,7 +385,7 @@ function _validateAnswers(answers, questions) {
392
385
  throw new TypeError("customerSurveys: answer for " + JSON.stringify(q.id) +
393
386
  " must be <= " + q.max + " chars");
394
387
  }
395
- if (CONTROL_BYTE_RE.test(a)) {
388
+ if (textGuard.hasCodepointThreat(a)) {
396
389
  throw new TypeError("customerSurveys: answer for " + JSON.stringify(q.id) +
397
390
  " must not contain control bytes");
398
391
  }