@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.
@@ -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
- perDomain.push({
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
- * → 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
  },
@@ -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
- currentlyImpersonating: async function () {
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
- { status: "revoked", ended_at: now, end_reason: reason },
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 };
@@ -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
- for (var i = 0; i < s.length; i += 1) {
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 even on the PSP path's
180
- // caller-supplied TLS agent, so DNS rebinding can't flip the answer between
181
- // check and connect. The remaining gap is a HOST allowlist: nothing today
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
@@ -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
- for (var i = 0; i < s.length; i += 1) {
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) {
@@ -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
- for (var i = 0; i < s.length; i += 1) {
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) {