@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
package/lib/customers.js CHANGED
@@ -36,6 +36,7 @@
36
36
  var nodeCrypto = require("node:crypto"); // allow:non-shop-require — Apple mandates ES256/P1363; framework has no classical ES256 signer
37
37
 
38
38
  var b = require("./vendor/blamejs");
39
+ var textGuard = require("./text-guard");
39
40
 
40
41
  var EMAIL_NAMESPACE = "customer-email";
41
42
  // Federated identity providers the OIDC sign-in path accepts.
@@ -43,7 +44,6 @@ var OAUTH_PROVIDERS = ["google", "apple"];
43
44
  var MAX_DISPLAY_NAME_LEN = 128;
44
45
  var MAX_CRED_FIELD_BYTES = 4096;
45
46
  var TRANSPORTS_RE = /^[a-z]+(?:,[a-z]+)*$/;
46
- var CONTROL_BYTE_RE = /[\x00-\x1f\x7f]/;
47
47
 
48
48
  // Pagination cursor for the operator-facing `list` — newest first by
49
49
  // (created_at, id), HMAC-tagged via b.pagination so an operator can't
@@ -105,9 +105,7 @@ function _displayName(s) {
105
105
  if (s.length > MAX_DISPLAY_NAME_LEN) {
106
106
  throw new TypeError("customers: display_name must be <= " + MAX_DISPLAY_NAME_LEN + " characters");
107
107
  }
108
- if (CONTROL_BYTE_RE.test(s)) {
109
- throw new TypeError("customers: display_name contains control bytes");
110
- }
108
+ textGuard.freeText(s, "customers: display_name", { singleLine: "reject" });
111
109
  return s;
112
110
  }
113
111
 
@@ -114,7 +114,6 @@ var PROCESSOR_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
114
114
  var REASON_CODE_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/;
115
115
  var CURRENCY_RE = /^[A-Z]{3}$/;
116
116
  var OPERATOR_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
117
- var CONTROL_BYTE_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
118
117
 
119
118
  var KINDS = Object.freeze([
120
119
  "chargeback",
@@ -151,6 +150,7 @@ var STATUSES = Object.freeze([
151
150
  ]);
152
151
 
153
152
  var b = require("./vendor/blamejs");
153
+ var textGuard = require("./text-guard");
154
154
 
155
155
  // ---- validators ---------------------------------------------------------
156
156
 
@@ -249,9 +249,7 @@ function _narrative(s) {
249
249
  " exceeds cap " + MAX_NARRATIVE_LEN
250
250
  );
251
251
  }
252
- if (CONTROL_BYTE_RE.test(s)) {
253
- throw new TypeError("disputeResolution: narrative must not contain control bytes");
254
- }
252
+ textGuard.freeText(s, "disputeResolution: narrative");
255
253
  return s;
256
254
  }
257
255
 
@@ -265,9 +263,7 @@ function _blobRef(s) {
265
263
  " exceeds cap " + MAX_BLOB_REF_LEN
266
264
  );
267
265
  }
268
- if (CONTROL_BYTE_RE.test(s)) {
269
- throw new TypeError("disputeResolution: blob_ref must not contain control bytes");
270
- }
266
+ textGuard.freeText(s, "disputeResolution: blob_ref");
271
267
  return s;
272
268
  }
273
269
 
@@ -281,9 +277,7 @@ function _notesOpt(s) {
281
277
  "disputeResolution: notes length " + s.length + " exceeds cap " + MAX_NOTES_LEN
282
278
  );
283
279
  }
284
- if (CONTROL_BYTE_RE.test(s)) {
285
- throw new TypeError("disputeResolution: notes must not contain control bytes");
286
- }
280
+ textGuard.freeText(s, "disputeResolution: notes");
287
281
  return s;
288
282
  }
289
283
 
@@ -307,9 +301,7 @@ function _writeoffReason(s) {
307
301
  " exceeds cap " + MAX_WRITEOFF_REASON_LEN
308
302
  );
