@blamejs/blamejs-shop 0.5.20 → 0.5.22

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 (102) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +1 -0
  3. package/SECURITY.md +23 -0
  4. package/lib/addresses.js +17 -3
  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 +7 -20
  16. package/lib/cms-blocks.js +4 -7
  17. package/lib/compliance-export.js +11 -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 +39 -15
  31. package/lib/email-warmup.js +2 -7
  32. package/lib/experiments.js +6 -10
  33. package/lib/externaldb-d1.js +47 -31
  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 +7 -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 +21 -28
  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 +179 -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/MANIFEST.json +12 -12
  89. package/lib/vendor/blamejs/CHANGELOG.md +32 -0
  90. package/lib/vendor/blamejs/NOTICE +1 -1
  91. package/lib/vendor/blamejs/README.md +1 -1
  92. package/lib/vendor/blamejs/lib/network-tls.js +98 -13
  93. package/lib/vendor/blamejs/lib/vendor/MANIFEST.json +12 -12
  94. package/lib/vendor/blamejs/lib/vendor/blamejs-pki.cjs +568 -304
  95. package/lib/vendor/blamejs/package.json +1 -1
  96. package/lib/vendor/blamejs/sbom.cdx.json +6 -6
  97. package/lib/vendor-invoices.js +3 -6
  98. package/lib/vendors.js +5 -19
  99. package/lib/webhook-receiver.js +3 -10
  100. package/lib/webhook-subscriptions.js +3 -13
  101. package/lib/wishlist-sharing.js +5 -10
  102. package/package.json +1 -1
@@ -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", { bidiMarks: "allow" });
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", { bidiMarks: "allow" });
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
  }
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", bidiMarks: "allow" });
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", { bidiMarks: "allow" });
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", { bidiMarks: "allow" });
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", { bidiMarks: "allow" });
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", bidiMarks: "allow" });
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", bidiMarks: "allow" });
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", { bidiMarks: "allow" });
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,29 @@ 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
+ // `bidiMarks` too: prose legitimately carries a direction mark (an Arabic
174
+ // street name, a Hebrew review) and the shared screen permits them by default,
175
+ // but a URL is machine-plain — a mark inside one is only ever there to make the
176
+ // host read as something it is not.
177
+ var LINK_URL_POLICY = Object.freeze({
178
+ singleLine: "reject",
179
+ zeroWidth: "reject",
180
+ bidiMarks: "reject",
181
+ });
182
+
183
+ // Free-form text folded into one ledger line. Stripped rather than refused —
184
+ // this runs on an error path that must not itself fail.
167
185
  var CONTROL_BYTE_LINE_RE = /[\u0000-\u001f\u007f]/;
168
- var ZERO_WIDTH_RE = /[\u200b-\u200f\u2028\u2029\ufeff]/;
186
+
187
+ // Global twin, derived so the class is written once. `.replace` with an
188
+ // unglobbed regex rewrites only the FIRST match, and a mailer's error message
189
+ // can carry several control bytes — the rest would reach the stored ledger row.
190
+ var CONTROL_BYTE_LINE_RE_G = new RegExp(CONTROL_BYTE_LINE_RE.source, "g");
169
191
 
170
192
  var STATUSES = ["draft", "scheduled", "sending", "sent", "paused", "cancelled"];
171
193
  var EVENT_TYPES = ["delivered", "opened", "clicked", "bounced", "unsubscribed"];
