@blamejs/blamejs-shop 0.5.21 → 0.5.23

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 (73) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +2 -1
  3. package/SECURITY.md +25 -1
  4. package/lib/addresses.js +16 -2
  5. package/lib/admin.js +116 -1
  6. package/lib/affiliates.js +3 -3
  7. package/lib/announcement-bar.js +2 -2
  8. package/lib/api-keys.js +2 -2
  9. package/lib/asset-manifest.json +5 -5
  10. package/lib/blog-articles.js +2 -2
  11. package/lib/carrier-accounts.js +2 -2
  12. package/lib/catalog-drafts.js +1 -1
  13. package/lib/category-navigation.js +6 -3
  14. package/lib/compliance-export.js +40 -3
  15. package/lib/customer-impersonation.js +105 -7
  16. package/lib/customer-notes.js +1 -1
  17. package/lib/customer-roles.js +1 -1
  18. package/lib/customer-surveys.js +2 -2
  19. package/lib/customers.js +1 -1
  20. package/lib/dispute-resolution.js +3 -3
  21. package/lib/dropship-forwarding.js +1 -1
  22. package/lib/dunning.js +1 -1
  23. package/lib/email-ab-tests.js +1 -1
  24. package/lib/email-campaigns.js +15 -2
  25. package/lib/email-warmup.js +1 -1
  26. package/lib/externaldb-d1.js +0 -8
  27. package/lib/gift-options.js +3 -3
  28. package/lib/knowledge-base.js +3 -3
  29. package/lib/line-gift-wrap.js +6 -2
  30. package/lib/live-chat.js +1 -1
  31. package/lib/notifications.js +1 -1
  32. package/lib/operator-accounts.js +1 -1
  33. package/lib/operator-help-center.js +3 -3
  34. package/lib/operator-inbox.js +2 -2
  35. package/lib/operator-roles.js +1 -1
  36. package/lib/order-notes.js +2 -2
  37. package/lib/order-ratings.js +3 -3
  38. package/lib/payment-methods.js +1 -1
  39. package/lib/payment-retries.js +1 -1
  40. package/lib/pixel-events.js +14 -6
  41. package/lib/plan-changes.js +1 -1
  42. package/lib/product-qa.js +1 -1
  43. package/lib/push-notifications.js +2 -2
  44. package/lib/reviews.js +3 -3
  45. package/lib/seller-signup.js +4 -4
  46. package/lib/sidebar-widgets.js +1 -1
  47. package/lib/sms-dispatcher.js +3 -3
  48. package/lib/stock-receipts.js +1 -1
  49. package/lib/storefront-dashboards.js +1 -1
  50. package/lib/storefront-forms.js +1 -1
  51. package/lib/storefront-pages.js +2 -2
  52. package/lib/storefront.js +569 -1
  53. package/lib/subscription-billing.js +1 -1
  54. package/lib/subscription-controls.js +1 -1
  55. package/lib/suggestion-box.js +4 -4
  56. package/lib/support-tickets.js +3 -3
  57. package/lib/tenants.js +1 -1
  58. package/lib/text-guard.js +44 -2
  59. package/lib/theme-assets.js +1 -1
  60. package/lib/vendor/MANIFEST.json +12 -12
  61. package/lib/vendor/blamejs/CHANGELOG.md +32 -0
  62. package/lib/vendor/blamejs/NOTICE +1 -1
  63. package/lib/vendor/blamejs/README.md +1 -1
  64. package/lib/vendor/blamejs/lib/network-tls.js +98 -13
  65. package/lib/vendor/blamejs/lib/vendor/MANIFEST.json +12 -12
  66. package/lib/vendor/blamejs/lib/vendor/blamejs-pki.cjs +568 -304
  67. package/lib/vendor/blamejs/package.json +1 -1
  68. package/lib/vendor/blamejs/sbom.cdx.json +6 -6
  69. package/lib/vendors.js +3 -3
  70. package/lib/webhook-receiver.js +1 -1
  71. package/lib/webhook-subscriptions.js +1 -1
  72. package/lib/wishlist-sharing.js +1 -1
  73. package/package.json +1 -1
@@ -113,8 +113,14 @@
113
113
  * → `{ notified: boolean, customer_notified_at: <ms> | null }`
114
114
  * - `listForOperator(operator_id, { active_only? })`
115
115
  * → array of session rows, newest-first.
116
- * - `listForCustomer(customer_id)`
117
- * → array of session rows, newest-first.
116
+ * - `listForCustomer(customer_id, { limit?, offset? })`
117
+ * → the customer's most recent sessions, newest-first. BOUNDED:
118
+ * 20 by default, 200 at most. The storefront renders this on
119
+ * every account page load and the rows are never pruned, so an
120
+ * unbounded read would make a customer's own dashboard slower
121
+ * for the rest of the account's life. Nothing becomes unreadable:
122
+ * `offset` walks the older pages, so a customer or a regulator
123
+ * asking for the whole history can still get it.
118
124
  * - `currentlyImpersonating()`