309
303
  }
310
- if (CONTROL_BYTE_RE.test(s)) {
311
- throw new TypeError("disputeResolution: writeoff reason must not contain control bytes");
312
- }
304
+ textGuard.freeText(s, "disputeResolution: writeoff reason");
313
305
  return s;
314
306
  }
315
307
 
@@ -66,6 +66,7 @@
66
66
  */
67
67
 
68
68
  var b = require("./vendor/blamejs");
69
+ var textGuard = require("./text-guard");
69
70
 
70
71
  // ---- constants ----------------------------------------------------------
71
72
 
@@ -81,7 +82,6 @@ var FORWARDING_STATUSES = Object.freeze([
81
82
  var SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
82
83
  var SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
83
84
  var CURRENCY_RE = /^[A-Z]{3}$/;
84
- var CONTROL_BYTE_RE = /[\x00-\x1f\x7f]/;
85
85
 
86
86
  var MAX_RETURN_POLICY_LEN = 4000;
87
87
  var MAX_VENDOR_REF_LEN = 256;
@@ -183,9 +183,7 @@ function _returnPolicy(s) {
183
183
  if (typeof s !== "string" || !s.length || s.length > MAX_RETURN_POLICY_LEN) {
184
184
  throw new TypeError("dropship-forwarding: return_policy must be a non-empty string <= " + MAX_RETURN_POLICY_LEN + " chars");
185
185
  }
186
- if (CONTROL_BYTE_RE.test(s)) {
187
- throw new TypeError("dropship-forwarding: return_policy must not contain control bytes");
188
- }
186
+ textGuard.freeText(s, "dropship-forwarding: return_policy", { singleLine: "reject" });
189
187
  }
190
188
  function _positiveInt(n, label) {
191
189
  if (!Number.isInteger(n) || n <= 0) {
@@ -214,7 +212,7 @@ function _shortText(s, label, max) {
214
212
  if (typeof s !== "string" || !s.length || s.length > max) {
215
213
  throw new TypeError("dropship-forwarding: " + label + " must be a non-empty string <= " + max + " chars");
216
214
  }
217
- if (CONTROL_BYTE_RE.test(s)) {
215
+ if (textGuard.hasCodepointThreat(s, { singleLine: "reject" })) {
218
216
  throw new TypeError("dropship-forwarding: " + label + " must not contain control bytes");
219
217
  }
220
218
  }
package/lib/dunning.js CHANGED
@@ -105,6 +105,7 @@
105
105
  */
106
106
 
107
107
  var b = require("./vendor/blamejs");
108
+ var textGuard = require("./text-guard");
108
109
  // Framework constants (C.TIME duration helpers). Safe at module-eval —
109
110
  // the index entry point exposes `framework` before the require cascade.
110
111
  var C = b.constants;
@@ -130,10 +131,6 @@ var DEFAULT_LIST_LIMIT = 100;
130
131
  var MS_PER_HOUR = C.TIME.hours(1);
131
132
 
132
133
  var SLUG_RE = /^[a-z](?:[a-z0-9-]*[a-z0-9])?$/;
133
- var CONTROL_BYTE_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
134
- var ZERO_WIDTH_RE = new RegExp(
135
- "[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
136
- );
137
134
 
138
135
  // ---- validators ---------------------------------------------------------
139
136
 
@@ -205,9 +202,7 @@ function _reason(s) {
205
202
  if (s.length > MAX_REASON_LEN) {
206
203
  throw new TypeError("dunning: reason must be <= " + MAX_REASON_LEN + " characters");
207
204
  }
208
- if (CONTROL_BYTE_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
209
- throw new TypeError("dunning: reason contains control / zero-width bytes");
210
- }
205
+ textGuard.freeText(s, "dunning: reason", { zeroWidth: "reject" });
211
206
  return s;
212
207
  }
213
208
 
@@ -105,6 +105,7 @@
105
105
  */
106
106
 
107
107
  var b = require("./vendor/blamejs");
108
+ var textGuard = require("./text-guard");
108
109
 
109
110
  // ---- constants ----------------------------------------------------------
110
111
 
@@ -133,7 +134,6 @@ var DEFAULT_LIST_LIMIT = 50;
133
134
  // but 1.96 is the de-facto convention for shipped dashboards.
134
135
  var WILSON_Z = 1.96;
135
136
 
136
- var CONTROL_BYTE_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
137
137
 
138
138
  // ---- monotonic clock ----------------------------------------------------
139
139
  //
@@ -165,9 +165,7 @@ function _title(s) {
165
165
  if (typeof s !== "string" || !s.length || s.length > MAX_TITLE_LEN) {
166
166
  throw new TypeError("emailABTests: title must be a non-empty string <= " + MAX_TITLE_LEN + " chars");
167
167
  }
168
- if (CONTROL_BYTE_RE.test(s)) {
169
- throw new TypeError("emailABTests: title must not contain control bytes");
170
- }
168
+ textGuard.freeText(s, "emailABTests: title");
171
169
  return s;
172
170
  }
173
171
 
@@ -184,7 +182,7 @@ function _label(s, idx) {
184
182
  throw new TypeError("emailABTests: variants[" + idx + "].label must be a non-empty string <= " +
185
183
  MAX_LABEL_LEN + " chars");
186
184
  }
187
- if (CONTROL_BYTE_RE.test(s)) {
185
+ if (textGuard.hasCodepointThreat(s)) {
188
186
  throw new TypeError("emailABTests: variants[" + idx + "].label must not contain control bytes");
189
187
  }
190
188
  return s;
@@ -223,9 +221,7 @@ function _recipientId(s) {
223
221
  throw new TypeError("emailABTests: recipient_id must be a non-empty string <= " +
224
222
  MAX_RECIPIENT_ID_LEN + " chars");
225
223
  }
226
- if (CONTROL_BYTE_RE.test(s)) {
227
- throw new TypeError("emailABTests: recipient_id must not contain control bytes");
228
- }
224
+ textGuard.freeText(s, "emailABTests: recipient_id");
229
225
  return s;
230
226
  }
231
227
 
@@ -146,6 +146,7 @@ var DEFAULT_BATCH_SIZE = 100;
146
146
  var RESOLVE_PAGE_LIMIT = 500; // matches mailingAudiences MAX_LIST_LIMIT
147
147
 
148
148
  var b = require("./vendor/blamejs");
149
+ var textGuard = require("./text-guard");
149
150
  // Framework duration constants (C.TIME.minutes(n) etc.). The index entry
150
151
  // point exposes `framework` before the require cascade, so resolving this
151
152
  // at module-eval is safe — same pattern lib/admin.js / lib/cart.js use.
@@ -164,8 +165,16 @@ var SEND_RATE_WINDOW_MS = C.TIME.minutes(1);
164
165
  // Per-recipient broadcast outcomes recorded in email_campaign_sends.
165
166
  var SEND_OUTCOMES = ["sent", "failed", "skipped_unsubscribed", "skipped_suppressed"];
166
167
 
168
+ // A link URL is rendered on one line and, in the plain-text part, is shown
169
+ // to the recipient verbatim — so it is screened against the shared codepoint
170
+ // policy rather than a local class. The class this replaced covered the
171
+ // zero-width family but not the bidi overrides, which let a campaign body
172
+ // carry a URL that displays as one host and resolves to another.
173
+ var LINK_URL_POLICY = Object.freeze({ singleLine: "reject", zeroWidth: "reject" });
174
+
175
+ // Free-form text folded into one ledger line. Stripped rather than refused —
176
+ // this runs on an error path that must not itself fail.
167
177
  var CONTROL_BYTE_LINE_RE = /[\u0000-\u001f\u007f]/;
168
- var ZERO_WIDTH_RE = /[\u200b-\u200f\u2028\u2029\ufeff]/;
169
178
 
170
179
  var STATUSES = ["draft", "scheduled", "sending", "sent", "paused", "cancelled"];
171
180
  var EVENT_TYPES = ["delivered", "opened", "clicked", "bounced", "unsubscribed"];
@@ -190,7 +199,7 @@ function _esc(s) {
190
199
 
191
200
  function _mdSafeLinkUrl(url) {
192
201
  if (typeof url !== "string" || !url.length || url.length > 2048) return null;
193
- if (CONTROL_BYTE_LINE_RE.test(url) || ZERO_WIDTH_RE.test(url)) return null;
202
+ if (textGuard.hasCodepointThreat(url, LINK_URL_POLICY)) return null;
194
203
  if (url.charCodeAt(0) === 47 /* "/" */) {
195
204
  if (url.length > 1 && url.charCodeAt(1) === 47) return null;
196
205
  if (url.indexOf("..") !== -1) return null;
@@ -389,10 +398,13 @@ function _validateSubject(s) {
389
398
  if (s.length > MAX_SUBJECT_LEN) {
390
399
  throw new TypeError("emailCampaigns: subject must be <= " + MAX_SUBJECT_LEN + " characters");
391
400
  }
392
- if (/[\r\n\0]/.test(s)) {
393
- throw new TypeError("emailCampaigns: subject must not contain CR / LF / NUL");
394
- }
395
- return s;
401
+ // A subject becomes a `Subject:` header, so a CR or LF in it is a header
402
+ // injection the bytes after the break are read as another header. NUL goes
403
+ // with them: it truncates the field in some agents, which hides whatever
404
+ // follows. b.safeBuffer.assertHeaderSafe is the framework's check for a
405
+ // value destined for a `Name: value` field, and it refuses exactly that set.
406
+ return b.safeBuffer.assertHeaderSafe(
407
+ s, "subject", TypeError, "emailCampaigns: subject must not contain CR / LF / NUL");
396
408
  }
397
409
 
398
410
  function _validateBody(s, label) {
@@ -433,10 +445,9 @@ function _validateFromName(s) {
433
445
  if (s.length > MAX_FROM_NAME_LEN) {
434
446
  throw new TypeError("emailCampaigns: from_name must be <= " + MAX_FROM_NAME_LEN + " characters");
435
447
  }
436
- if (/[\r\n\0]/.test(s)) {
437
- throw new TypeError("emailCampaigns: from_name must not contain CR / LF / NUL");
438
- }
439
- return s;
448
+ // Lands in the `From:` header — same header-injection surface as the subject.
449
+ return b.safeBuffer.assertHeaderSafe(
450
+ s, "from_name", TypeError, "emailCampaigns: from_name must not contain CR / LF / NUL");
440
451
  }
441
452
 
442
453
  function _validateTs(ts, label) {
@@ -481,10 +492,10 @@ function _validateReason(s) {
481
492
  if (s.length > MAX_REASON_LEN) {
482
493
  throw new TypeError("emailCampaigns: reason must be <= " + MAX_REASON_LEN + " characters");
483
494
  }
484
- if (/[\r\n\0]/.test(s)) {
485
- throw new TypeError("emailCampaigns: reason must not contain CR / LF / NUL");
486
- }
487
- return s;
495
+ // Operator-authored and surfaced in logs and the console rather than a mail
496
+ // header, but held to the same rule: a newline here forges a log line.
497
+ return b.safeBuffer.assertHeaderSafe(
498
+ s, "reason", TypeError, "emailCampaigns: reason must not contain CR / LF / NUL");
488
499
  }
489
500
 
490
501
  function _validateStatus(s, label) {
@@ -93,6 +93,7 @@
93
93
  // ---- constants ----------------------------------------------------------
94
94
 
95
95
  var b = require("./vendor/blamejs");
96
+ var textGuard = require("./text-guard");
96
97
 
97
98
  var C = b.constants;
98
99
 
@@ -118,10 +119,6 @@ var IPV4_RE = /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\.(25[0-5]|2[0-4
118
119
  var IPV6_RE = /^[0-9a-f:]+$/;
119
120
  var ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
120
121
 
121
- var CONTROL_BYTE_RE = /[\x00-\x1f\x7f]/;
122
- var ZERO_WIDTH_RE = new RegExp(
123
- "[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
124
- );
125
122
 
126
123
  var ALLOWED_STATUSES = Object.freeze(["active", "paused", "archived"]);
127
124
 
@@ -234,9 +231,7 @@ function _reason(s) {
234
231
  if (s.length > MAX_REASON_LEN) {
235
232
  throw new TypeError("emailWarmup: reason must be <= " + MAX_REASON_LEN + " characters");
236
233
  }
237
- if (CONTROL_BYTE_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
238
- throw new TypeError("emailWarmup: reason contains control / zero-width bytes");
239
- }
234
+ textGuard.freeText(s, "emailWarmup: reason", { singleLine: "reject", zeroWidth: "reject" });
240
235
  return s;
241
236
  }
242
237
 
@@ -116,15 +116,10 @@ var SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
116
116
  // title + hypothesis fields land into the operator dashboard, not the
117
117
  // storefront, but the discipline is the same — strings reach the
118
118
  // operator UI as inert text, never as live markup.
119
- var CONTROL_BYTE_LINE_RE = /[\x00-\x1f\x7f]/;
120
- var CONTROL_BYTE_BLOCK_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
121
119
 
122
120
  // Zero-width / direction-override family — mirrors the promo-banners
123
121
  // + gift-options catalogues. Spelled with \u-escapes so ESLint's
124
122
  // no-irregular-whitespace stays happy.
125
- var ZERO_WIDTH_RE = new RegExp(
126
- "[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
127
- );
128
123
 
129
124
  var ALLOWED_PATCH_COLUMNS = Object.freeze([
130
125
  "title",
@@ -134,6 +129,7 @@ var ALLOWED_PATCH_COLUMNS = Object.freeze([
134
129
  ]);
135
130
 
136
131
  var b = require("./vendor/blamejs");
132
+ var textGuard = require("./text-guard");
137
133
 
138
134
  // ---- validators ---------------------------------------------------------
139
135
 
@@ -149,10 +145,10 @@ function _line(s, label, maxLen) {
149
145
  if (typeof s !== "string" || !s.length || s.length > maxLen) {
150
146
  throw new TypeError("experiments: " + label + " must be a non-empty string ≤ " + maxLen + " chars");
151
147
  }
152
- if (CONTROL_BYTE_LINE_RE.test(s)) {
148
+ if (textGuard.hasCodepointThreat(s, { singleLine: "reject" })) {
153
149
  throw new TypeError("experiments: " + label + " contains control bytes (incl. CR/LF)");
154
150
  }
155
- if (ZERO_WIDTH_RE.test(s)) {
151
+ if (textGuard.hasCodepointThreat(s, { zeroWidth: "reject" })) {
156
152
  throw new TypeError("experiments: " + label + " contains zero-width / direction-override characters");
157
153
  }
158
154
  return s;
@@ -162,10 +158,10 @@ function _block(s, label, maxLen) {
162
158
  if (typeof s !== "string" || !s.length || s.length > maxLen) {
163
159
  throw new TypeError("experiments: " + label + " must be a non-empty string ≤ " + maxLen + " chars");
164
160
  }
165
- if (CONTROL_BYTE_BLOCK_RE.test(s)) {
161
+ if (textGuard.hasCodepointThreat(s)) {
166
162
  throw new TypeError("experiments: " + label + " contains control bytes");
167
163
  }
168
- if (ZERO_WIDTH_RE.test(s)) {
164
+ if (textGuard.hasCodepointThreat(s, { zeroWidth: "reject" })) {
169
165
  throw new TypeError("experiments: " + label + " contains zero-width / direction-override characters");
170
166
  }
171
167
  return s;
@@ -437,7 +433,7 @@ function create(opts) {
437
433
  if (typeof input.metric !== "string" || !input.metric.length || input.metric.length > MAX_METRIC_LEN) {
438
434
  return { recorded: false };
439
435
  }
440
- if (CONTROL_BYTE_LINE_RE.test(input.metric) || ZERO_WIDTH_RE.test(input.metric)) {
436
+ if (textGuard.hasCodepointThreat(input.metric, { singleLine: "reject", zeroWidth: "reject" })) {
441
437
  return { recorded: false };
442
438
  }
443
439
  var value = 1;
@@ -125,34 +125,58 @@ function _timeoutErr(ms, label) {
125
125
  return e;
126
126
  }
127
127
 
128
+ // The attempt loop is b.retry.withRetry — exponential backoff, jitter, and
129
+ // the final rethrow — with two things this adapter has to supply.
130
+ //
131
+ // The classifier is ours. The framework's default reads `err.code` and
132
+ // `err.statusCode` off the error itself, and undici does not put them there:
133
+ // a transport failure arrives as TypeError("fetch failed") carrying the real
134
+ // code on `.cause`. Its retryable set also omits EAI_AGAIN and UND_ERR_SOCKET,
135
+ // which are precisely the DNS and socket failures this adapter exists to ride
136
+ // out. Handing it the default classifier would leave the loop in place and
137
+ // silently stop retrying anything that matters.
138
+ //
139
+ // The HTTP arm has to throw to be seen at all. b.httpClient and fetch both
140
+ // resolve a 5xx as an ordinary response, so a retryable status is raised
141
+ // inside the attempt and the response handed back at the boundary once the
142
+ // retries are spent — the caller still inspects `res.status` itself.
128
143
  async function _retryingFetch(fetchImpl, url, init, attempts, label) {
129
- var lastErr;
130
- for (var i = 0; i <= attempts; i += 1) {
131
- try {
132
- var res = await fetchImpl(url, init);
133
- // Retry only on 5xx / 429; 4xx is a permanent client error.
134
- if (res.status >= 500 || res.status === 429) {
135
- lastErr = _httpErr(res.status, label);
136
- if (i < attempts) {
137
- await _backoff(i);
138
- continue;
139
- }
140
- }
141
- return res;
142
- } catch (e) {
143
- lastErr = e;
144
- if (i < attempts && _isTransient(e)) {
145
- await _backoff(i);
146
- continue;
147
- }
148
- throw e;
149
- }
150
- }
151
- throw lastErr;
144
+ var received = null;
145
+ return b.retry.withRetry(async function () {
146
+ var res = await fetchImpl(url, init);
147
+ received = res;
148
+ // Retry only on 5xx / 429; a 4xx is the request's own fault.
149
+ if (res.status >= 500 || res.status === 429) throw _httpErr(res.status, label);
150
+ return res;
151
+ }, {
152
+ maxAttempts: attempts + 1, // `attempts` counts RETRIES, not tries
153
+ baseDelayMs: RETRY_BACKOFF_MS,
154
+ isRetryable: _isTransient,
155
+ }).catch(function (e) {
156
+ // Retries exhausted on a status we did receive: give the caller the
157
+ // response so it reports the upstream's own error, exactly as the previous
158
+ // loop did. A transport failure has no response and propagates.
159
+ if (received && e && e.status === received.status) return received;
160
+ throw e;
161
+ });
152
162
  }
153
163
 
164
+ // Which failures are worth another attempt. Both arms matter and neither is
165
+ // what the framework's default classifier looks at.
166
+ //
167
+ // Transport: undici reports a network failure as TypeError("fetch failed")
168
+ // with the real code nested on `.cause`, so the code is read from either
169
+ // place. EAI_AGAIN (transient DNS) and UND_ERR_SOCKET (socket torn down
170
+ // mid-request) are the two this adapter most needs and the two the framework's
171
+ // set leaves out.
172
+ //
173
+ // HTTP: a 5xx or 429 is raised as an error inside the attempt so the loop can
174
+ // see it at all — fetch resolves those as ordinary responses. `_httpErr`
175
+ // records the status on `.status`, so that is what is read here. A 4xx is the
176
+ // request's own fault and is never retried.
154
177
  function _isTransient(err) {
155
178
  if (!err) return false;
179
+ if (typeof err.status === "number") return err.status >= 500 || err.status === 429;
156
180
  var c = err.code || (err.cause && err.cause.code);
157
181
  return c === "ECONNRESET" || c === "ETIMEDOUT" || c === "ENOTFOUND" || c === "EAI_AGAIN" || c === "UND_ERR_SOCKET";
158
182
  }
@@ -254,34 +254,24 @@ function _now() { return Date.now(); }
254
254
  // through to the country's default_locale.
255
255
  function _parseAcceptLanguage(raw) {
256
256
  if (typeof raw !== "string" || !raw.length) return [];
257
- var entries = raw.split(",");
258
- var parsed = [];
259
- for (var i = 0; i < entries.length; i += 1) {
260
- var part = entries[i].trim();
261
- if (!part) continue;
262
- var semi = part.indexOf(";");
263
- var tag = (semi === -1 ? part : part.slice(0, semi)).trim();
264
- var q = 1.0;
265
- if (semi !== -1) {
266
- var attrs = part.slice(semi + 1).split(";");
267
- for (var j = 0; j < attrs.length; j += 1) {
268
- var a = attrs[j].trim();
269
- if (a.slice(0, 2).toLowerCase() === "q=") {
270
- var n = parseFloat(a.slice(2));
271
- if (isFinite(n) && n >= 0 && n <= 1) q = n;
272
- }
273
- }
274
- }
275
- if (tag === "*") continue;
276
- if (!LOCALE_RE.test(tag)) continue;
277
- parsed.push({ tag: tag, q: q, order: i });
278
- }
279
- // Stable q-desc sort (preserve original order on ties).
280
- parsed.sort(function (a, b) {
281
- if (a.q !== b.q) return b.q - a.q;
282
- return a.order - b.order;
283
- });
284
- return parsed;
257
+ // b.requestHelpers.parseQualityList is the RFC 9110 §12.5 parser, and the
258
+ // edge bypass check reads the same header through it — which is what keeps
259
+ // the two agreeing on which tag wins. They had drifted: a header spelling
260
+ // the parameter `Q=0.9` was read correctly here and ignored at the edge, so
261
+ // a visitor sending it got one locale decided at the edge and another here.
262
+ //
263
+ // `caseSensitive` because these are BCP 47 tags and the chosen one is
264
+ // returned to the caller — a browser asking for `de-AT` should get `de-AT`
265
+ // back, not `de-at`. Matching lowercases separately in _primarySubtag.
266
+ //
267
+ // A tag carrying `q=0` is dropped rather than ranked last: q=0 means "not
268
+ // acceptable", and keeping it meant a locale the visitor had explicitly
269
+ // ruled out could still be selected when nothing else matched.
270
+ return b.requestHelpers.parseQualityList(raw, { caseSensitive: true })
271
+ .filter(function (e) {
272
+ return e.value !== "*" && e.q > 0 && LOCALE_RE.test(e.value);
273
+ })
274
+ .map(function (e) { return { tag: e.value, q: e.q }; });
285
275
  }
286
276
 
287
277
  function _primarySubtag(tag) {
@@ -69,8 +69,6 @@ var SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
69
69
  // in adjacent ecosystems. Newlines are allowed in gift_message
70
70
  // (people write multi-line messages); the recipient name is a
71
71
  // single line and refuses LF / CR too.
72
- var CONTROL_BYTE_MSG_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
73
- var CONTROL_BYTE_NAME_RE = /[\x00-\x1f\x7f]/;
74
72
 
75
73
  // Zero-width / direction-override family — mirrors the order-notes
76
74
  // primitive's catalogue: ZWSP/ZWNJ/ZWJ (U+200B-200D), LRM/RLM
@@ -79,15 +77,13 @@ var CONTROL_BYTE_NAME_RE = /[\x00-\x1f\x7f]/;
79
77
  // (U+2066-U+2069), the BOM (U+FEFF), and the Arabic letter mark
80
78
  // (U+061C). Spelled with \u-escapes so ESLint's
81
79
  // no-irregular-whitespace stays happy.
82
- var ZERO_WIDTH_RE = new RegExp(
83
- "[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
84
- );
85
80
 
86
81
  var ALLOWED_WRAP_COLUMNS = Object.freeze([
87
82
  "title", "fee_minor", "image_url", "max_per_order", "active",
88
83
  ]);
89
84
 
90
85
  var b = require("./vendor/blamejs");
86
+ var textGuard = require("./text-guard");
91
87
 
92
88
  // ---- validators ---------------------------------------------------------
93
89
 
@@ -107,9 +103,7 @@ function _title(s) {
107
103
  if (typeof s !== "string" || !s.length || s.length > MAX_TITLE_LEN) {
108
104
  throw new TypeError("giftOptions: title must be a non-empty string ≤ " + MAX_TITLE_LEN + " chars");
109
105
  }
110
- if (CONTROL_BYTE_NAME_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
111
- throw new TypeError("giftOptions: title contains control / zero-width bytes");
112
- }
106
+ textGuard.freeText(s, "giftOptions: title", { singleLine: "reject", zeroWidth: "reject" });
113
107
  return s;
114
108
  }
115
109
 
@@ -133,9 +127,7 @@ function _imageUrl(s) {
133
127
  if (!(/^https:\/\//.test(s) || /^\/\//.test(s) || /^\//.test(s))) {
134
128
  throw new TypeError("giftOptions: image_url must be https://, // (protocol-relative), or / (absolute path)");
135
129
  }
136
- if (CONTROL_BYTE_NAME_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
137
- throw new TypeError("giftOptions: image_url contains control / zero-width bytes");
138
- }
130
+ textGuard.freeText(s, "giftOptions: image_url", { singleLine: "reject", zeroWidth: "reject" });
139
131
  return s;
140
132
  }
141
133
 
@@ -162,12 +154,7 @@ function _giftMessage(s) {
162
154
  if (s.length > MAX_MESSAGE_LEN) {
163
155
  throw new TypeError("giftOptions: gift_message must be ≤ " + MAX_MESSAGE_LEN + " chars");
164
156
  }
165
- if (CONTROL_BYTE_MSG_RE.test(s)) {
166
- throw new TypeError("giftOptions: gift_message contains control bytes");
167
- }
168
- if (ZERO_WIDTH_RE.test(s)) {
169
- throw new TypeError("giftOptions: gift_message contains zero-width / direction-override characters");
170
- }
157
+ textGuard.freeText(s, "giftOptions: gift_message", { zeroWidth: "reject" });
171
158
  return s;
172
159
  }
173
160
 
@@ -179,12 +166,7 @@ function _recipientName(s) {
179
166
  if (s.length > MAX_RECIPIENT_LEN) {
180
167
  throw new TypeError("giftOptions: recipient_name must be ≤ " + MAX_RECIPIENT_LEN + " chars");
181
168
  }
182
- if (CONTROL_BYTE_NAME_RE.test(s)) {
183
- throw new TypeError("giftOptions: recipient_name contains control bytes (incl. CR/LF)");
184
- }
185
- if (ZERO_WIDTH_RE.test(s)) {
186
- throw new TypeError("giftOptions: recipient_name contains zero-width / direction-override characters");
187
- }
169
+ textGuard.freeText(s, "giftOptions: recipient_name", { singleLine: "reject", zeroWidth: "reject" });
188
170
  return s;
189
171
  }
190
172