@blamejs/blamejs-shop 0.5.22 → 0.5.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/README.md +1 -0
- package/SECURITY.md +23 -0
- package/lib/admin.js +292 -20
- package/lib/asset-manifest.json +5 -5
- package/lib/business-hours.js +4 -5
- package/lib/carrier-rates.js +4 -5
- package/lib/compliance-export.js +32 -2
- package/lib/customer-impersonation.js +140 -12
- package/lib/delivery-estimate.js +4 -5
- package/lib/payment.js +5 -3
- package/lib/shipping-labels.js +4 -5
- package/lib/shipping-zones.js +4 -5
- package/lib/storefront.js +583 -1
- package/lib/webhooks.js +5 -3
- package/package.json +1 -1
package/lib/compliance-export.js
CHANGED
|
@@ -207,6 +207,10 @@ var SCOPE_SECTIONS = Object.freeze({
|
|
|
207
207
|
// both directions (as referrer and as referred friend).
|
|
208
208
|
"guestOrderReconciliations", "stockAlerts", "quotes", "orderRatings",
|
|
209
209
|
"productQa", "customerNotes", "giftcards", "referrals",
|
|
210
|
+
// Support sessions opened on the account, and what was done under each.
|
|
211
|
+
// When someone looked at your account, who, why, and which pages is
|
|
212
|
+
// personal data about you, so a subject-access request returns it.
|
|
213
|
+
"customerImpersonation",
|
|
210
214
|
]),
|
|
211
215
|
orders_only: Object.freeze(["order", "orderNotes"]),
|
|
212
216
|
identity_only: Object.freeze(["customers", "addresses"]),
|
|
@@ -392,6 +396,7 @@ function create(opts) {
|
|
|
392
396
|
orderRatings: opts.orderRatings || null,
|
|
393
397
|
productQa: opts.productQa || null,
|
|
394
398
|
customerNotes: opts.customerNotes || null,
|
|
399
|
+
customerImpersonation: opts.customerImpersonation || null,
|
|
395
400
|
giftcards: opts.giftcards || null,
|
|
396
401
|
referrals: opts.referrals || null,
|
|
397
402
|
};
|
|
@@ -655,6 +660,14 @@ function create(opts) {
|
|
|
655
660
|
"supportTickets", "orderNotes", "order", "guestOrderReconciliations",
|
|
656
661
|
"subscriptions", "paymentMethods", "loyalty", "storeCredit",
|
|
657
662
|
"giftcards", "referrals", "addresses",
|
|
663
|
+
// Retains rather than erases, and the report says so with a reason.
|
|
664
|
+
// Listed here precisely BECAUSE it retains: a domain absent from this
|
|
665
|
+
// walk is never asked, so the erasure report would neither erase it nor
|
|
666
|
+
// explain why it did not — the customer would be told their record was
|
|
667
|
+
// erased while an audit trail about them quietly remained, unmentioned.
|
|
668
|
+
// Named last so the accountability record is the final line of the
|
|
669
|
+
// report rather than buried among the erasures.
|
|
670
|
+
"customerImpersonation",
|
|
658
671
|
];
|
|
659
672
|
var perDomain = [];
|
|
660
673
|
var domainsAbsent = [];
|
|
@@ -680,11 +693,28 @@ function create(opts) {
|
|
|
680
693
|
throw new TypeError("reader " + JSON.stringify(name) +
|
|
681
694
|
".forCustomerDeletion returned non-object — must return { table, deleted }");
|
|
682
695
|
}
|
|
683
|
-
|
|
696
|
+
var entry = {
|
|
684
697
|
domain: name,
|
|
685
698
|
table: effect.table == null ? name : effect.table,
|
|
686
699
|
deleted: effect.deleted == null ? 0 : Number(effect.deleted),
|
|
687
|
-
}
|
|
700
|
+
};
|
|
701
|
+
// A domain that RETAINS its rows on purpose says so, and says why.
|
|
702
|
+
// Without this the report shows a plain `deleted: 0`, which reads as
|
|
703
|
+
// "there was nothing to erase" — indistinguishable from a domain the
|
|
704
|
+
// customer had no data in. An erasure report that cannot tell those
|
|
705
|
+
// apart is the one an auditor cannot rely on, and retention here is a
|
|
706
|
+
// deliberate legal position (accounting records, accountability logs)
|
|
707
|
+
// that has to be stated rather than inferred.
|
|
708
|
+
if (effect.retained === true) {
|
|
709
|
+
entry.retained = true;
|
|
710
|
+
if (typeof effect.reason === "string" && effect.reason.length) {
|
|
711
|
+
entry.reason = effect.reason;
|
|
712
|
+
}
|
|
713
|
+
} else if (typeof effect.note === "string" && effect.note.length) {
|
|
714
|
+
// Older readers carry the same meaning as a free-text `note`.
|
|
715
|
+
entry.note = effect.note;
|
|
716
|
+
}
|
|
717
|
+
perDomain.push(entry);
|
|
688
718
|
} catch (e) {
|
|
689
719
|
failures.push({ domain: name, error: (e && e.message) ? e.message : String(e) });
|
|
690
720
|
}
|
|
@@ -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
|
-
* →
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
},
|
|
@@ -583,11 +635,22 @@ function create(opts) {
|
|
|
583
635
|
// The operator dashboard's "who's currently impersonating whom"
|
|
584
636
|
// surface. Returns every `active` row across all operators,
|
|
585
637
|
// newest-first.
|
|
586
|
-
|
|
638
|
+
// Filters on the CLOCK as well as the status column.
|
|
639
|
+
//
|
|
640
|
+
// Authority expires by timestamp — `verifyImpersonationToken` and the
|
|
641
|
+
// per-request liveness read both refuse an elapsed row immediately — while
|
|
642
|
+
// the `status` column only catches up when `cleanupExpired` next runs. A
|
|
643
|
+
// query on status alone therefore reports sessions as live in the window
|
|
644
|
+
// between those two, and longer if the sweep is delayed or was never
|
|
645
|
+
// scheduled. On an oversight screen that is the wrong direction to be
|
|
646
|
+
// wrong in: it shows a supervisor people inside accounts who are not,
|
|
647
|
+
// which is the fastest way to teach them the screen is noise.
|
|
648
|
+
currentlyImpersonating: async function (listOpts) {
|
|
649
|
+
var now = (listOpts && listOpts.now != null) ? listOpts.now : _now();
|
|
587
650
|
var r = await query(
|
|
588
|
-
"SELECT * FROM impersonations WHERE status = 'active' " +
|
|
651
|
+
"SELECT * FROM impersonations WHERE status = 'active' AND expires_at > ?1 " +
|
|
589
652
|
"ORDER BY started_at DESC, id DESC",
|
|
590
|
-
[],
|
|
653
|
+
[now],
|
|
591
654
|
);
|
|
592
655
|
return r.rows.map(_projectRow);
|
|
593
656
|
},
|
|
@@ -703,12 +766,64 @@ function create(opts) {
|
|
|
703
766
|
// `endImpersonation` because the audit log distinguishes
|
|
704
767
|
// operator-driven completion from operator-side termination.
|
|
705
768
|
// Idempotent on already-terminal rows.
|
|
769
|
+
// ---- consumeToken -----------------------------------------------------
|
|
770
|
+
//
|
|
771
|
+
// Spend the handoff bearer WITHOUT ending the session.
|
|
772
|
+
//
|
|
773
|
+
// `startImpersonation` mints a single plaintext token and the operator
|
|
774
|
+
// follows it once, out of the console and into the storefront. Verifying
|
|
775
|
+
// it is a read, though, so until the token stops matching, that URL keeps
|
|
776
|
+
// working: it sits in browser history, in a chat message if it was pasted,
|
|
777
|
+
// in a proxy log — and anyone holding it can mint their own cookie for
|
|
778
|
+
// that customer for the rest of the hour. The handoff is meant to be
|
|
779
|
+
// one-time; this is what makes it so.
|
|
780
|
+
//
|
|
781
|
+
// Rotating the hash to a fresh random value is what spends it. The row's
|
|
782
|
+
// authority now lives entirely in the cookie the redemption sealed, which
|
|
783
|
+
// is bound to the operator's browser; the session stays `active` and
|
|
784
|
+
// `end` / `revoke` / the sweep are unaffected. Rotating (rather than
|
|
785
|
+
// nulling) also keeps the NOT NULL UNIQUE column honest.
|
|
786
|
+
//
|
|
787
|
+
// Takes the PLAINTEXT the caller was presented, and spends only that one.
|
|
788
|
+
//
|
|
789
|
+
// The match on the current hash is what makes this single-use under
|
|
790
|
+
// concurrency. Verification is a read, so two requests carrying the same
|
|
791
|
+
// link can both pass it before either writes; if the update only keyed on
|
|
792
|
+
// the row id they would both succeed and both mint a cookie, which is the
|
|
793
|
+
// guarantee this exists to provide, broken. Conditioning the write on the
|
|
794
|
+
// hash it verified means exactly one request can win — the second finds
|
|
795
|
+
// the hash already rotated and gets `consumed: false`.
|
|
796
|
+
//
|
|
797
|
+
// Deliberately NOT idempotent: a caller that cannot claim the token must
|
|
798
|
+
// not be handed a session.
|
|
799
|
+
consumeToken: async function (impersonationId, plaintext) {
|
|
800
|
+
var impId = _uuid(impersonationId, "impersonation_id");
|
|
801
|
+
if (typeof plaintext !== "string" || !plaintext.length) {
|
|
802
|
+
throw new TypeError("customer-impersonation.consumeToken: plaintext token required");
|
|
803
|
+
}
|
|
804
|
+
var presented = b.crypto.namespaceHash(TOKEN_NAMESPACE, plaintext);
|
|
805
|
+
var spent = b.crypto.namespaceHash(
|
|
806
|
+
TOKEN_NAMESPACE, "spent:" + impId + ":" + b.crypto.toBase64Url(b.crypto.generateBytes(32)));
|
|
807
|
+
var r = await query(
|
|
808
|
+
"UPDATE impersonations SET token_hash = ?1 " +
|
|
809
|
+
"WHERE id = ?2 AND status = 'active' AND token_hash = ?3",
|
|
810
|
+
[spent, impId, presented],
|
|
811
|
+
);
|
|
812
|
+
return { consumed: r.rowCount === 1 };
|
|
813
|
+
},
|
|
814
|
+
|
|
706
815
|
revoke: async function (input) {
|
|
707
816
|
if (!input || typeof input !== "object") {
|
|
708
817
|
throw new TypeError("customer-impersonation.revoke: input object required");
|
|
709
818
|
}
|
|
710
819
|
var id = _uuid(input.impersonation_id, "impersonation_id");
|
|
711
820
|
var reason = _requiredString(input.reason, "reason", MAX_END_REASON_LEN);
|
|
821
|
+
// Optional so an existing caller keeps working; when present it is the
|
|
822
|
+
// operator performing the revocation, and it is what the audit row
|
|
823
|
+
// records as the actor.
|
|
824
|
+
var revokedBy = input.revoked_by == null
|
|
825
|
+
? null
|
|
826
|
+
: _operatorId(input.revoked_by, "revoked_by");
|
|
712
827
|
var existing = await _getRow(id);
|
|
713
828
|
if (!existing) {
|
|
714
829
|
var miss = new Error("customer-impersonation.revoke: impersonation not found");
|
|
@@ -727,12 +842,25 @@ function create(opts) {
|
|
|
727
842
|
);
|
|
728
843
|
var revoked = Number(r.rowCount || 0) > 0;
|
|
729
844
|
if (revoked) {
|
|
845
|
+
// The actor is whoever DID the revoking, which is usually not the
|
|
846
|
+
// operator being removed. Attributing it to `existing.operator_id`
|
|
847
|
+
// wrote an audit row saying they ended their own session — on the one
|
|
848
|
+
// feature whose entire justification is an accurate record of who did
|
|
849
|
+
// what to whose account, that is a falsified line. `revoked_by` falls
|
|
850
|
+
// back to the session's own operator so a self-revoke still reads
|
|
851
|
+
// correctly and an older caller keeps working.
|
|
730
852
|
await _audit(
|
|
731
853
|
"revoke",
|
|
732
|
-
existing.operator_id,
|
|
854
|
+
revokedBy || existing.operator_id,
|
|
733
855
|
id,
|
|
734
856
|
{ status: "active" },
|
|
735
|
-
{
|
|
857
|
+
{
|
|
858
|
+
status: "revoked", ended_at: now, end_reason: reason,
|
|
859
|
+
revoked_by: revokedBy || existing.operator_id,
|
|
860
|
+
// Named explicitly so an auditor reading the row can tell a
|
|
861
|
+
// supervisor's intervention from an operator closing their own.
|
|
862
|
+
self_revoked: !revokedBy || revokedBy === existing.operator_id,
|
|
863
|
+
},
|
|
736
864
|
);
|
|
737
865
|
}
|
|
738
866
|
return { revoked: revoked };
|
package/lib/delivery-estimate.js
CHANGED
|
@@ -132,12 +132,11 @@ var DAY_MS = b.constants.TIME.days(1);
|
|
|
132
132
|
|
|
133
133
|
// ---- validators ---------------------------------------------------------
|
|
134
134
|
|
|
135
|
+
// C0 + DEL, tab included. `allowHt: false` is load-bearing: the predicate
|
|
136
|
+
// permits ASCII HT by default (it is folding whitespace in a header), and
|
|
137
|
+
// these are single-line operator fields where a tab has no business.
|
|
135
138
|
function _hasControlByte(s) {
|
|
136
|
-
|
|
137
|
-
var cc = s.charCodeAt(i);
|
|
138
|
-
if (cc <= 0x1f || cc === 0x7f) return true;
|
|
139
|
-
}
|
|
140
|
-
return false;
|
|
139
|
+
return b.structuredFields.containsControlBytes(s, { allowHt: false });
|
|
141
140
|
}
|
|
142
141
|
|
|
143
142
|
function _zone(s, label) {
|
package/lib/payment.js
CHANGED
|
@@ -176,9 +176,11 @@ function _formEncode(obj, prefix) {
|
|
|
176
176
|
//
|
|
177
177
|
// b.httpClient already routes every dial through b.ssrfGuard (private /
|
|
178
178
|
// loopback / link-local / reserved / cloud-metadata IP classes refused) and
|
|
179
|
-
// pins the TCP connect to the guard's resolved IP set
|
|
180
|
-
//
|
|
181
|
-
//
|
|
179
|
+
// pins the TCP connect to the guard's resolved IP set, so DNS rebinding can't
|
|
180
|
+
// flip the answer between check and connect. (These dials no longer supply a
|
|
181
|
+
// caller-built TLS agent at all — see the note at the head of this file — so
|
|
182
|
+
// the pinning applies to the framework's own client.) The remaining gap is a
|
|
183
|
+
// HOST allowlist: nothing today
|
|
182
184
|
// stops an `opts.apiBase` pointed at an unexpected public host (config
|
|
183
185
|
// injection, or a future code path that derives the base from request data)
|
|
184
186
|
// from reaching a non-PSP upstream. We pin `allowedHosts` to the configured
|
package/lib/shipping-labels.js
CHANGED
|
@@ -136,12 +136,11 @@ function _currency(c) {
|
|
|
136
136
|
|
|
137
137
|
// C0 + DEL refusal — same pattern orderTracking uses, kept as a
|
|
138
138
|
// charCodeAt walk so the `no-control-regex` ESLint rule stays clean.
|
|
139
|
+
// C0 + DEL, tab included. `allowHt: false` is load-bearing: the predicate
|
|
140
|
+
// permits ASCII HT by default (it is folding whitespace in a header), and
|
|
141
|
+
// these are single-line operator fields where a tab has no business.
|
|
139
142
|
function _hasControlByte(s) {
|
|
140
|
-
|
|
141
|
-
var cc = s.charCodeAt(i);
|
|
142
|
-
if (cc <= 0x1f || cc === 0x7f) return true;
|
|
143
|
-
}
|
|
144
|
-
return false;
|
|
143
|
+
return b.structuredFields.containsControlBytes(s, { allowHt: false });
|
|
145
144
|
}
|
|
146
145
|
|
|
147
146
|
function _shortText(s, label, max) {
|
package/lib/shipping-zones.js
CHANGED
|
@@ -93,12 +93,11 @@ var b = require("./vendor/blamejs");
|
|
|
93
93
|
|
|
94
94
|
// ---- validators ---------------------------------------------------------
|
|
95
95
|
|
|
96
|
+
// C0 + DEL, tab included. `allowHt: false` is load-bearing: the predicate
|
|
97
|
+
// permits ASCII HT by default (it is folding whitespace in a header), and
|
|
98
|
+
// these are single-line operator fields where a tab has no business.
|
|
96
99
|
function _hasControlByte(s) {
|
|
97
|
-
|
|
98
|
-
var cc = s.charCodeAt(i);
|
|
99
|
-
if (cc <= 0x1f || cc === 0x7f) return true;
|
|
100
|
-
}
|
|
101
|
-
return false;
|
|
100
|
+
return b.structuredFields.containsControlBytes(s, { allowHt: false });
|
|
102
101
|
}
|
|
103
102
|
|
|
104
103
|
function _slug(s) {
|