@blamejs/blamejs-shop 0.4.23 → 0.4.25

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/lib/payment.js CHANGED
@@ -177,6 +177,56 @@ function _formEncode(obj, prefix) {
177
177
  return parts.filter(Boolean).join("&");
178
178
  }
179
179
 
180
+ // ---- egress hardening + outbound idempotency-key validation ----------------
181
+ //
182
+ // b.httpClient already routes every dial through b.ssrfGuard (private /
183
+ // loopback / link-local / reserved / cloud-metadata IP classes refused) and
184
+ // pins the TCP connect to the guard's resolved IP set even on the PSP path's
185
+ // caller-supplied TLS agent, so DNS rebinding can't flip the answer between
186
+ // check and connect. The remaining gap is a HOST allowlist: nothing today
187
+ // stops an `opts.apiBase` pointed at an unexpected public host (config
188
+ // injection, or a future code path that derives the base from request data)
189
+ // from reaching a non-PSP upstream. We pin `allowedHosts` to the configured
190
+ // PSP host on every dial so a compromised process can only ever talk to the
191
+ // host the adapter was constructed against — defense-in-depth layered on top
192
+ // of the IP-class SSRF gate.
193
+
194
+ // Extract the lowercase hostname from a PSP base URL. Throws a TypeError on a
195
+ // non-string / non-https / hostless base — config-time validation (entry
196
+ // point), so an operator's typo'd apiBase surfaces at adapter construction
197
+ // rather than on the first charge.
198
+ function _pspHost(baseUrl, label) {
199
+ if (typeof baseUrl !== "string" || !baseUrl.length) {
200
+ throw new TypeError("payment: " + label + " must be a non-empty URL string");
201
+ }
202
+ var parsed;
203
+ try { parsed = new URL(baseUrl); } catch (_e) {
204
+ throw new TypeError("payment: " + label + " must be a valid absolute URL (got " + JSON.stringify(baseUrl) + ")");
205
+ }
206
+ if (parsed.protocol !== "https:") {
207
+ throw new TypeError("payment: " + label + " must be https (a payment processor is never dialed over plaintext)");
208
+ }
209
+ if (!parsed.hostname) {
210
+ throw new TypeError("payment: " + label + " has no hostname");
211
+ }
212
+ return parsed.hostname.toLowerCase();
213
+ }
214
+
215
+ // Validate + normalize an idempotency key that crosses the wire as an
216
+ // outbound header (Stripe `Idempotency-Key` / PayPal `PayPal-Request-Id`).
217
+ // Composes b.guardIdempotencyKey (strict profile): bounded length, control-
218
+ // char / path-traversal / slash refusal, ASCII-only — so a key carrying a
219
+ // log-injection or traversal shape never reaches the processor or the local
220
+ // replay cache. Returns the validated key; throws TypeError on refusal (the
221
+ // caller's bad-shape path is a clean 400, matching every other field guard).
222
+ function _assertOutboundKey(key, label) {
223
+ try {
224
+ return b.guardIdempotencyKey.validate(key, { profile: "strict" });
225
+ } catch (e) {
226
+ throw new TypeError("payment: " + (label || "idempotency key") + " — " + ((e && e.message) || "invalid idempotency key"));
227
+ }
228
+ }
229
+
180
230
  // ---- webhook verifier -----------------------------------------------------
181
231
  //
182
232
  // Composes b.webhook.verify (alg: "hmac-sha256-stripe") — the
