@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.
- package/CHANGELOG.md +4 -0
- package/README.md +1 -0
- package/lib/admin.js +45 -0
- package/lib/asset-manifest.json +1 -1
- package/lib/checkout.js +70 -0
- package/lib/compliance-export.js +61 -4
- package/lib/customer-portal.js +23 -0
- package/lib/customer-segments.js +8 -11
- package/lib/customers.js +72 -0
- package/lib/email-campaigns.js +8 -1
- package/lib/operator-accounts.js +52 -1
- package/lib/operator-audit-log.js +166 -6
- package/lib/order-export.js +14 -17
- package/lib/payment.js +178 -69
- package/lib/security-middleware.js +13 -5
- package/lib/storefront.js +128 -4
- package/lib/support-tickets.js +113 -53
- package/package.json +1 -1
|
@@ -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:
|
|
597
|
-
listByActor:
|
|
598
|
-
listByResource:
|
|
599
|
-
searchAction:
|
|
600
|
-
chainHead:
|
|
601
|
-
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
|
};
|
package/lib/order-export.js
CHANGED
|
@@ -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
|
-
//
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
//
|
|
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
|
-
|
|
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)
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
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
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
|
|
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
|