@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/CHANGELOG.md +4 -0
- package/README.md +6 -1
- package/SECURITY.md +13 -0
- package/lib/admin.js +1328 -49
- package/lib/asset-manifest.json +5 -5
- package/lib/compliance-export.js +61 -7
- package/lib/customers.js +53 -0
- package/lib/cycle-counting.js +24 -4
- package/lib/gift-card-ledger.js +81 -10
- package/lib/giftcards.js +88 -0
- package/lib/inventory-allocations.js +33 -14
- package/lib/inventory-receive.js +116 -20
- package/lib/inventory-writeoffs.js +53 -64
- package/lib/loyalty-earn-rules.js +117 -0
- package/lib/loyalty.js +79 -0
- package/lib/newsletter.js +39 -2
- package/lib/operator-audit-log.js +20 -0
- package/lib/operator-inbox.js +202 -9
- package/lib/order.js +227 -27
- package/lib/payment.js +91 -18
- package/lib/quotes.js +107 -15
- package/lib/referrals.js +71 -0
- package/lib/security-middleware.js +33 -1
- package/lib/stock-transfers.js +185 -53
- package/lib/storefront.js +979 -126
- package/lib/translations.js +1 -0
- package/lib/webhook-receiver.js +15 -19
- package/lib/wishlist-alerts.js +37 -0
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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:
|
|
272
|
-
url:
|
|
273
|
-
headers:
|
|
274
|
-
body:
|
|
275
|
-
timeoutMs:
|
|
276
|
-
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:
|
|
749
|
-
timeoutMs:
|
|
750
|
-
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
|
-
|
|
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:
|
|
789
|
-
url:
|
|
790
|
-
headers:
|
|
791
|
-
body:
|
|
792
|
-
timeoutMs:
|
|
793
|
-
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
988
|
-
|
|
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
|
-
|
|
1149
|
-
|
|
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()
|
|
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
|
};
|