@blamejs/blamejs-shop 0.4.21 → 0.4.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.
@@ -84,6 +84,11 @@ var SHA3_512_HEX_LEN = 128;
84
84
 
85
85
  var ZERO_HASH = "0".repeat(SHA3_512_HEX_LEN);
86
86
 
87
+ // Canonical prefix for the bytes a checkpoint signs. Stable forever —
88
+ // changing it invalidates every prior checkpoint signature. Mirrors the
89
+ // framework audit chain's "blamejs-audit-checkpoint-v1" format.
90
+ var CHECKPOINT_FORMAT = "blamejs-operator-audit-checkpoint-v1";
91
+
87
92
  var ALLOWED_ACTOR_TYPES = Object.freeze(["operator", "system", "app"]);
88
93
  var ALLOWED_UA_CLASSES = Object.freeze([
89
94
  "browser",
@@ -581,6 +586,152 @@ function create(opts) {
581
586
  return { ok: true, rows_verified: rows.length, last_hash: prevHash };
582
587
  }
583
588
 
589
+ // -- checkpoint anchoring ----------------------------------------------
590
+ //
591
+ // The hash chain is tamper-EVIDENT against a single edited/deleted row,
592
+ // but an attacker who can rewrite the whole table can re-hash from a
593
+ // forged genesis and leave the chain internally consistent. Anchoring
594
+ // the tip with a post-quantum signature over the head row_hash — keyed
595
+ // off the framework's b.auditSign keypair, whose private half never
596
+ // touches D1 — closes that hole: a full-chain rewrite can't reproduce a
597
+ // valid checkpoint signature over the rewritten tip.
598
+ //
599
+ // checkpoint() signs the CURRENT head and inserts an anchor row.
600
+ // verifyCheckpoints() re-derives every anchor's signature and confirms
601
+ // the anchored row still carries the signed hash. Both no-op gracefully
602
+ // when b.auditSign isn't initialized (the chain stays hash-linked; the
603
+ // signature layer is simply absent) so a deployment without the
604
+ // audit-signing keypair still records + verifies the linkage.
605
+
606
+ function _auditSignReady() {
607
+ try { b.auditSign.getPublicKeyFingerprint(); return true; }
608
+ catch (_e) { return false; }
609
+ }
610
+
611
+ // Canonical signed bytes for a checkpoint. STABLE FOREVER — changing
612
+ // this layout invalidates every prior checkpoint signature.
613
+ function _checkpointPayload(atRowId, atOccurredAt, atRowHash, createdAt) {
614
+ return Buffer.from(
615
+ CHECKPOINT_FORMAT + "\n" +
616
+ atRowId + "\n" +
617
+ String(atOccurredAt) + "\n" +
618
+ atRowHash + "\n" +
619
+ String(createdAt),
620
+ "utf8",
621
+ );
622
+ }
623
+
624
+ // Anchor the current chain tip with a fresh PQC signature. Returns the
625
+ // inserted checkpoint row, or null when the chain is empty (nothing to
626
+ // anchor) or `skipIfUnchanged` and the tip hasn't advanced since the
627
+ // last checkpoint. Throws OperatorAuditCheckpointSigningUnavailable-
628
+ // shaped TypeError only if asked to checkpoint without a signing key —
629
+ // callers that want a soft skip check `signingAvailable()` first.
630
+ async function checkpoint(checkpointOpts) {
631
+ checkpointOpts = checkpointOpts || {};
632
+ if (!_auditSignReady()) {
633
+ throw new TypeError("operatorAuditLog.checkpoint: b.auditSign is not initialized — " +
634
+ "no signing key to anchor the chain with");
635
+ }
636
+ var head = await _currentHead();
637
+ if (head.id === null) return null; // empty chain — nothing to anchor
638
+
639
+ if (checkpointOpts.skipIfUnchanged) {
640
+ var last = await query(
641
+ "SELECT at_row_hash FROM operator_audit_checkpoints " +
642
+ "ORDER BY created_at DESC, id DESC LIMIT 1",
643
+ [],
644
+ );
645
+ if (last.rows.length && last.rows[0].at_row_hash === head.row_hash) {
646
+ return null; // already anchored at this exact tip
647
+ }
648
+ }
649
+
650
+ var createdAt = _now();
651
+ var payload = _checkpointPayload(head.id, head.occurred_at, head.row_hash, createdAt);
652
+ var signature = b.auditSign.sign(payload).toString("base64");
653
+ var pubFp = b.auditSign.getPublicKeyFingerprint();
654
+ var id = b.uuid.v7();
655
+
656
+ await query(
657
+ "INSERT INTO operator_audit_checkpoints " +
658
+ "(id, created_at, at_row_id, at_occurred_at, at_row_hash, signature, public_key_fingerprint) " +
659
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
660
+ [id, createdAt, head.id, head.occurred_at, head.row_hash, signature, pubFp],
661
+ );
662
+
663
+ return {
664
+ id: id,
665
+ created_at: createdAt,
666
+ at_row_id: head.id,
667
+ at_occurred_at: head.occurred_at,
668
+ at_row_hash: head.row_hash,
669
+ public_key_fingerprint: pubFp,
670
+ };
671
+ }
672
+
673
+ // Walk every checkpoint oldest-first, re-derive its signature, and
674
+ // confirm the anchored row still carries the signed hash. Reports the
675
+ // first divergence — a forged signature, a key-rotation fingerprint
676
+ // mismatch, a vanished anchor row, or a rewritten tip whose hash no
677
+ // longer matches what was signed.
678
+ async function verifyCheckpoints() {
679
+ if (!_auditSignReady()) {
680
+ return { ok: true, checkpoints_verified: 0, reason: "audit-sign-unavailable" };
681
+ }
682
+ var r = await query(
683
+ "SELECT * FROM operator_audit_checkpoints ORDER BY created_at ASC, id ASC",
684
+ [],
685
+ );
686
+ var rows = r.rows;
687
+ if (!rows.length) return { ok: true, checkpoints_verified: 0 };
688
+
689
+ var currentFp = b.auditSign.getPublicKeyFingerprint();
690
+ var currentPub = b.auditSign.getPublicKey();
691
+
692
+ for (var i = 0; i < rows.length; i += 1) {
693
+ var c = rows[i];
694
+ // Only the current key verifies (no key-history table). A rotation
695
+ // without re-signing surfaces here rather than silently failing.
696
+ if (c.public_key_fingerprint !== currentFp) {
697
+ return {
698
+ ok: false, checkpoints_verified: i, break_at: i, checkpoint_id: c.id,
699
+ reason: "public key fingerprint mismatch (key rotated without re-signing?)",
700
+ expected: currentFp, actual: c.public_key_fingerprint,
701
+ };
702
+ }
703
+ var payload = _checkpointPayload(c.at_row_id, Number(c.at_occurred_at), c.at_row_hash, Number(c.created_at));
704
+ var sigBuf;
705
+ try { sigBuf = Buffer.from(c.signature, "base64"); }
706
+ catch (_e) {
707
+ return { ok: false, checkpoints_verified: i, break_at: i, checkpoint_id: c.id,
708
+ reason: "checkpoint signature not decodable" };
709
+ }
710
+ if (!b.auditSign.verify(payload, sigBuf, currentPub)) {
711
+ return { ok: false, checkpoints_verified: i, break_at: i, checkpoint_id: c.id,
712
+ reason: "post-quantum signature failed" };
713
+ }
714
+ // The anchored row must still exist AND still carry the signed hash.
715
+ // A full-chain rewrite changes row_hash → this mismatch is the catch.
716
+ var anchored = await query(
717
+ "SELECT row_hash FROM operator_audit_events WHERE id = ?1 LIMIT 1",
718
+ [c.at_row_id],
719
+ );
720
+ if (!anchored.rows.length) {
721
+ return { ok: false, checkpoints_verified: i, break_at: i, checkpoint_id: c.id,
722
+ reason: "anchored audit row missing (id=" + c.at_row_id + ")" };
723
+ }
724
+ if (anchored.rows[0].row_hash !== c.at_row_hash) {
725
+ return {
726
+ ok: false, checkpoints_verified: i, break_at: i, checkpoint_id: c.id,
727
+ reason: "anchored row_hash mismatch — operator_audit_events was rewritten",
728
+ expected: c.at_row_hash, actual: anchored.rows[0].row_hash,
729
+ };
730
+ }
731
+ }
732
+ return { ok: true, checkpoints_verified: rows.length };
733
+ }
734
+
584
735
  return {
585
736
  MAX_ACTOR_ID_LEN: MAX_ACTOR_ID_LEN,
586
737
  MAX_ACTION_LEN: MAX_ACTION_LEN,
@@ -593,12 +744,20 @@ function create(opts) {
593
744
  ALLOWED_UA_CLASSES: ALLOWED_UA_CLASSES,
594
745
  ZERO_HASH: ZERO_HASH,
595
746
 
596
- record: record,
597
- listByActor: listByActor,
598
- listByResource: listByResource,
599
- searchAction: searchAction,
600
- chainHead: chainHead,
601
- verifyChain: verifyChain,
747
+ record: record,
748
+ listByActor: listByActor,
749
+ listByResource: listByResource,
750
+ searchAction: searchAction,
751
+ chainHead: chainHead,
752
+ verifyChain: verifyChain,
753
+ // Checkpoint anchoring — sign the chain tip out-of-band so a
754
+ // full-chain rewrite (which verifyChain alone can't catch) becomes
755
+ // detectable. `signingAvailable()` lets callers soft-skip when the
756
+ // audit-signing key isn't initialized.
757
+ checkpoint: checkpoint,
758
+ verifyCheckpoints: verifyCheckpoints,
759
+ signingAvailable: _auditSignReady,
760
+ CHECKPOINT_FORMAT: CHECKPOINT_FORMAT,
602
761
  };
603
762
  }
604
763
 
@@ -614,4 +773,5 @@ module.exports = {
614
773
  ALLOWED_ACTOR_TYPES: ALLOWED_ACTOR_TYPES,
615
774
  ALLOWED_UA_CLASSES: ALLOWED_UA_CLASSES,
616
775
  ZERO_HASH: ZERO_HASH,
776
+ CHECKPOINT_FORMAT: CHECKPOINT_FORMAT,
617
777
  };
@@ -212,7 +212,8 @@ function _limit(n) {
212
212
  // RFC-4180 quoting: every cell wrapped in `"`, embedded `"` doubled.
213
213
  // We quote unconditionally — the cost is a few extra bytes per cell;
214
214
  // the win is that a downstream parser never has to track quote-vs-
215
- // bare-cell state for a column with mixed shapes.
215
+ // bare-cell state for a column with mixed shapes. The injection
216
+ // neutralization runs first via the shared vendored primitive.
216
217
  function _csvCell(value) {
217
218
  var s = _coerceCell(value);
218
219
  s = _neutralizeInjection(s);
@@ -227,23 +228,19 @@ function _coerceCell(value) {
227
228
  return JSON.stringify(value);
228
229
  }
229
230
 
230
- // Numeric-with-sign detector`+15.00` / `-3.50` / `+0` parse as
231
- // legitimate amounts and should pass through unmolested. The
232
- // detector rejects anything with embedded whitespace or trailing
233
- // non-numeric tail so `+15 SUM(A1)` still gets escaped.
234
- var _NUMERIC_SIGN_RE = /^[+-](?:\d+(?:\.\d+)?|\.\d+)$/;
235
-
236
- // CSV injection neutralization see OWASP "CSV Injection". A cell
237
- // beginning with `=`, `+`, `-`, or `@` is interpreted as a formula
238
- // by most spreadsheet renderers. The defense is to prefix with `'`
239
- // so the renderer treats the cell as literal text. Signed numerics
240
- // are the deliberate exception (legitimate amount strings).
231
+ // CSV injection neutralization composes the vendored b.guardCsv.escapeCell
232
+ // (OWASP "CSV Injection" defense). The vendored primitive is the single
233
+ // shared neutralizer across every CSV export surface (order-export +
234
+ // customer-segments). It prefixes a leading TAB when a cell starts with ANY
235
+ // formula-trigger char — `= + - @` AND the tab / CR / LF / pipe / full-width
236
+ // variants a hand-rolled `= + - @`-only check misses. A leading tab renders
237
+ // as invisible whitespace in a spreadsheet, so the cell reads as text and
238
+ // never evaluates as a formula. The earlier in-tree check exempted signed
239
+ // numerics (`+15.00`); the shared primitive prefixes those too (the safe
240
+ // OWASP posture `-2+3+cmd|…` is a real injection that begins like an
241
+ // amount), which is the more complete behavior this consolidation buys.
241
242
  function _neutralizeInjection(s) {
242
- if (s.length === 0) return s;
243
- var first = s.charAt(0);
244
- if (first !== "=" && first !== "+" && first !== "-" && first !== "@") return s;
245
- if ((first === "+" || first === "-") && _NUMERIC_SIGN_RE.test(s)) return s;
246
- return "'" + s;
243
+ return b.guardCsv.escapeCell(s);
247
244
  }
248
245
 
249
246
  function _csvRow(cells) {
package/lib/payment.js CHANGED
@@ -8,9 +8,15 @@
8
8
  * verification (HMAC-SHA256 over `<timestamp>.<body>` with
9
9
  * `whsec_...` secret, ±5 min tolerance, constant-time compare) and
10
10
  * outbound API calls (PaymentIntent create / retrieve / confirm /
11
- * cancel + Refund) composed on `b.httpClient` (SSRF-gated, retried,
12
- * circuit-broken, ALPN HTTP/2). No `stripe` npm dep every byte is
13
- * either node built-in or vendored blamejs primitive.
11
+ * cancel + Refund) on `b.httpClient` (SSRF-gated, response-capped,
12
+ * ALPN HTTP/2). Each adapter instance wraps its dials in a per-upstream
13
+ * `b.circuitBreaker` plus a bounded `b.retry` (the latter only on
14
+ * idempotent dials — GET reads + idempotency-keyed writes — so a
15
+ * transient blip never re-sends a one-shot charge). While the breaker is
16
+ * open the dial fast-fails with `code: "CIRCUIT_OPEN"` BEFORE the
17
+ * request, so checkout renders the recoverable payment-unavailable page
18
+ * rather than stranding an order mid-charge. No `stripe` npm dep — every
19
+ * byte is either node built-in or vendored blamejs primitive.
14
20
  *
15
21
  * Future Adyen / Mollie / Paddle adapters land as additional
16
22
  * factory functions returning the same `{ verifyWebhook,
@@ -48,6 +54,59 @@ var STRIPE_WEBHOOK_TOLERANCE = 300; // ± 5 minutes (Stripe default)
48
54
  var STRIPE_HTTP_TIMEOUT_MS = 15000;
49
55
  var CURRENCY_RE = /^[a-z]{3}$/; // Stripe wants lowercase ISO 4217
50
56
 
57
+ // ---- PSP dial resilience (circuit breaker + bounded retry) ----------------
58
+ //
59
+ // b.httpClient does NOT itself circuit-break or retry — it SSRF-gates, caps,
60
+ // and pools, but the failure-threshold breaker + backoff retry are separate
61
+ // primitives a consumer composes around it. Each adapter instance owns ONE
62
+ // breaker per upstream (Stripe / PayPal): per-target is the only correct
63
+ // scope — sharing a breaker across unrelated peers defeats the
64
+ // failure-threshold semantic.
65
+ //
66
+ // Open-circuit posture: while the breaker is open every dial fast-fails with
67
+ // a plain Error carrying `code: "CIRCUIT_OPEN"` (NOT a TypeError), so the
68
+ // storefront's checkout handler renders it through the existing recoverable
69
+ // "checkout didn't go through" page — the same structured payment-unavailable
70
+ // surface a raw upstream 5xx already produces — rather than charging or
71
+ // 400-ing a field. Nothing is captured: the breaker fails BEFORE the dial, so
72
+ // no order is stranded mid-charge.
73
+ //
74
+ // Retry rides ONLY on idempotent dials — GET reads and writes carrying an
75
+ // idempotency key (Stripe Idempotency-Key / PayPal-Request-Id). A
76
+ // keyless POST is sent exactly once: re-driving it could double-charge.
77
+ var BREAKER_FAILURE_THRESHOLD = 5; // consecutive failures before opening
78
+ var BREAKER_COOLDOWN_MS = C.TIME.seconds(30); // open → half-open probe delay
79
+ var BREAKER_SUCCESS_THRESHOLD = 2; // half-open probes to re-close
80
+ var DIAL_RETRY_MAX_ATTEMPTS = 3; // total tries incl. first (idempotent only)
81
+ var DIAL_RETRY_BASE_DELAY_MS = 200;
82
+ var DIAL_RETRY_MAX_DELAY_MS = C.TIME.seconds(2);
83
+
84
+ // One breaker per adapter instance. `idempotent` selects whether the dial
85
+ // also rides the bounded backoff retry — the breaker counts the retry loop's
86
+ // FINAL outcome as a single call (b.retry.withBreaker), so a transient blip
87
+ // that the retry rides out never inflates the failure counter.
88
+ function _makeBreaker(name) {
89
+ return b.circuitBreaker.create({
90
+ name: name,
91
+ failureThreshold: BREAKER_FAILURE_THRESHOLD,
92
+ cooldownMs: BREAKER_COOLDOWN_MS,
93
+ successThreshold: BREAKER_SUCCESS_THRESHOLD,
94
+ });
95
+ }
96
+
97
+ function _dial(breaker, idempotent, fn) {
98
+ if (!breaker) return fn();
99
+ if (!idempotent) return breaker.wrap(fn);
100
+ return b.retry.withBreaker(fn, {
101
+ breaker: breaker,
102
+ retry: {
103
+ maxAttempts: DIAL_RETRY_MAX_ATTEMPTS,
104
+ baseDelayMs: DIAL_RETRY_BASE_DELAY_MS,
105
+ maxDelayMs: DIAL_RETRY_MAX_DELAY_MS,
106
+ },
107
+ });
108
+ }
109
+
51
110
  // Stripe holds idempotency keys for 24h, so the local cache row
52
111
  // expires on the same window — operators who run `cleanupExpired()`
53
112
  // on a daily schedule keep the table small without ever shortening
@@ -196,33 +255,47 @@ async function _stripeCall(opts, method, path, params, idempotencyKey) {
196
255
  headers["idempotency-key"] = idempotencyKey;
197
256
  }
198
257
  var httpClient = opts.httpClient || b.httpClient;
199
- var res = await httpClient.request({
200
- method: method,
201
- url: url,
202
- headers: headers,
203
- body: body || undefined,
204
- timeoutMs: opts.timeoutMs || STRIPE_HTTP_TIMEOUT_MS,
205
- agent: _PSP_TLS_AGENT,
258
+ // A GET read is always idempotent; a write is idempotent only when it
259
+ // carries an Idempotency-Key (Stripe dedupes a replay of the SAME key
260
+ // server-side). A keyless POST rides the breaker but NOT the retry, so
261
+ // a transient blip never re-sends it (double-charge guard).
262
+ var idempotent = method === "GET" || !!idempotencyKey;
263
+ // The HTTP request AND the non-2xx → throw both run INSIDE the dialed
264
+ // closure so the breaker counts a 5xx / transport error as a failure
265
+ // (and the idempotent-path retry retries it). A 4xx is the request's
266
+ // fault, not the peer's health — `err.statusCode` makes the retry
267
+ // classifier skip it, and a 4xx still increments the breaker, which is
268
+ // acceptable since a sustained stream of 4xx is itself a degraded state.
269
+ var json = await _dial(opts._breaker, idempotent, async function () {
270
+ 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,
277
+ });
278
+ var text = res.body && res.body.toString ? res.body.toString("utf8") : "";
279
+ var parsed = null;
280
+ try { parsed = text.length ? JSON.parse(text) : {}; } catch (_e) { parsed = { _raw: text }; }
281
+ if (res.statusCode < 200 || res.statusCode >= 300) {
282
+ var err = new Error("stripe: " + method + " " + path + " → HTTP " + res.statusCode +
283
+ (parsed && parsed.error && parsed.error.message ? " — " + parsed.error.message : ""));
284
+ err.code = (parsed && parsed.error && parsed.error.code) || "STRIPE_HTTP_" + res.statusCode;
285
+ err.statusCode = res.statusCode;
286
+ err.stripe = parsed && parsed.error || null;
287
+ err._stripeRawText = text;
288
+ err._stripeStatus = res.statusCode;
289
+ throw err;
290
+ }
291
+ // Carry the raw status + serialised body alongside the parsed JSON
292
+ // so the idempotency layer can persist them verbatim for replay
293
+ // without re-stringifying (preserves byte-for-byte fidelity with
294
+ // what Stripe returned, including field ordering).
295
+ Object.defineProperty(parsed, "_stripeStatus", { value: res.statusCode, enumerable: false });
296
+ Object.defineProperty(parsed, "_stripeRawText", { value: text, enumerable: false });
297
+ return parsed;
206
298
  });
207
- var text = res.body && res.body.toString ? res.body.toString("utf8") : "";
208
- var json = null;
209
- try { json = text.length ? JSON.parse(text) : {}; } catch (_e) { json = { _raw: text }; }
210
- if (res.statusCode < 200 || res.statusCode >= 300) {
211
- var err = new Error("stripe: " + method + " " + path + " → HTTP " + res.statusCode +
212
- (json && json.error && json.error.message ? " — " + json.error.message : ""));
213
- err.code = (json && json.error && json.error.code) || "STRIPE_HTTP_" + res.statusCode;
214
- err.statusCode = res.statusCode;
215
- err.stripe = json && json.error || null;
216
- err._stripeRawText = text;
217
- err._stripeStatus = res.statusCode;
218
- throw err;
219
- }
220
- // Carry the raw status + serialised body alongside the parsed JSON
221
- // so the idempotency layer can persist them verbatim for replay
222
- // without re-stringifying (preserves byte-for-byte fidelity with
223
- // what Stripe returned, including field ordering).
224
- Object.defineProperty(json, "_stripeStatus", { value: res.statusCode, enumerable: false });
225
- Object.defineProperty(json, "_stripeRawText", { value: text, enumerable: false });
226
299
  return json;
227
300
  }
228
301
 
@@ -330,6 +403,13 @@ function stripe(opts) {
330
403
  throw new TypeError("payment: now must be a function returning current epoch ms");
331
404
  }
332
405
 
406
+ // One circuit breaker per adapter instance, guarding every Stripe dial.
407
+ // Stashed on opts so the module-level `_stripeCall` reaches it without a
408
+ // signature change. Skippable in tests via `breaker: false`.
409
+ if (opts._breaker === undefined) {
410
+ opts._breaker = opts.breaker === false ? null : _makeBreaker("psp-stripe");
411
+ }
412
+
333
413
  // Idempotency state shared across every mutating call. When `query`
334
414
  // is not supplied the primitive runs in legacy mode — every
335
415
  // mutating call goes straight to Stripe, no cache writes, no
@@ -349,6 +429,11 @@ function stripe(opts) {
349
429
  return {
350
430
  name: "stripe",
351
431
 
432
+ // The per-adapter circuit breaker (or null when disabled). Exposed so
433
+ // an operator dashboard can read `breaker.getState()` ("closed" /
434
+ // "open" / "half") and reset it after a confirmed recovery.
435
+ breaker: opts._breaker,
436
+
352
437
  verifyWebhook: function (headers, rawBody, vOpts) {
353
438
  return _verifyWebhook(headers, rawBody, opts.webhookSecret, vOpts);
354
439
  },
@@ -648,28 +733,33 @@ async function _paypalToken(opts, state) {
648
733
  if (state.token && now < state.tokenExpiresAt) return state.token;
649
734
  var httpClient = opts.httpClient || b.httpClient;
650
735
  var basic = Buffer.from(opts.clientId + ":" + opts.secret).toString("base64");
651
- var res = await httpClient.request({
652
- method: "POST",
653
- url: _paypalApiBase(opts) + "/v1/oauth2/token",
654
- headers: {
655
- "authorization": "Basic " + basic,
656
- "accept": "application/json",
657
- "content-type": "application/x-www-form-urlencoded",
658
- "user-agent": "blamejs-shop (zero-dep)",
659
- },
660
- body: "grant_type=client_credentials",
661
- timeoutMs: opts.timeoutMs || PAYPAL_HTTP_TIMEOUT_MS,
662
- agent: _PSP_TLS_AGENT,
736
+ // The client-credentials token exchange is idempotent (re-asking for a
737
+ // token is always safe), so it rides the breaker AND the bounded retry.
738
+ var json = await _dial(opts._breaker, true, async function () {
739
+ var res = await httpClient.request({
740
+ method: "POST",
741
+ url: _paypalApiBase(opts) + "/v1/oauth2/token",
742
+ headers: {
743
+ "authorization": "Basic " + basic,
744
+ "accept": "application/json",
745
+ "content-type": "application/x-www-form-urlencoded",
746
+ "user-agent": "blamejs-shop (zero-dep)",
747
+ },
748
+ body: "grant_type=client_credentials",
749
+ timeoutMs: opts.timeoutMs || PAYPAL_HTTP_TIMEOUT_MS,
750
+ agent: _PSP_TLS_AGENT,
751
+ });
752
+ var text = res.body && res.body.toString ? res.body.toString("utf8") : "";
753
+ var parsed; try { parsed = text.length ? JSON.parse(text) : {}; } catch (_e) { parsed = {}; }
754
+ if (res.statusCode < 200 || res.statusCode >= 300 || !parsed.access_token) {
755
+ var err = new Error("paypal: OAuth2 token exchange failed → HTTP " + res.statusCode +
756
+ (parsed && parsed.error_description ? " — " + parsed.error_description : ""));
757
+ err.code = "PAYPAL_AUTH_" + res.statusCode;
758
+ err.statusCode = res.statusCode;
759
+ throw err;
760
+ }
761
+ return parsed;
663
762
  });
664
- var text = res.body && res.body.toString ? res.body.toString("utf8") : "";
665
- var json; try { json = text.length ? JSON.parse(text) : {}; } catch (_e) { json = {}; }
666
- if (res.statusCode < 200 || res.statusCode >= 300 || !json.access_token) {
667
- var err = new Error("paypal: OAuth2 token exchange failed → HTTP " + res.statusCode +
668
- (json && json.error_description ? " — " + json.error_description : ""));
669
- err.code = "PAYPAL_AUTH_" + res.statusCode;
670
- err.statusCode = res.statusCode;
671
- throw err;
672
- }
673
763
  state.token = json.access_token;
674
764
  var ttlMs = (typeof json.expires_in === "number" ? json.expires_in : 0) * 1000; // allow:raw-time-literal — PayPal expires_in is a runtime seconds value; *1000 → ms
675
765
  state.tokenExpiresAt = now + Math.max(0, ttlMs - PAYPAL_TOKEN_SKEW_MS);
@@ -688,26 +778,34 @@ async function _paypalCall(opts, state, method, path, bodyObj, requestId) {
688
778
  var body = bodyObj != null ? JSON.stringify(bodyObj) : undefined;
689
779
  if (body) headers["content-length"] = Buffer.byteLength(body, "utf8");
690
780
  var httpClient = opts.httpClient || b.httpClient;
691
- var res = await httpClient.request({
692
- method: method,
693
- url: _paypalApiBase(opts) + path,
694
- headers: headers,
695
- body: body,
696
- timeoutMs: opts.timeoutMs || PAYPAL_HTTP_TIMEOUT_MS,
697
- agent: _PSP_TLS_AGENT,
781
+ // A GET is idempotent; a write is idempotent only when it carries a
782
+ // PayPal-Request-Id (PayPal dedupes a replay of the SAME id, and the
783
+ // same id rides every retry attempt within one call). A keyless write
784
+ // rides the breaker but not the retry.
785
+ var idempotent = method === "GET" || !!requestId;
786
+ var json = await _dial(opts._breaker, idempotent, async function () {
787
+ 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,
794
+ });
795
+ var text = res.body && res.body.toString ? res.body.toString("utf8") : "";
796
+ var parsed; try { parsed = text.length ? JSON.parse(text) : {}; } catch (_e) { parsed = { _raw: text }; }
797
+ if (res.statusCode < 200 || res.statusCode >= 300) {
798
+ var detail = parsed && (parsed.message || (parsed.details && parsed.details[0] && parsed.details[0].description)) || "";
799
+ var err = new Error("paypal: " + method + " " + path + " → HTTP " + res.statusCode + (detail ? " — " + detail : ""));
800
+ err.code = (parsed && parsed.name) || "PAYPAL_HTTP_" + res.statusCode;
801
+ err.statusCode = res.statusCode;
802
+ err.paypal = parsed || null;
803
+ throw err;
804
+ }
805
+ Object.defineProperty(parsed, "_paypalStatus", { value: res.statusCode, enumerable: false });
806
+ Object.defineProperty(parsed, "_paypalRawText", { value: text, enumerable: false });
807
+ return parsed;
698
808
  });
699
- var text = res.body && res.body.toString ? res.body.toString("utf8") : "";
700
- var json; try { json = text.length ? JSON.parse(text) : {}; } catch (_e) { json = { _raw: text }; }
701
- if (res.statusCode < 200 || res.statusCode >= 300) {
702
- var detail = json && (json.message || (json.details && json.details[0] && json.details[0].description)) || "";
703
- var err = new Error("paypal: " + method + " " + path + " → HTTP " + res.statusCode + (detail ? " — " + detail : ""));
704
- err.code = (json && json.name) || "PAYPAL_HTTP_" + res.statusCode;
705
- err.statusCode = res.statusCode;
706
- err.paypal = json || null;
707
- throw err;
708
- }
709
- Object.defineProperty(json, "_paypalStatus", { value: res.statusCode, enumerable: false });
710
- Object.defineProperty(json, "_paypalRawText", { value: text, enumerable: false });
711
809
  return json;
712
810
  }
713
811
 
@@ -721,6 +819,13 @@ function paypal(opts) {
721
819
  if (opts.now != null && typeof opts.now !== "function") {
722
820
  throw new TypeError("payment: now must be a function returning current epoch ms");
723
821
  }
822
+ // One circuit breaker per adapter instance, guarding every PayPal dial
823
+ // (the token exchange + every Orders-v2 call). Skippable in tests via
824
+ // `breaker: false`.
825
+ if (opts._breaker === undefined) {
826
+ opts._breaker = opts.breaker === false ? null : _makeBreaker("psp-paypal");
827
+ }
828
+
724
829
  var state = {
725
830
  query: opts.query || null,
726
831
  now: typeof opts.now === "function" ? opts.now : function () { return Date.now(); },
@@ -744,6 +849,10 @@ function paypal(opts) {
744
849
  return {
745
850
  name: "paypal",
746
851
 
852
+ // The per-adapter circuit breaker (or null when disabled). Same
853
+ // operator-dashboard surface as the Stripe adapter's.
854
+ breaker: opts._breaker,
855
+
747
856
  // Create an Orders-v2 order (intent CAPTURE). The returned `id` is the
748
857
  // PayPal order id the buyer approves; `captureOrder` finalizes it.
749
858
  createOrder: function (input, idempotencyKey) {
@@ -58,11 +58,19 @@ var _vendoredSecurityHeaders = require("./vendor/blamejs/lib/middleware/security
58
58
 
59
59
  var C = b.constants;
60
60
 
61
- // Payment webhooks are server-to-server POSTs from Stripe / PayPal:
62
- // cross-site by nature, unthrottleable by a per-IP human budget, and
63
- // already authenticated by an HMAC signature the edge + container both
64
- // verify. They are exempt from BOTH the rate limiters and fetch-metadata.
65
- var WEBHOOK_PATHS = ["/api/webhooks/stripe", "/api/webhooks/paypal"];
61
+ // Server-to-server webhooks: cross-site by nature, unthrottleable by a
62
+ // per-IP human budget, and each authenticated by its own gate the handler
63
+ // verifies first thing an HMAC signature (Stripe / PayPal) or a per-
64
+ // endpoint signing secret (the ESP bounce / complaint intake). They are
65
+ // exempt from the rate limiters, fetch-metadata, and the double-submit CSRF
66
+ // token (a third-party POST carries no session cookie or token). The
67
+ // `/api/` prefix also lands them in the vendored bot-guard's onlyForHtml
68
+ // skip, so the secret / signature gate is the deciding check.
69
+ var WEBHOOK_PATHS = [
70
+ "/api/webhooks/stripe",
71
+ "/api/webhooks/paypal",
72
+ "/api/webhooks/mail-bounce",
73
+ ];
66
74
 
67
75
  // Liveness / readiness probe — the container's Docker HEALTHCHECK hits
68
76
  // this on a fixed cadence; never rate-limit it or a slow cold start