@@ -190,7 +212,7 @@ function _esc(s) {
190
212
 
191
213
  function _mdSafeLinkUrl(url) {
192
214
  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;
215
+ if (textGuard.hasCodepointThreat(url, LINK_URL_POLICY)) return null;
194
216
  if (url.charCodeAt(0) === 47 /* "/" */) {
195
217
  if (url.length > 1 && url.charCodeAt(1) === 47) return null;
196
218
  if (url.indexOf("..") !== -1) return null;
@@ -389,10 +411,13 @@ function _validateSubject(s) {
389
411
  if (s.length > MAX_SUBJECT_LEN) {
390
412
  throw new TypeError("emailCampaigns: subject must be <= " + MAX_SUBJECT_LEN + " characters");
391
413
  }
392
- if (/[\r\n\0]/.test(s)) {
393
- throw new TypeError("emailCampaigns: subject must not contain CR / LF / NUL");
394
- }
395
- return s;
414
+ // A subject becomes a `Subject:` header, so a CR or LF in it is a header
415
+ // injection — the bytes after the break are read as another header. NUL goes
416
+ // with them: it truncates the field in some agents, which hides whatever
417
+ // follows. b.safeBuffer.assertHeaderSafe is the framework's check for a
418
+ // value destined for a `Name: value` field, and it refuses exactly that set.
419
+ return b.safeBuffer.assertHeaderSafe(
420
+ s, "subject", TypeError, "emailCampaigns: subject must not contain CR / LF / NUL");
396
421
  }
397
422
 
398
423
  function _validateBody(s, label) {
@@ -433,10 +458,9 @@ function _validateFromName(s) {
433
458
  if (s.length > MAX_FROM_NAME_LEN) {
434
459
  throw new TypeError("emailCampaigns: from_name must be <= " + MAX_FROM_NAME_LEN + " characters");
435
460
  }
436
- if (/[\r\n\0]/.test(s)) {
437
- throw new TypeError("emailCampaigns: from_name must not contain CR / LF / NUL");
438
- }
439
- return s;
461
+ // Lands in the `From:` header — same header-injection surface as the subject.
462
+ return b.safeBuffer.assertHeaderSafe(
463
+ s, "from_name", TypeError, "emailCampaigns: from_name must not contain CR / LF / NUL");
440
464
  }
441
465
 
442
466
  function _validateTs(ts, label) {
@@ -481,10 +505,10 @@ function _validateReason(s) {
481
505
  if (s.length > MAX_REASON_LEN) {
482
506
  throw new TypeError("emailCampaigns: reason must be <= " + MAX_REASON_LEN + " characters");
483
507
  }
484
- if (/[\r\n\0]/.test(s)) {
485
- throw new TypeError("emailCampaigns: reason must not contain CR / LF / NUL");
486
- }
487
- return s;
508
+ // Operator-authored and surfaced in logs and the console rather than a mail
509
+ // header, but held to the same rule: a newline here forges a log line.
510
+ return b.safeBuffer.assertHeaderSafe(
511
+ s, "reason", TypeError, "emailCampaigns: reason must not contain CR / LF / NUL");
488
512
  }
489
513
 
490
514
  function _validateStatus(s, label) {
@@ -971,7 +995,7 @@ function create(opts) {
971
995
  // strip control bytes (no CRLF / NUL into the stored row) and
972
996
  // cap length. The recipient address is never echoed into the
973
997
  // reason — only the mailer's own message.
974
- var reason = String(sendErr && sendErr.message || sendErr).replace(CONTROL_BYTE_LINE_RE, " ").slice(0, 280);
998
+ var reason = String(sendErr && sendErr.message || sendErr).replace(CONTROL_BYTE_LINE_RE_G, " ").slice(0, 280);
975
999
  await _finalizeSend(row.slug, consentHash || emailHash, "failed", reason);
976
1000
  continue;
977
1001
  }
@@ -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", bidiMarks: "allow" });
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,46 +125,62 @@ 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
  }
159
183
 
160
- function _backoff(attempt) {
161
- // Jittered exponential — 200ms, 400ms, ... with ±25% jitter so
162
- // retries from many container replicas don't synchronize.
163
- var base = RETRY_BACKOFF_MS * Math.pow(2, attempt);
164
- var jitter = base * (0.75 + (b.crypto.randomInt(0, 50) / 100));
165
- return new Promise(function (r) { setTimeout(r, jitter); });
166
- }
167
-
168
184
  function _httpErr(status, label) {
169
185
  var e = new Error("externaldbD1: " + label + " failed with HTTP " + status);
170
186
  e.code = "D1_HTTP_" + status;
@@ -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) {