@@ -239,7 +289,12 @@ async function _verifyWebhook(headers, rawBody, secret, opts) {
239
289
  // ---- Stripe API call ------------------------------------------------------
240
290
 
241
291
  async function _stripeCall(opts, method, path, params, idempotencyKey) {
242
- var url = (opts.apiBase || STRIPE_API_BASE_DEFAULT) + path;
292
+ var apiBase = opts.apiBase || STRIPE_API_BASE_DEFAULT;
293
+ var url = apiBase + path;
294
+ // Pin the dial to the configured Stripe host (computed once per adapter).
295
+ // Layered on top of b.httpClient's IP-class SSRF gate — the process can
296
+ // only ever reach the host the adapter was built against.
297
+ if (opts._allowedHost === undefined) opts._allowedHost = _pspHost(apiBase, "apiBase");
243
298
  var headers = {
244
299
  "authorization": "Bearer " + opts.apiKey,
245
300
  "accept": "application/json",
@@ -252,7 +307,9 @@ async function _stripeCall(opts, method, path, params, idempotencyKey) {
252
307
  headers["content-length"] = Buffer.byteLength(body, "utf8");
253
308
  }
254
309
  if (idempotencyKey) {
255
- headers["idempotency-key"] = idempotencyKey;
310
+ // The key crosses the wire as the `Idempotency-Key` header — validate +
311
+ // refuse traversal / control-char / oversize shapes before it leaves.
312
+ headers["idempotency-key"] = _assertOutboundKey(idempotencyKey, "idempotency_key");
256
313
  }
257
314
  var httpClient = opts.httpClient || b.httpClient;
258
315
  // A GET read is always idempotent; a write is idempotent only when it
@@ -268,12 +325,13 @@ async function _stripeCall(opts, method, path, params, idempotencyKey) {
268
325
  // acceptable since a sustained stream of 4xx is itself a degraded state.
269
326
  var json = await _dial(opts._breaker, idempotent, async function () {
270
327
  var res = await httpClient.request({
271
- method: method,
272
- url: url,
273
- headers: headers,
274
- body: body || undefined,
275
- timeoutMs: opts.timeoutMs || STRIPE_HTTP_TIMEOUT_MS,
276
- agent: _PSP_TLS_AGENT,
328
+ method: method,
329
+ url: url,
330
+ headers: headers,
331
+ body: body || undefined,
332
+ timeoutMs: opts.timeoutMs || STRIPE_HTTP_TIMEOUT_MS,
333
+ agent: _PSP_TLS_AGENT,
334
+ allowedHosts: [opts._allowedHost],
277
335
  });
278
336
  var text = res.body && res.body.toString ? res.body.toString("utf8") : "";
279
337
  var parsed = null;
@@ -709,6 +767,14 @@ function _paypalApiBase(opts) {
709
767
  return opts.sandbox ? PAYPAL_API_BASE_SANDBOX : PAYPAL_API_BASE_LIVE;
710
768
  }
711
769
 
770
+ // The PayPal host the adapter is allowed to dial (computed once, cached on
771
+ // opts). Pins every PayPal dial — token exchange + Orders-v2 — to the
772
+ // configured host, layered on b.httpClient's IP-class SSRF gate.
773
+ function _paypalAllowedHost(opts) {
774
+ if (opts._allowedHost === undefined) opts._allowedHost = _pspHost(_paypalApiBase(opts), "apiBase");
775
+ return opts._allowedHost;
776
+ }
777
+
712
778
  function _minorToDecimalString(minor, currency) {
713
779
  var dec = PAYPAL_ZERO_DECIMAL[currency] ? 0 : 2;
714
780
  var neg = minor < 0;
@@ -745,9 +811,10 @@ async function _paypalToken(opts, state) {
745
811
  "content-type": "application/x-www-form-urlencoded",
746
812
  "user-agent": "blamejs-shop (zero-dep)",
747
813
  },
748
- body: "grant_type=client_credentials",
749
- timeoutMs: opts.timeoutMs || PAYPAL_HTTP_TIMEOUT_MS,
750
- agent: _PSP_TLS_AGENT,
814
+ body: "grant_type=client_credentials",
815
+ timeoutMs: opts.timeoutMs || PAYPAL_HTTP_TIMEOUT_MS,
816
+ agent: _PSP_TLS_AGENT,
817
+ allowedHosts: [_paypalAllowedHost(opts)],
751
818
  });
752
819
  var text = res.body && res.body.toString ? res.body.toString("utf8") : "";
753
820
  var parsed; try { parsed = text.length ? JSON.parse(text) : {}; } catch (_e) { parsed = {}; }
@@ -774,10 +841,15 @@ async function _paypalCall(opts, state, method, path, bodyObj, requestId) {
774
841
  "content-type": "application/json",
775
842
  "user-agent": "blamejs-shop (zero-dep)",
776
843
  };
777
- if (requestId) headers["paypal-request-id"] = requestId;
844
+ // The request id crosses the wire as the `PayPal-Request-Id` header —
845
+ // validate + refuse traversal / control-char / oversize shapes before it
846
+ // leaves (covers both operator-supplied keys and the adapter-constructed
847
+ // `order:`/`capture:` ids).
848
+ if (requestId) headers["paypal-request-id"] = _assertOutboundKey(requestId, "paypal_request_id");
778
849
  var body = bodyObj != null ? JSON.stringify(bodyObj) : undefined;
779
850
  if (body) headers["content-length"] = Buffer.byteLength(body, "utf8");
780
851
  var httpClient = opts.httpClient || b.httpClient;
852
+ var allowedHost = _paypalAllowedHost(opts);
781
853
  // A GET is idempotent; a write is idempotent only when it carries a
782
854
  // PayPal-Request-Id (PayPal dedupes a replay of the SAME id, and the
783
855
  // same id rides every retry attempt within one call). A keyless write
@@ -785,12 +857,13 @@ async function _paypalCall(opts, state, method, path, bodyObj, requestId) {
785
857
  var idempotent = method === "GET" || !!requestId;
786
858
  var json = await _dial(opts._breaker, idempotent, async function () {
787
859
  var res = await httpClient.request({
788
- method: method,
789
- url: _paypalApiBase(opts) + path,
790
- headers: headers,
791
- body: body,
792
- timeoutMs: opts.timeoutMs || PAYPAL_HTTP_TIMEOUT_MS,
793
- agent: _PSP_TLS_AGENT,
860
+ method: method,
861
+ url: _paypalApiBase(opts) + path,
862
+ headers: headers,
863
+ body: body,
864
+ timeoutMs: opts.timeoutMs || PAYPAL_HTTP_TIMEOUT_MS,
865
+ agent: _PSP_TLS_AGENT,
866
+ allowedHosts: [allowedHost],
794
867
  });
795
868
  var text = res.body && res.body.toString ? res.body.toString("utf8") : "";
796
869
  var parsed; try { parsed = text.length ? JSON.parse(text) : {}; } catch (_e) { parsed = { _raw: text }; }
package/lib/quotes.js CHANGED
@@ -764,17 +764,26 @@ function create(opts) {
764
764
 
765
765
  // Update header + each line. The line update sets
766
766
  // unit_price_minor + currency per-line so a future single-line
767
- // re-quote (out of scope here) has a place to land.
768
- await query(
767
+ // re-quote (out of scope here) has a place to land. The header
768
+ // UPDATE claims the requested -> responded transition atomically
769
+ // (`AND status = 'requested'`) so a concurrent cancel/respond can't
770
+ // both commit against the same row.
771
+ var claim = await query(
769
772
  "UPDATE quotes SET status = 'responded', shipping_minor = ?1, " +
770
773
  "tax_minor = ?2, total_minor = ?3, currency = ?4, valid_until = ?5, " +
771
774
  "operator_notes = ?6, " +
772
775
  "delivery_terms = COALESCE(?7, delivery_terms), " +
773
776
  "payment_terms = COALESCE(?8, payment_terms), " +
774
- "updated_at = ?9 WHERE id = ?10",
777
+ "updated_at = ?9 WHERE id = ?10 AND status = 'requested'",
775
778
  [shipping, tax, total, currency, validUntil, operatorNotes,
776
779
  delivery, payment, ts, quoteId],
777
780
  );
781
+ if (Number(claim.rowCount || 0) !== 1) {
782
+ var raced = new Error("quotes.respondToQuote: refused — quote " + quoteId +
783
+ " is no longer in the requested state (settled by a concurrent call)");
784
+ raced.code = "QUOTE_TRANSITION_REFUSED";
785
+ throw raced;
786
+ }
778
787
  for (var j = 0; j < lines.length; j += 1) {
779
788
  var line = lines[j];
780
789
  await query(
@@ -810,11 +819,23 @@ function create(opts) {
810
819
  expired.code = "QUOTE_EXPIRED";
811
820
  throw expired;
812
821
  }
813
- await query(
822
+ // Claim the responded -> accepted transition atomically. The in-memory
823
+ // FSM assert above only checks the row we read; the `AND status =
824
+ // 'responded'` clause is what serializes a concurrent double-accept (two
825
+ // customerAccept calls on one responded quote) and a withdraw-vs-accept
826
+ // race (an operator cancelQuote running alongside) — only one wins, so a
827
+ // single accept lands and a later convert can't fire twice.
828
+ var claim = await query(
814
829
  "UPDATE quotes SET status = 'accepted', accepted_at = ?1, " +
815
- "accepted_by_customer = ?2, updated_at = ?1 WHERE id = ?3",
830
+ "accepted_by_customer = ?2, updated_at = ?1 WHERE id = ?3 AND status = 'responded'",
816
831
  [ts, acceptedBy, quoteId],
817
832
  );
833
+ if (Number(claim.rowCount || 0) !== 1) {
834
+ var raced = new Error("quotes.customerAccept: refused — quote " + quoteId +
835
+ " is no longer responded (accepted, rejected, or withdrawn by a concurrent call)");
836
+ raced.code = "QUOTE_TRANSITION_REFUSED";
837
+ throw raced;
838
+ }
818
839
  return await _hydrated(quoteId);
819
840
  },
820
841
 
@@ -835,11 +856,19 @@ function create(opts) {
835
856
  }
836
857
  _assertTransition(current.status, "reject", "customerReject");
837
858
  var ts = _now();
838
- await query(
859
+ // Claim the responded -> rejected transition atomically so a concurrent
860
+ // accept / cancel can't both commit against the same responded quote.
861
+ var claim = await query(
839
862
  "UPDATE quotes SET status = 'rejected', rejected_at = ?1, " +
840
- "reject_reason = ?2, updated_at = ?1 WHERE id = ?3",
863
+ "reject_reason = ?2, updated_at = ?1 WHERE id = ?3 AND status = 'responded'",
841
864
  [ts, reason, quoteId],
842
865
  );
866
+ if (Number(claim.rowCount || 0) !== 1) {
867
+ var raced = new Error("quotes.customerReject: refused — quote " + quoteId +
868
+ " is no longer responded (settled by a concurrent call)");
869
+ raced.code = "QUOTE_TRANSITION_REFUSED";
870
+ throw raced;
871
+ }
843
872
  return await _hydrated(quoteId);
844
873
  },
845
874
 
@@ -863,11 +892,24 @@ function create(opts) {
863
892
  }
864
893
  _assertTransition(current.status, "cancel", "cancelQuote");
865
894
  var ts = _now();
866
- await query(
895
+ // Claim the (requested|responded) -> cancelled transition atomically.
896
+ // The `AND status IN (...)` clause closes the withdraw-vs-accept race:
897
+ // an operator cancelQuote and a customer customerAccept on the same
898
+ // responded quote can't both commit — whichever UPDATE lands first wins,
899
+ // and the loser refuses instead of silently clobbering the other side's
900
+ // transition (a cancel clobbering an accept would otherwise kill an
901
+ // order the operator thought was live, and vice-versa).
902
+ var claim = await query(
867
903
  "UPDATE quotes SET status = 'cancelled', cancelled_at = ?1, " +
868
- "cancel_reason = ?2, updated_at = ?1 WHERE id = ?3",
904
+ "cancel_reason = ?2, updated_at = ?1 WHERE id = ?3 AND status IN ('requested', 'responded')",
869
905
  [ts, reason, quoteId],
870
906
  );
907
+ if (Number(claim.rowCount || 0) !== 1) {
908
+ var raced = new Error("quotes.cancelQuote: refused — quote " + quoteId +
909
+ " is no longer cancellable (accepted, rejected, or settled by a concurrent call)");
910
+ raced.code = "QUOTE_TRANSITION_REFUSED";
911
+ throw raced;
912
+ }
871
913
  return await _hydrated(quoteId);
872
914
  },
873
915
 
@@ -890,10 +932,45 @@ function create(opts) {
890
932
  }
891
933
  _assertTransition(current.status, "convert", "convertToOrder");
892
934
 
893
- var lines = await _getLinesRaw(quoteId);
894
935
  var ts = _now();
936
+ // Claim the accepted -> converted transition atomically BEFORE placing
937
+ // inventory holds or creating the pending order. The in-memory FSM
938
+ // `_assertTransition` above only answers "is this edge legal for the row
939
+ // I read?" — two concurrent convertToOrder calls both read 'accepted'
940
+ // and both pass it. The conditional UPDATE is the real serialization
941
+ // point: only one matches `status = 'accepted'`, so only one mints holds
942
+ // + an order. (Without it both would create a pending order + double the
943
+ // inventory holds from a single accepted quote.) converted_order_id is
944
+ // backfilled once the order id is known; on any failure the claim is
945
+ // released back to 'accepted' so the operator can retry.
946
+ var claim = await query(
947
+ "UPDATE quotes SET status = 'converted', converted_at = ?1, updated_at = ?1 " +
948
+ "WHERE id = ?2 AND status = 'accepted'",
949
+ [ts, quoteId],
950
+ );
951
+ if (Number(claim.rowCount || 0) !== 1) {
952
+ var raced = new Error("quotes.convertToOrder: refused — quote " + quoteId +
953
+ " is no longer accepted (converted by a concurrent call)");
954
+ raced.code = "QUOTE_TRANSITION_REFUSED";
955
+ throw raced;
956
+ }
957
+
958
+ // Release the claim back to 'accepted' so a retry is possible after a
959
+ // downstream (hold / order-creation) failure leaves no order behind.
960
+ async function _releaseConvertClaim() {
961
+ try {
962
+ await query(
963
+ "UPDATE quotes SET status = 'accepted', converted_at = NULL, updated_at = ?1 " +
964
+ "WHERE id = ?2 AND status = 'converted' AND converted_order_id IS NULL",
965
+ [_now(), quoteId],
966
+ );
967
+ } catch (_e0) { /* drop-silent — the downstream error is the caller's signal */ }
968
+ }
969
+
970
+ var lines = await _getLinesRaw(quoteId);
895
971
  var orderId;
896
972
 
973
+ try {
897
974
  if (orderHandle) {
898
975
  // The order primitive requires cart_id + session_id as
899
976
  // UUID-shape inputs. For a quote-driven order, the operator
@@ -982,11 +1059,18 @@ function create(opts) {
982
1059
  }
983
1060
  orderId = input.converted_order_id;
984
1061
  }
1062
+ } catch (eConvert) {
1063
+ // The claim already flipped status to 'converted'; nothing downstream
1064
+ // produced an order, so release it back to 'accepted' for a retry.
1065
+ await _releaseConvertClaim();
1066
+ throw eConvert;
1067
+ }
985
1068
 
1069
+ // Status + converted_at were claimed atomically above; backfill the
1070
+ // resolved order id onto the (already-converted) quote.
986
1071
  await query(
987
- "UPDATE quotes SET status = 'converted', converted_at = ?1, " +
988
- "converted_order_id = ?2, updated_at = ?1 WHERE id = ?3",
989
- [ts, orderId, quoteId],
1072
+ "UPDATE quotes SET converted_order_id = ?1, updated_at = ?2 WHERE id = ?3",
1073
+ [orderId, _now(), quoteId],
990
1074
  );
991
1075
  return await _hydrated(quoteId);
992
1076
  },
@@ -1145,10 +1229,18 @@ function create(opts) {
1145
1229
  notYet.code = "QUOTE_NOT_EXPIRED";
1146
1230
  throw notYet;
1147
1231
  }
1148
- await query(
1149
- "UPDATE quotes SET status = 'expired', updated_at = ?1 WHERE id = ?2",
1232
+ // Claim the responded -> expired transition atomically so a concurrent
1233
+ // accept / cancel can't both commit against the same responded quote.
1234
+ var claim = await query(
1235
+ "UPDATE quotes SET status = 'expired', updated_at = ?1 WHERE id = ?2 AND status = 'responded'",
1150
1236
  [asOf, quoteId],
1151
1237
  );
1238
+ if (Number(claim.rowCount || 0) !== 1) {
1239
+ var raced = new Error("quotes.markExpired: refused — quote " + quoteId +
1240
+ " is no longer responded (settled by a concurrent call)");
1241
+ raced.code = "QUOTE_TRANSITION_REFUSED";
1242
+ throw raced;
1243
+ }
1152
1244
  return await _hydrated(quoteId);
1153
1245
  },
1154
1246
  };
package/lib/referrals.js CHANGED
@@ -452,6 +452,77 @@ function create(opts) {
452
452
  return out;
453
453
  },
454
454
 
455
+ // Reverse the funnel completion when the order that qualified it is
456
+ // refunded or cancelled — the counterpart to trackPurchase on an order's
457
+ // terminal refund / cancel edge. trackPurchase flips the invitation to
458
+ // `both-rewarded`, pins the first qualifying order, and bumps the
459
+ // referrer's leaderboard `referrals_count`; without this, a referred
460
+ // customer can buy (completing the funnel + ticking the leaderboard) then
461
+ // refund and keep the completion — buy-then-refund reward farming.
462
+ //
463
+ // Keyed on the ORDER id (first_order_id) — the qualifying purchase whose
464
+ // settlement is reversing the funnel. Rolls the invitation back to
465
+ // `pending`, clears first_purchase_at / first_order_id, and decrements the
466
+ // referrer's referrals_count (floored at zero by the schema CHECK + the
467
+ // `referrals_count > 0` guard). Unlike the money tenders this is BINARY,
468
+ // not pro-rata: a refund of the qualifying order — partial or full —
469
+ // un-completes the funnel (the purchase that earned the reward was
470
+ // reversed), so the operator's actual payouts (rewardReferrer /
471
+ // rewardReferee) are NOT touched here — they're separate instruments the
472
+ // operator claws back manually; this only rolls back the funnel state +
473
+ // the leaderboard counter the FSM auto-advanced.
474
+ //
475
+ // Idempotent + concurrency-safe: the invitation is claimed with
476
+ // `WHERE first_order_id = ? AND reversed_at IS NULL` so a re-delivered
477
+ // refund webhook reverses the funnel exactly once; a row whose
478
+ // reward_status is no longer `both-rewarded` (an operator already advanced
479
+ // it past trackPurchase) keeps its reward_status but still drops out of
480
+ // the leaderboard count. A no-op for an order that never completed a
481
+ // funnel (organic purchase, or one already reversed). Returns the reversed
482
+ // invitation row, or null when there was nothing to reverse.
483
+ reverseForOrder: async function (orderId) {
484
+ var oid = _uuid(orderId, "order_id");
485
+ var r = await query(
486
+ "SELECT id, referral_code_id, reward_status FROM referral_invitations " +
487
+ "WHERE first_order_id = ?1 AND reversed_at IS NULL LIMIT 1",
488
+ [oid],
489
+ );
490
+ if (!r.rows.length) return null;
491
+ var inv = r.rows[0];
492
+ var ts = _now();
493
+ // Claim the invitation — the reversed_at predicate is the
494
+ // serialization point so two concurrent reversals can't both
495
+ // decrement the leaderboard. Roll reward_status back to `pending`
496
+ // only from the `both-rewarded` completion trackPurchase set; an
497
+ // operator-advanced terminal (`expired` / `voided`) or an
498
+ // operator-issued payout state is left as the operator chose.
499
+ var claim = await query(
500
+ "UPDATE referral_invitations SET reversed_at = ?1, " +
501
+ "first_purchase_at = NULL, first_order_id = NULL, " +
502
+ "reward_status = CASE WHEN reward_status = 'both-rewarded' THEN 'pending' ELSE reward_status END " +
503
+ "WHERE id = ?2 AND reversed_at IS NULL",
504
+ [ts, inv.id],
505
+ );
506
+ if (Number(claim.rowCount || 0) === 0) return null; // lost the claim
507
+ // Decrement the referrer's leaderboard count, floored at zero (the
508
+ // `> 0` guard plus the schema CHECK keep it non-negative even if the
509
+ // count drifted).
510
+ await query(
511
+ "UPDATE referral_codes " +
512
+ "SET referrals_count = referrals_count - 1, updated_at = ?1 " +
513
+ "WHERE id = ?2 AND referrals_count > 0",
514
+ [ts, inv.referral_code_id],
515
+ );
516
+ var after = await query(
517
+ "SELECT id, referral_code_id, reward_status, first_purchase_at, first_order_id, reversed_at " +
518
+ "FROM referral_invitations WHERE id = ?1",
519
+ [inv.id],
520
+ );
521
+ var out = after.rows[0] || null;
522
+ if (out) out.status = "reversed";
523
+ return out;
524
+ },
525
+
455
526
  // Operator records that the referrer payout has been issued.
456
527
  // `reward_id` is opaque (operator's gift-card id, discount-code
457
528
  // id, ledger-credit id — whatever instrument they chose). The
@@ -103,6 +103,23 @@ var INTERNAL_BRIDGE_PATHS = [
103
103
  "/_/stale-order-reap",
104
104
  ];
105
105
 
106
+ // Public well-known paths fetched by third-party verification crawlers
107
+ // rather than browsers. Apple's Apple Pay domain-verification crawl GETs
108
+ // the merchantid association file and is not guaranteed to send
109
+ // Accept-Language, which the bot-guard's missing-Accept-Language heuristic
110
+ // would 403 — silently hiding the Apple Pay button (the same
111
+ // machine-caller-blocked-before-its-real-gate failure the worker→container
112
+ // paths above hit). The file is a static, unauthenticated, state-free
113
+ // public resource (the route 404s when unconfigured and only ever serves
114
+ // the operator-supplied association bytes), so skipping the browser
115
+ // fingerprint check on it weakens nothing. In production the crawl lands on
116
+ // the edge Worker, which serves the file before any container hop; this
117
+ // skip keeps a direct-to-container fetch (or an edge-serving-off deploy)
118
+ // from refusing the verification crawl.
119
+ var PUBLIC_WELL_KNOWN_PATHS = [
120
+ "/.well-known/apple-developer-merchantid-domain-association",
121
+ ];
122
+
106
123
  // The abusable endpoints — POST / auth surfaces where a human does
107
124
  // well under five requests a minute. Each gets its own per-client-IP
108
125
  // budget so a spray against one can't borrow another's headroom, and a
@@ -145,6 +162,12 @@ var TIGHT_PREFIXES = [
145
162
  "/orders/",
146
163
  "/stock-alert/",
147
164
  "/cart/coupon",
165
+ // Public suggestion box — anonymous submit + vote POSTs. The submit writes
166
+ // a free-text row and the vote bumps a counter; on the loose global bucket
167
+ // alone an anonymous sprayer could flood the backlog or grind a vote. The
168
+ // tight per-(IP+path) budget caps the rate. Container-only (CSRF-tokened),
169
+ // so it is NOT in EDGE_POST_PATHS — the page carries a per-session token.
170
+ "/suggestions",
148
171
  ];
149
172
 
150
173
  // Edge-served state-changing POST endpoints. These forms are rendered at
@@ -584,7 +607,15 @@ function globalRateLimitOpts() {
584
607
  */
585
608
  function botGuardOpts() {
586
609
  return {
587
- skipPaths: INTERNAL_BRIDGE_PATHS.slice().concat([/^\/admin(\/|$)/]),
610
+ skipPaths: INTERNAL_BRIDGE_PATHS.slice()
611
+ // EXACT-match the well-known paths. A bare-string skip is matched as a
612
+ // PREFIX by the bot guard, which would also exempt any sibling under the
613
+ // directory (e.g. /.well-known/apple-...-association/anything), re-opening
614
+ // the guard on routes that aren't the single static association file.
615
+ .concat(PUBLIC_WELL_KNOWN_PATHS.map(function (p) {
616
+ return new RegExp("^" + p.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&") + "$");
617
+ }))
618
+ .concat([/^\/admin(\/|$)/]),
588
619
  };
589
620
  }
590
621
 
@@ -733,4 +764,5 @@ module.exports = {
733
764
  HEALTH_PATH: HEALTH_PATH,
734
765
  TIGHT_PREFIXES: TIGHT_PREFIXES,
735
766
  EDGE_POST_PATHS: EDGE_POST_PATHS,
767
+ PUBLIC_WELL_KNOWN_PATHS: PUBLIC_WELL_KNOWN_PATHS,
736
768
  };