119
125
  * → array of active session rows.
120
126
  * - `cleanupExpired({ now? })`
@@ -138,6 +144,13 @@
138
144
  var TOKEN_NAMESPACE = "customer-impersonation-token";
139
145
  var TOKEN_BYTES = 32;
140
146
  var DEFAULT_TTL_SECONDS = 60 * 60; // allow:raw-time-literal — seconds value; C.TIME returns ms (60-minute default)
147
+
148
+ // How much of a customer's access history their own account page shows by
149
+ // default, and the ceiling a caller may ask for. Bounded because the rows are
150
+ // an audit trail that is never pruned: an unbounded read would make a
151
+ // customer's dashboard slower every time support helped them.
152
+ var DEFAULT_CUSTOMER_HISTORY = 20;
153
+ var MAX_CUSTOMER_HISTORY = 200;
141
154
  var MIN_TTL_SECONDS = 60; // refuse sub-minute
142
155
  var MAX_TTL_SECONDS = 8 * 60 * 60; // allow:raw-time-literal — seconds value; C.TIME returns ms (hard ceiling — eight hours)
143
156
  var MAX_REASON_LEN = 280;
@@ -165,6 +178,24 @@ function _uuid(s, label) {
165
178
  }
166
179
  }
167
180
 
181
+ // The admin console has two kinds of operator identity: a staff account, whose
182
+ // id is a v7 UUID, and the bootstrap / break-glass credential, whose id is the
183
+ // reserved literal below. Both are real operators and both must be recordable
184
+ // here — a store running the single-credential console (the common deploy) has
185
+ // no UUID to offer, and refusing it would make the feature unreachable exactly
186
+ // where the audit trail matters most.
187
+ //
188
+ // Only this one literal is admitted; anything else still has to be a UUID, so
189
+ // the column cannot fill up with free-form operator labels. It matches the id
190
+ // the rest of the console records against the same action, which keeps an
191
+ // impersonation row joinable to its audit entries.
192
+ var BOOTSTRAP_OPERATOR_ID = "owner";
193
+
194
+ function _operatorId(s, label) {
195
+ if (s === BOOTSTRAP_OPERATOR_ID) return s;
196
+ return _uuid(s, label);
197
+ }
198
+
168
199
  function _requiredString(s, label, maxLen) {
169
200
  if (typeof s !== "string") {
170
201
  throw new TypeError("customer-impersonation: " + label + " must be a string");
@@ -308,7 +339,7 @@ function create(opts) {
308
339
  if (!input || typeof input !== "object") {
309
340
  throw new TypeError("customer-impersonation.startImpersonation: input object required");
310
341
  }
311
- var operatorId = _uuid(input.operator_id, "operator_id");
342
+ var operatorId = _operatorId(input.operator_id, "operator_id");
312
343
  var customerId = _uuid(input.customer_id, "customer_id");
313
344
  var reason = _requiredString(input.reason, "reason", MAX_REASON_LEN);
314
345
  var ttl = _ttlSeconds(input.ttl_seconds);
@@ -543,7 +574,7 @@ function create(opts) {
543
574
  // started, newest-first. `active_only: true` filters to live
544
575
  // sessions only.
545
576
  listForOperator: async function (operatorId, listOpts) {
546
- var oid = _uuid(operatorId, "operator_id");
577
+ var oid = _operatorId(operatorId, "operator_id");
547
578
  listOpts = listOpts || {};
548
579
  if (typeof listOpts !== "object") {
549
580
  throw new TypeError("customer-impersonation.listForOperator: opts must be an object");
@@ -568,12 +599,33 @@ function create(opts) {
568
599
  // Returns every impersonation session ever opened against the
569
600
  // customer, newest-first. The customer-side "show me who's looked
570
601
  // at my account" page reads this directly.
571
- listForCustomer: async function (customerId) {
602
+ // `limit` is bounded and defaulted because the storefront renders this on
603
+ // every /account load. These rows are an audit trail and are never
604
+ // deleted, so an unbounded read would grow the query cost and the page
605
+ // weight of a customer's own dashboard for the rest of the account's life
606
+ // — worst for exactly the customers support has helped most often.
607
+ listForCustomer: async function (customerId, listOpts) {
572
608
  var cid = _uuid(customerId, "customer_id");
609
+ var opts = listOpts || {};
610
+ var limit = opts.limit == null ? DEFAULT_CUSTOMER_HISTORY : opts.limit;
611
+ if (typeof limit !== "number" || !Number.isInteger(limit) || limit <= 0 ||
612
+ limit > MAX_CUSTOMER_HISTORY) {
613
+ throw new TypeError("customer-impersonation.listForCustomer: limit must be an integer in [1, " +
614
+ MAX_CUSTOMER_HISTORY + "]");
615
+ }
616
+ // Offset paging, so capping the page does not put older rows out of
617
+ // reach. These are audit records: a bounded default protects the
618
+ // account page from growing heavier with every support visit, but
619
+ // nothing may become unreadable — a customer or a regulator asking for
620
+ // the whole history has to be able to walk it.
621
+ var offset = opts.offset == null ? 0 : opts.offset;
622
+ if (typeof offset !== "number" || !Number.isInteger(offset) || offset < 0) {
623
+ throw new TypeError("customer-impersonation.listForCustomer: offset must be a non-negative integer");
624
+ }
573
625
  var r = await query(
574
626
  "SELECT * FROM impersonations WHERE customer_id = ?1 " +
575
- "ORDER BY started_at DESC, id DESC",
576
- [cid],
627
+ "ORDER BY started_at DESC, id DESC LIMIT ?2 OFFSET ?3",
628
+ [cid, limit, offset],
577
629
  );
578
630
  return r.rows.map(_projectRow);
579
631
  },
@@ -703,6 +755,52 @@ function create(opts) {
703
755
  // `endImpersonation` because the audit log distinguishes
704
756
  // operator-driven completion from operator-side termination.
705
757
  // Idempotent on already-terminal rows.
758
+ // ---- consumeToken -----------------------------------------------------
759
+ //
760
+ // Spend the handoff bearer WITHOUT ending the session.
761
+ //
762
+ // `startImpersonation` mints a single plaintext token and the operator
763
+ // follows it once, out of the console and into the storefront. Verifying
764
+ // it is a read, though, so until the token stops matching, that URL keeps
765
+ // working: it sits in browser history, in a chat message if it was pasted,
766
+ // in a proxy log — and anyone holding it can mint their own cookie for
767
+ // that customer for the rest of the hour. The handoff is meant to be
768
+ // one-time; this is what makes it so.
769
+ //
770
+ // Rotating the hash to a fresh random value is what spends it. The row's
771
+ // authority now lives entirely in the cookie the redemption sealed, which
772
+ // is bound to the operator's browser; the session stays `active` and
773
+ // `end` / `revoke` / the sweep are unaffected. Rotating (rather than
774
+ // nulling) also keeps the NOT NULL UNIQUE column honest.
775
+ //
776
+ // Takes the PLAINTEXT the caller was presented, and spends only that one.
777
+ //
778
+ // The match on the current hash is what makes this single-use under
779
+ // concurrency. Verification is a read, so two requests carrying the same
780
+ // link can both pass it before either writes; if the update only keyed on
781
+ // the row id they would both succeed and both mint a cookie, which is the
782
+ // guarantee this exists to provide, broken. Conditioning the write on the
783
+ // hash it verified means exactly one request can win — the second finds
784
+ // the hash already rotated and gets `consumed: false`.
785
+ //
786
+ // Deliberately NOT idempotent: a caller that cannot claim the token must
787
+ // not be handed a session.
788
+ consumeToken: async function (impersonationId, plaintext) {
789
+ var impId = _uuid(impersonationId, "impersonation_id");
790
+ if (typeof plaintext !== "string" || !plaintext.length) {
791
+ throw new TypeError("customer-impersonation.consumeToken: plaintext token required");
792
+ }
793
+ var presented = b.crypto.namespaceHash(TOKEN_NAMESPACE, plaintext);
794
+ var spent = b.crypto.namespaceHash(
795
+ TOKEN_NAMESPACE, "spent:" + impId + ":" + b.crypto.toBase64Url(b.crypto.generateBytes(32)));
796
+ var r = await query(
797
+ "UPDATE impersonations SET token_hash = ?1 " +
798
+ "WHERE id = ?2 AND status = 'active' AND token_hash = ?3",
799
+ [spent, impId, presented],
800
+ );
801
+ return { consumed: r.rowCount === 1 };
802
+ },
803
+
706
804
  revoke: async function (input) {
707
805
  if (!input || typeof input !== "object") {
708
806
  throw new TypeError("customer-impersonation.revoke: input object required");
@@ -142,7 +142,7 @@ function _body(s) {
142
142
  if (s.length > MAX_BODY_LEN) {
143
143
  throw new TypeError("customerNotes: body must be <= " + MAX_BODY_LEN + " characters");
144
144
  }
145
- textGuard.freeText(s, "customerNotes: body", { zeroWidth: "reject" });
145
+ textGuard.freeText(s, "customerNotes: body", { zeroWidth: "reject", bidiMarks: "allow" });
146
146
  return s;
147
147
  }
148
148
 
@@ -152,7 +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
- textGuard.freeText(s, "customerRoles: title", { singleLine: "reject" });
155
+ textGuard.freeText(s, "customerRoles: title", { singleLine: "reject", bidiMarks: "allow" });
156
156
  return s;
157
157
  }
158
158
 
@@ -142,7 +142,7 @@ function _title(s) {
142
142
  if (typeof s !== "string" || !s.length || s.length > MAX_TITLE_LEN) {
143
143
  throw new TypeError("customerSurveys: title must be a non-empty string <= " + MAX_TITLE_LEN + " chars");
144
144
  }
145
- textGuard.freeText(s, "customerSurveys: title");
145
+ textGuard.freeText(s, "customerSurveys: title", { bidiMarks: "allow" });
146
146
  return s;
147
147
  }
148
148
 
@@ -187,7 +187,7 @@ function _closeReason(s) {
187
187
  if (typeof s !== "string" || !s.length || s.length > MAX_CLOSE_REASON_LEN) {
188
188
  throw new TypeError("customerSurveys: reason must be a non-empty string <= " + MAX_CLOSE_REASON_LEN + " chars");
189
189
  }
190
- textGuard.freeText(s, "customerSurveys: reason");
190
+ textGuard.freeText(s, "customerSurveys: reason", { bidiMarks: "allow" });
191
191
  return s;
192
192
  }
193
193
 
package/lib/customers.js CHANGED
@@ -105,7 +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
- textGuard.freeText(s, "customers: display_name", { singleLine: "reject" });
108
+ textGuard.freeText(s, "customers: display_name", { singleLine: "reject", bidiMarks: "allow" });
109
109
  return s;
110
110
  }
111
111
 
@@ -249,7 +249,7 @@ function _narrative(s) {
249
249
  " exceeds cap " + MAX_NARRATIVE_LEN
250
250
  );
251
251
  }
252
- textGuard.freeText(s, "disputeResolution: narrative");
252
+ textGuard.freeText(s, "disputeResolution: narrative", { bidiMarks: "allow" });
253
253
  return s;
254
254
  }
255
255
 
@@ -277,7 +277,7 @@ function _notesOpt(s) {
277
277
  "disputeResolution: notes length " + s.length + " exceeds cap " + MAX_NOTES_LEN
278
278
  );
279
279
  }
280
- textGuard.freeText(s, "disputeResolution: notes");
280
+ textGuard.freeText(s, "disputeResolution: notes", { bidiMarks: "allow" });
281
281
  return s;
282
282
  }
283
283
 
@@ -301,7 +301,7 @@ function _writeoffReason(s) {
301
301
  " exceeds cap " + MAX_WRITEOFF_REASON_LEN
302
302
  );
303
303
  }
304
- textGuard.freeText(s, "disputeResolution: writeoff reason");
304
+ textGuard.freeText(s, "disputeResolution: writeoff reason", { bidiMarks: "allow" });
305
305
  return s;
306
306
  }
307
307
 
@@ -183,7 +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
- textGuard.freeText(s, "dropship-forwarding: return_policy", { singleLine: "reject" });
186
+ textGuard.freeText(s, "dropship-forwarding: return_policy", { singleLine: "reject", bidiMarks: "allow" });
187
187
  }
188
188
  function _positiveInt(n, label) {
189
189
  if (!Number.isInteger(n) || n <= 0) {
package/lib/dunning.js CHANGED
@@ -202,7 +202,7 @@ function _reason(s) {
202
202
  if (s.length > MAX_REASON_LEN) {
203
203
  throw new TypeError("dunning: reason must be <= " + MAX_REASON_LEN + " characters");
204
204
  }
205
- textGuard.freeText(s, "dunning: reason", { zeroWidth: "reject" });
205
+ textGuard.freeText(s, "dunning: reason", { zeroWidth: "reject", bidiMarks: "allow" });
206
206
  return s;
207
207
  }
208
208
 
@@ -165,7 +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
- textGuard.freeText(s, "emailABTests: title");
168
+ textGuard.freeText(s, "emailABTests: title", { bidiMarks: "allow" });
169
169
  return s;
170
170
  }
171
171
 
@@ -170,12 +170,25 @@ var SEND_OUTCOMES = ["sent", "failed", "skipped_unsubscribed", "skipped_suppress
170
170
  // policy rather than a local class. The class this replaced covered the
171
171
  // zero-width family but not the bidi overrides, which let a campaign body
172
172
  // carry a URL that displays as one host and resolves to another.
173
- var LINK_URL_POLICY = Object.freeze({ singleLine: "reject", zeroWidth: "reject" });
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
+ });
174
182
 
175
183
  // Free-form text folded into one ledger line. Stripped rather than refused —
176
184
  // this runs on an error path that must not itself fail.
177
185
  var CONTROL_BYTE_LINE_RE = /[\u0000-\u001f\u007f]/;
178
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");
191
+
179
192
  var STATUSES = ["draft", "scheduled", "sending", "sent", "paused", "cancelled"];
180
193
  var EVENT_TYPES = ["delivered", "opened", "clicked", "bounced", "unsubscribed"];
181
194
  var TERMINAL = ["sent", "cancelled"];
@@ -982,7 +995,7 @@ function create(opts) {
982
995
  // strip control bytes (no CRLF / NUL into the stored row) and
983
996
  // cap length. The recipient address is never echoed into the
984
997
  // reason — only the mailer's own message.
985
- 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);
986
999
  await _finalizeSend(row.slug, consentHash || emailHash, "failed", reason);
987
1000
  continue;
988
1001
  }
@@ -231,7 +231,7 @@ function _reason(s) {
231
231
  if (s.length > MAX_REASON_LEN) {
232
232
  throw new TypeError("emailWarmup: reason must be <= " + MAX_REASON_LEN + " characters");
233
233
  }
234
- textGuard.freeText(s, "emailWarmup: reason", { singleLine: "reject", zeroWidth: "reject" });
234
+ textGuard.freeText(s, "emailWarmup: reason", { singleLine: "reject", zeroWidth: "reject", bidiMarks: "allow" });
235
235
  return s;
236
236
  }
237
237
 
@@ -181,14 +181,6 @@ function _isTransient(err) {
181
181
  return c === "ECONNRESET" || c === "ETIMEDOUT" || c === "ENOTFOUND" || c === "EAI_AGAIN" || c === "UND_ERR_SOCKET";
182
182
  }
183
183
 
184
- function _backoff(attempt) {
185
- // Jittered exponential — 200ms, 400ms, ... with ±25% jitter so
186
- // retries from many container replicas don't synchronize.
187
- var base = RETRY_BACKOFF_MS * Math.pow(2, attempt);
188
- var jitter = base * (0.75 + (b.crypto.randomInt(0, 50) / 100));
189
- return new Promise(function (r) { setTimeout(r, jitter); });
190
- }
191
-
192
184
  function _httpErr(status, label) {
193
185
  var e = new Error("externaldbD1: " + label + " failed with HTTP " + status);
194
186
  e.code = "D1_HTTP_" + status;
@@ -103,7 +103,7 @@ function _title(s) {
103
103
  if (typeof s !== "string" || !s.length || s.length > MAX_TITLE_LEN) {
104
104
  throw new TypeError("giftOptions: title must be a non-empty string ≤ " + MAX_TITLE_LEN + " chars");
105
105
  }
106
- textGuard.freeText(s, "giftOptions: title", { singleLine: "reject", zeroWidth: "reject" });
106
+ textGuard.freeText(s, "giftOptions: title", { singleLine: "reject", zeroWidth: "reject", bidiMarks: "allow" });
107
107
  return s;
108
108
  }
109
109
 
@@ -154,7 +154,7 @@ function _giftMessage(s) {
154
154
  if (s.length > MAX_MESSAGE_LEN) {
155
155
  throw new TypeError("giftOptions: gift_message must be ≤ " + MAX_MESSAGE_LEN + " chars");
156
156
  }
157
- textGuard.freeText(s, "giftOptions: gift_message", { zeroWidth: "reject" });
157
+ textGuard.freeText(s, "giftOptions: gift_message", { zeroWidth: "reject", bidiMarks: "allow" });
158
158
  return s;
159
159
  }
160
160
 
@@ -166,7 +166,7 @@ function _recipientName(s) {
166
166
  if (s.length > MAX_RECIPIENT_LEN) {
167
167
  throw new TypeError("giftOptions: recipient_name must be ≤ " + MAX_RECIPIENT_LEN + " chars");
168
168
  }
169
- textGuard.freeText(s, "giftOptions: recipient_name", { singleLine: "reject", zeroWidth: "reject" });
169
+ textGuard.freeText(s, "giftOptions: recipient_name", { singleLine: "reject", zeroWidth: "reject", bidiMarks: "allow" });
170
170
  return s;
171
171
  }
172
172
 
@@ -159,7 +159,7 @@ function _title(s) {
159
159
  if (s.length > MAX_TITLE_LEN) {
160
160
  throw new TypeError("knowledgeBase: title must be <= " + MAX_TITLE_LEN + " characters");
161
161
  }
162
- textGuard.freeText(s, "knowledgeBase: title", { singleLine: "reject", zeroWidth: "reject" });
162
+ textGuard.freeText(s, "knowledgeBase: title", { singleLine: "reject", zeroWidth: "reject", bidiMarks: "allow" });
163
163
  return s;
164
164
  }
165
165
 
@@ -177,7 +177,7 @@ function _body(s) {
177
177
  if (textGuard.hasCodepointThreat(s)) {
178
178
  throw new TypeError("knowledgeBase: body contains control bytes");
179
179
  }
180
- textGuard.freeText(s, "knowledgeBase: body", { zeroWidth: "reject" });
180
+ textGuard.freeText(s, "knowledgeBase: body", { zeroWidth: "reject", bidiMarks: "allow" });
181
181
  return s;
182
182
  }
183
183
 
@@ -271,7 +271,7 @@ function _queryStr(s) {
271
271
  if (s.length > MAX_QUERY_LEN) {
272
272
  throw new TypeError("knowledgeBase: query must be <= " + MAX_QUERY_LEN + " characters");
273
273
  }
274
- textGuard.freeText(s, "knowledgeBase: query", { zeroWidth: "reject" });
274
+ textGuard.freeText(s, "knowledgeBase: query", { zeroWidth: "reject", bidiMarks: "allow" });
275
275
  return s;
276
276
  }
277
277
 
@@ -151,7 +151,10 @@ function _giftMessage(s) {
151
151
  if (s.length > MAX_MESSAGE_LEN) {
152
152
  throw new TypeError("lineGiftWrap: gift_message must be ≤ " + MAX_MESSAGE_LEN + " chars");
153
153
  }
154
- textGuard.freeText(s, "lineGiftWrap: gift_message", { zeroWidth: "reject" });
154
+ // Prose a customer writes for another person to read — a message in Arabic
155
+ // or Hebrew carries a direction mark wherever a Latin word or number sits
156
+ // inside the line. The overrides that reverse a run stay refused.
157
+ textGuard.freeText(s, "lineGiftWrap: gift_message", { zeroWidth: "reject", bidiMarks: "allow" });
155
158
  return s;
156
159
  }
157
160
 
@@ -163,7 +166,8 @@ function _recipientName(s) {
163
166
  if (s.length > MAX_RECIPIENT_LEN) {
164
167
  throw new TypeError("lineGiftWrap: recipient_name must be ≤ " + MAX_RECIPIENT_LEN + " chars");
165
168
  }
166
- textGuard.freeText(s, "lineGiftWrap: recipient_name", { singleLine: "reject", zeroWidth: "reject" });
169
+ // A person's name, in whatever script they write it.
170
+ textGuard.freeText(s, "lineGiftWrap: recipient_name", { singleLine: "reject", zeroWidth: "reject", bidiMarks: "allow" });
167
171
  return s;
168
172
  }
169
173
 
package/lib/live-chat.js CHANGED
@@ -130,7 +130,7 @@ function _body(s) {
130
130
  if (s.length > MAX_BODY_LEN) {
131
131
  throw new TypeError("live-chat: body must be <= " + MAX_BODY_LEN + " characters");
132
132
  }
133
- textGuard.freeText(s, "live-chat: body", { zeroWidth: "reject" });
133
+ textGuard.freeText(s, "live-chat: body", { zeroWidth: "reject", bidiMarks: "allow" });
134
134
  return s;
135
135
  }
136
136
 
@@ -99,7 +99,7 @@ function _title(s) {
99
99
  if (s.length > MAX_TITLE_LEN) {
100
100
  throw new TypeError("notifications: title must be <= " + MAX_TITLE_LEN + " characters");
101
101
  }
102
- textGuard.freeText(s, "notifications: title");
102
+ textGuard.freeText(s, "notifications: title", { bidiMarks: "allow" });
103
103
  return s;
104
104
  }
105
105
 
@@ -212,7 +212,7 @@ function _displayName(s) {
212
212
  if (typeof s !== "string" || !s.length || s.length > MAX_DISPLAY_NAME_LEN) {
213
213
  throw new TypeError("operatorAccounts: display_name must be a non-empty string (<= " + MAX_DISPLAY_NAME_LEN + " chars)");
214
214
  }
215
- textGuard.freeText(s, "operatorAccounts: display_name", { singleLine: "reject" });
215
+ textGuard.freeText(s, "operatorAccounts: display_name", { singleLine: "reject", bidiMarks: "allow" });
216
216
  return s;
217
217
  }
218
218
 
@@ -164,7 +164,7 @@ function _title(s) {
164
164
  if (s.length > MAX_TITLE_LEN) {
165
165
  throw new TypeError("operatorHelpCenter: title must be <= " + MAX_TITLE_LEN + " characters");
166
166
  }
167
- textGuard.freeText(s, "operatorHelpCenter: title", { singleLine: "reject", zeroWidth: "reject" });
167
+ textGuard.freeText(s, "operatorHelpCenter: title", { singleLine: "reject", zeroWidth: "reject", bidiMarks: "allow" });
168
168
  return s;
169
169
  }
170
170
 
@@ -179,7 +179,7 @@ function _body(s) {
179
179
  if (s.length > MAX_BODY_LEN) {
180
180
  throw new TypeError("operatorHelpCenter: body must be <= " + MAX_BODY_LEN + " characters");
181
181
  }
182
- textGuard.freeText(s, "operatorHelpCenter: body", { zeroWidth: "reject" });
182
+ textGuard.freeText(s, "operatorHelpCenter: body", { zeroWidth: "reject", bidiMarks: "allow" });
183
183
  return s;
184
184
  }
185
185
 
@@ -290,7 +290,7 @@ function _queryStr(s) {
290
290
  if (s.length > MAX_QUERY_LEN) {
291
291
  throw new TypeError("operatorHelpCenter: query must be <= " + MAX_QUERY_LEN + " characters");
292
292
  }
293
- textGuard.freeText(s, "operatorHelpCenter: query", { zeroWidth: "reject" });
293
+ textGuard.freeText(s, "operatorHelpCenter: query", { zeroWidth: "reject", bidiMarks: "allow" });
294
294
  return s;
295
295
  }
296
296
 
@@ -248,7 +248,7 @@ function _subject(s) {
248
248
  if (typeof s !== "string" || !s.length || s.length > MAX_SUBJECT_LEN) {
249
249
  throw new TypeError("operatorInbox: subject must be a non-empty string <= " + MAX_SUBJECT_LEN + " chars");
250
250
  }
251
- textGuard.freeText(s, "operatorInbox: subject");
251
+ textGuard.freeText(s, "operatorInbox: subject", { bidiMarks: "allow" });
252
252
  return s;
253
253
  }
254
254
 
@@ -256,7 +256,7 @@ function _body(s) {
256
256
  if (typeof s !== "string" || !s.length || s.length > MAX_BODY_LEN) {
257
257
  throw new TypeError("operatorInbox: body must be a non-empty string <= " + MAX_BODY_LEN + " chars");
258
258
  }
259
- textGuard.freeText(s, "operatorInbox: body");
259
+ textGuard.freeText(s, "operatorInbox: body", { bidiMarks: "allow" });
260
260
  return s;
261
261
  }
262
262
 
@@ -185,7 +185,7 @@ function _title(s) {
185
185
  if (typeof s !== "string" || !s.length || s.length > MAX_TITLE_LEN) {
186
186
  throw new TypeError("operatorRoles: title must be a non-empty string <= " + MAX_TITLE_LEN + " chars");
187
187
  }
188
- textGuard.freeText(s, "operatorRoles: title", { singleLine: "reject" });
188
+ textGuard.freeText(s, "operatorRoles: title", { singleLine: "reject", bidiMarks: "allow" });
189
189
  return s;
190
190
  }
191
191
 
@@ -95,7 +95,7 @@ function _body(s) {
95
95
  if (s.length > MAX_BODY_LEN) {
96
96
  throw new TypeError("orderNotes: body must be <= " + MAX_BODY_LEN + " characters");
97
97
  }
98
- textGuard.freeText(s, "orderNotes: body", { zeroWidth: "reject" });
98
+ textGuard.freeText(s, "orderNotes: body", { zeroWidth: "reject", bidiMarks: "allow" });
99
99
  return s;
100
100
  }
101
101
 
@@ -131,7 +131,7 @@ function _resolution(s) {
131
131
  if (s.length > MAX_RESOLUTION_LEN) {
132
132
  throw new TypeError("orderNotes: resolution must be <= " + MAX_RESOLUTION_LEN + " characters");
133
133
  }
134
- textGuard.freeText(s, "orderNotes: resolution", { zeroWidth: "reject" });
134
+ textGuard.freeText(s, "orderNotes: resolution", { zeroWidth: "reject", bidiMarks: "allow" });
135
135
  return s;
136
136
  }
137
137
 
@@ -130,7 +130,7 @@ function _comment(s) {
130
130
  if (s.length > MAX_COMMENT_LEN) {
131
131
  throw new TypeError("orderRatings: comment must be <= " + MAX_COMMENT_LEN + " chars");
132
132
  }
133
- textGuard.freeText(s, "orderRatings: comment");
133
+ textGuard.freeText(s, "orderRatings: comment", { bidiMarks: "allow" });
134
134
  return s;
135
135
  }
136
136
 
@@ -138,7 +138,7 @@ function _flagReason(s) {
138
138
  if (typeof s !== "string" || !s.length || s.length > MAX_FLAG_REASON_LEN) {
139
139
  throw new TypeError("orderRatings: reason must be a non-empty string <= " + MAX_FLAG_REASON_LEN + " chars");
140
140
  }
141
- textGuard.freeText(s, "orderRatings: reason");
141
+ textGuard.freeText(s, "orderRatings: reason", { bidiMarks: "allow" });
142
142
  return s;
143
143
  }
144
144
 
@@ -146,7 +146,7 @@ function _responseText(s) {
146
146
  if (typeof s !== "string" || !s.length || s.length > MAX_RESPONSE_LEN) {
147
147
  throw new TypeError("orderRatings: response must be a non-empty string <= " + MAX_RESPONSE_LEN + " chars");
148
148
  }
149
- textGuard.freeText(s, "orderRatings: response");
149
+ textGuard.freeText(s, "orderRatings: response", { bidiMarks: "allow" });
150
150
  return s;
151
151
  }
152
152
 
@@ -140,7 +140,7 @@ function _label(s) {
140
140
  if (s.length > MAX_LABEL_LEN) {
141
141
  throw new TypeError("paymentMethods: label must be <= " + MAX_LABEL_LEN + " characters");
142
142
  }
143
- textGuard.freeText(s, "paymentMethods: label", { singleLine: "reject" });
143
+ textGuard.freeText(s, "paymentMethods: label", { singleLine: "reject", bidiMarks: "allow" });
144
144
  return s;
145
145
  }
146
146
 
@@ -229,7 +229,7 @@ function _reason(s) {
229
229
  if (s.length > MAX_REASON_LEN) {
230
230
  throw new TypeError("paymentRetries: reason must be <= " + MAX_REASON_LEN + " characters");
231
231
  }
232
- textGuard.freeText(s, "paymentRetries: reason", { zeroWidth: "reject" });
232
+ textGuard.freeText(s, "paymentRetries: reason", { zeroWidth: "reject", bidiMarks: "allow" });
233
233
  return s;
234
234
  }
235
235
 
@@ -403,12 +403,20 @@ function _responseBody(s) {
403
403
  if (s.length > MAX_RESPONSE_BODY_LEN) {
404
404
  throw new TypeError("pixelEvents: response_body must be <= " + MAX_RESPONSE_BODY_LEN + " characters");
405
405
  }
406
- // Response bodies from upstream providers are operator-observed
407
- // already (the dispatch hook captured them); allow the full ASCII
408
- // range minus the zero-width / bidi block so a malicious provider
409
- // can't smuggle invisible bytes through the audit trail.
410
- textGuard.freeText(s, "pixelEvents: response_body", { zeroWidth: "reject" });
411
- return s;
406
+ // SCRUBBED, never refused. This string is whatever an upstream provider sent
407
+ // back, and `markDispatched` runs this validator BEFORE it transitions the
408
+ // event so any refusal here leaves the row in `queued` and the scheduler
409
+ // re-sends the identical pixel on every tick, forever, because we disliked a
410
+ // byte in the reply. A provider must never be able to hold an event open by
411
+ // choosing its response body.
412
+ //
413
+ // The interest in this field is only that an operator reading the audit
414
+ // trail is not shown invisible characters. Removing them satisfies that
415
+ // without ever failing: the bidi overrides, the direction marks, the
416
+ // zero-width family and the Unicode Tags block all come out, and everything
417
+ // legible — including the tabs and newlines a real response is full of —
418
+ // stays exactly as the provider sent it.
419
+ return textGuard.scrubInvisible(s);
412
420
  }
413
421
 
414
422
  function _responseStatus(n) {
@@ -115,7 +115,7 @@ function _optReason(s) {
115
115
  if (s.length > MAX_REASON_LEN) {
116
116
  throw new TypeError("planChanges: reason must be <= " + MAX_REASON_LEN + " characters");
117
117
  }
118
- textGuard.freeText(s, "planChanges: reason", { zeroWidth: "reject" });
118
+ textGuard.freeText(s, "planChanges: reason", { zeroWidth: "reject", bidiMarks: "allow" });
119
119
  return s;
120
120
  }
121
121
 
package/lib/product-qa.js CHANGED
@@ -131,7 +131,7 @@ function _reason(s) {
131
131
  if (s.length > MAX_REASON_LEN) {
132
132
  throw new TypeError("productQA: reason must be <= " + MAX_REASON_LEN + " characters");
133
133
  }
134
- textGuard.freeText(s, "productQA: reason");
134
+ textGuard.freeText(s, "productQA: reason", { bidiMarks: "allow" });
135
135
  return s;
136
136
  }
137
137
 
@@ -195,7 +195,7 @@ function _title(s) {
195
195
  if (s.length > MAX_TITLE_LEN) {
196
196
  throw new TypeError("pushNotifications: title must be <= " + MAX_TITLE_LEN + " characters");
197
197
  }
198
- textGuard.freeText(s, "pushNotifications: title", { zeroWidth: "reject" });
198
+ textGuard.freeText(s, "pushNotifications: title", { zeroWidth: "reject", bidiMarks: "allow" });
199
199
  return s;
200
200
  }
201
201
 
@@ -206,7 +206,7 @@ function _body(s) {
206
206
  if (s.length > MAX_BODY_LEN) {
207
207
  throw new TypeError("pushNotifications: body must be <= " + MAX_BODY_LEN + " characters");
208
208
  }
209
- textGuard.freeText(s, "pushNotifications: body", { zeroWidth: "reject" });
209
+ textGuard.freeText(s, "pushNotifications: body", { zeroWidth: "reject", bidiMarks: "allow" });
210
210
  return s;
211
211
  }
212
212