@blamejs/blamejs-shop 0.3.59 → 0.3.62
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/lib/asset-manifest.json +1 -1
- package/lib/auto-discount.js +10 -3
- package/lib/bundles.js +5 -2
- package/lib/email.js +10 -6
- package/lib/payment.js +20 -0
- package/lib/refund-policy.js +6 -4
- package/lib/security-middleware.js +7 -0
- package/lib/shipping-insurance.js +7 -3
- package/lib/stock-alerts.js +98 -39
- package/lib/storefront.js +77 -45
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.3.x
|
|
10
10
|
|
|
11
|
+
- v0.3.62 (2026-06-04) — **Live card payments work, and back-in-stock unsubscribe is token-gated.** Checkout failed at the charge step on Stripe-configured deploys: the framework's outbound HTTP client offers only post-quantum hybrid TLS key-exchange groups, and the Stripe and PayPal API edges do not negotiate any of them yet, so every processor call died with a TLS handshake failure (alert 40) and checkout rendered a server error. The payment module now dials the processors through an explicitly constructed agent — TLS 1.3 minimum, the platform's default key-exchange groups — which is the framework's prescribed pattern for a peer that cannot meet the post-quantum list: the downgrade is visible in the code, scoped to the processor connections only, and every other outbound connection keeps the post-quantum-first default. Verified against the live Stripe API. The downgrade reverts when the processor edges add hybrid key-exchange support. Separately, the checkout error page no longer echoes upstream failure detail (such as TLS library internals) to the customer — that detail goes to the audit log, and the page states plainly that nothing was charged. This release also token-gates back-in-stock unsubscribe: cancelling a back-in-stock alert previously took only the subscriber's email address and the product SKU — guessable values, so a third party could silently cancel someone else's restock notification. Unsubscribe is now authorized by an opaque per-subscription token, carried in the confirmation and notification emails as a one-click link, exactly like the newsletter's unsubscribe. Tokens are stored only as keyed hashes, compared in constant time, and unknown tokens get the same response as valid ones, so the endpoint is not a subscription-existence oracle. A schema migration adds the token column; it applies automatically on deploy. **Changed:** *Back-in-stock unsubscribe requires the emailed token* — POST /stock-alert/unsubscribe no longer accepts an email + SKU pair; it requires the per-subscription token from the unsubscribe link in the confirmation or back-in-stock email. The token is minted at subscribe time for the confirmation mail and rotated per notification send, stored only as a keyed hash, and verified with a constant-time compare; bad or unknown tokens return the same uniform response as valid ones. The one-click link works without cookies or a signed-in session, so mail clients can fire it directly; each email carries the token that is current for its send. Operators upgrading: migration 0207 adds the token column and applies with the normal deploy. **Fixed:** *Stripe and PayPal connections complete their TLS handshake* — The outbound HTTP client's default TLS posture offers exclusively post-quantum hybrid key-exchange groups. Processor API edges currently answer that ClientHello with handshake_failure (TLS alert 40), so the first real charge attempt on a Stripe-configured deploy failed checkout with a server error. The payment module now passes its own connection agent on every processor call — TLS 1.3 minimum is kept, key exchange falls back to the platform defaults — while the client's SSRF pinning, retries, and response caps stay in force. The agent is scoped to the payment-processor dials; all other outbound traffic keeps the post-quantum-first posture. A regression test pins the agent's presence and configuration on the charge path. · *Checkout server errors no longer echo upstream detail to the customer* — When the charge step failed, the checkout error page interpolated the underlying error string — in this case raw TLS library internals — into the customer-facing message. Server-side failure detail now goes to the audit log; the customer sees a plain recoverable message that confirms nothing was charged. Field-validation messages, which are written for customers, are unaffected.
|
|
12
|
+
|
|
13
|
+
- v0.3.60 (2026-06-04) — **Exact integer math for percentage discounts and insurance premiums, and an honest Add-a-card state under partial Stripe configuration.** Every place that computes a percentage of a money amount — automatic percent-off discounts, bundle discounts and their per-line allocation, partial-refund policy amounts, and shipping-insurance premiums — now uses exact integer arithmetic instead of a floating-point intermediate. On very large amounts the float product loses precision past 2^53 and can drift the rounded result by a minor unit; charged totals were never affected (the charge path was already integer end-to-end), but displayed and clamped values now match exact math everywhere, with each site's rounding direction preserved. Separately, the account Payment-methods screen no longer links into a 503 when the Stripe publishable key is missing: the Add-a-card affordance now renders as a disabled control with an explanatory note until the configuration is complete, matching how the unconfigured checkout behaves. **Fixed:** *Percentage-of-amount math is exact integer arithmetic* — Automatic percent-off discounts, bundle discounts, the cart's per-line bundle allocation, partial-refund policy amounts, and shipping-insurance premiums all computed `amount × basis_points / 10000` through a floating-point multiply. Amounts and rates are both integers, so the computation is now exact integer arithmetic with each site's rounding direction unchanged — half-away-from-zero for discounts, floor for allocations and refunds, ceiling for insurance premiums (the insurer is never short). The drift was only reachable on very large amounts (products beyond 2^53) and never touched a charged total, which was already integer end-to-end; tests now pin exact results on amounts past the float-precision boundary. · *Add a card renders honestly when Stripe configuration is incomplete* — Saving a card needs the Stripe publishable key in addition to the server-side API keys. With the server keys set but the publishable key missing, the Payment-methods screen rendered a live "Add a card" link that dead-ended on a 503. The link now gates on the same condition the add route enforces: when the publishable key is absent it renders as a disabled control with a note explaining the missing configuration, while listing, choosing a default, and removing saved cards keep working. The route's own 503 remains as defense in depth for a directly typed URL.
|
|
14
|
+
|
|
11
15
|
- v0.3.59 (2026-06-04) — **Back-in-stock subscribe rate-limited, payment-method metadata in privacy exports, and accessible wishlist alert toggles.** The anonymous back-in-stock "notify me" endpoint sends a confirmation email to the address the request supplies; it now sits in the same tight per-IP rate budget as login, checkout, and the newsletter, closing a victim-addressed email-flooding vector. Privacy (GDPR/CCPA) data exports now include saved-payment-method display metadata — card brand, last four, expiry — which is personal data the export was silently omitting. The wishlist alert and digest toggle buttons gain accessible names that include the alert they control, so a screen reader no longer announces a list of identical "Turn on" buttons. Internal housekeeping rides along: module imports hoisted to file tops, a per-test wall-clock-ceiling helper backing an existing test gate, and stale comments corrected. **Fixed:** *Back-in-stock subscribe joins the tight rate limit* — POST /stock-alert/subscribe is anonymous and sends a double-opt-in confirmation email to the request-supplied address. It previously sat only behind the loose global token bucket, so a script could direct a burst of confirmation emails at an arbitrary victim address — an email-flooding and sender-reputation risk. The route now shares the tight per-IP, per-path budget that already covers login, registration, checkout, gift-card balance, and the newsletter; the integration suite pins the throttle. · *Privacy exports include saved-payment-method metadata* — The subject-access (GDPR/CCPA) export composed every reader except saved payment methods, which was wired to an empty handle — an export silently omitted the card brand, last-four digits, and expiry the shop stores alongside the opaque processor token. The reader now receives the live handle, so a customer's export reflects everything held about them. Only display metadata is stored or exported — never card numbers. · *Wishlist alert toggles are screen-reader distinguishable* — The account wishlist-alerts screen rendered visually identical "Turn on" / "Turn off" / "Subscribe" buttons whose accessible names did not say which alert or digest they control. Each toggle now carries an accessible name that includes its alert label (for example "Turn off Price-drop alerts"), satisfying WCAG 2.4.6 for assistive-technology users walking the button list.
|
|
12
16
|
|
|
13
17
|
- v0.3.58 (2026-06-04) — **Browser form submissions no longer refused as cross-origin, server-side shipping-address validation, and a repaired checkout layout.** Every navigational form POST served by the container — checkout, magic-link sign-in, product reviews, returns, the account area, and the admin console — was refused with a 403 in real browsers. Pages shipped Referrer-Policy: no-referrer, which makes browsers send Origin: null on same-origin form submissions, and the CSRF gate's origin pre-check refuses a null Origin before the token is read. Both the edge and the container now send Referrer-Policy: same-origin: referrer information still never leaves the site, while same-origin posts carry the real Origin the gate verifies. Cross-site and sandboxed-iframe submissions are still refused. Checkout additionally gains backend shipping-address validation with accessible per-field errors that preserve everything the shopper typed, and the checkout page's layout is repaired — fields fill the form column instead of collapsing to its center, and the gift, store-pickup, and loyalty options render inside the form above the submit button. **Added:** *Server-side shipping-address validation with per-field errors* — The checkout POST validates the shipping address on the backend and, on rejection, re-renders the form with the one bad field marked (aria-invalid plus an adjacent role="alert" message) and every typed value preserved — replacing the previous dead-end error page. Country must be a real ISO 3166-1 alpha-2 code, validated against the platform's Intl region data (no new dependencies). US and Canadian destinations validate the state or province code and the ZIP (12345 or 12345-6789) / postal (A1A 1A1) format, and require street, city, region, and postal — the figures that feed destination tax. Every other country keeps lenient length-bounded checks, so international addresses are never over-validated. Email gains a shape check alongside the existing content-safety gate, and the customer name is length-bounded. Headless callers of checkout.confirm keep the presence-optional contract for digital-only orders; malformed values are now refused everywhere. **Fixed:** *Form posts from real browsers were refused as cross-origin (403)* — Pages were served with Referrer-Policy: no-referrer, which per the Fetch specification makes browsers serialize the Origin header as the literal string "null" on same-origin navigational POSTs. The CSRF gate's origin pre-check refuses a null Origin before the double-submit token is validated, so every container-rendered form returned {"error":"CSRF cross-origin request refused."} on submit — checkout, magic-link sign-in, reviews, returns, account self-service, and the admin console. JSON fetch() calls (passkeys, saved cards, the cart-count island) carry a real Origin and were unaffected. Both substrates now send Referrer-Policy: same-origin: no referrer information ever leaves the site (matching the previous policy's intent), same-origin posts carry a real Origin verified against the configured public origins, and cross-site or sandboxed-iframe posts still arrive as null and are still refused. The integration suite now drives the full Origin matrix — same-origin accepted, null and cross-origin refused — closing the gap between loopback tests (which send no Origin header) and real browsers. Operators composing their own securityHeaders options should not reinstate no-referrer: it re-breaks every tokened form post. · *Checkout layout: fields fill the column; gift and pickup options join the form* — A global form rule leaked align-items: center into the checkout's stacked form, collapsing every field to its content width in the middle of a wide column; the stacked form now stretches its rows. The gift-options, store-pickup, and loyalty-redeem blocks were spliced in after the submit button, rendering as an unstyled run of fields below the call to action — they now render inside the form above the submit row, framed as cards consistent with the order summary, with labels matching the shipping fields.
|
package/lib/asset-manifest.json
CHANGED
package/lib/auto-discount.js
CHANGED
|
@@ -954,9 +954,16 @@ function create(opts) {
|
|
|
954
954
|
}
|
|
955
955
|
}
|
|
956
956
|
}
|
|
957
|
-
// Half-away-from-zero round to integer minor.
|
|
958
|
-
|
|
959
|
-
|
|
957
|
+
// Half-away-from-zero round to integer minor. Exact BigInt
|
|
958
|
+
// math — base and basis_points are integers, so a JS float
|
|
959
|
+
// multiply would drift on large carts; the round happens once
|
|
960
|
+
// on the integer numerator. base is non-negative here (subtotal
|
|
961
|
+
// or a sum of unit_price*qty), so half-away equals half-up:
|
|
962
|
+
// floor((base*bps + 5000) / 10000).
|
|
963
|
+
var num = BigInt(base) * BigInt(rule.value.basis_points);
|
|
964
|
+
savingsMinor = num >= 0n
|
|
965
|
+
? Number((num + 5000n) / 10000n)
|
|
966
|
+
: -Number((-num + 5000n) / 10000n);
|
|
960
967
|
if (savingsMinor > base) savingsMinor = base;
|
|
961
968
|
} else if (rule.value.kind === "amount_off_total") {
|
|
962
969
|
savingsMinor = rule.value.minor;
|
package/lib/bundles.js
CHANGED
|
@@ -467,8 +467,11 @@ function create(opts) {
|
|
|
467
467
|
// Integer floor — basis points are 1/100 of a percent (10000 =
|
|
468
468
|
// 100%). The floor keeps the customer from paying a fractional
|
|
469
469
|
// cent and matches the rounding convention the pricing
|
|
470
|
-
// primitive uses for line totals.
|
|
471
|
-
|
|
470
|
+
// primitive uses for line totals. Exact BigInt math (listTotal
|
|
471
|
+
// and discountBps are integers) so the multiply can't drift on a
|
|
472
|
+
// large bundle; BigInt division truncates toward zero, which is
|
|
473
|
+
// floor for the non-negative inputs here.
|
|
474
|
+
discountMinor = Number((BigInt(listTotal) * BigInt(discountBps)) / 10000n);
|
|
472
475
|
}
|
|
473
476
|
var grandTotal = listTotal - discountMinor;
|
|
474
477
|
return {
|
package/lib/email.js
CHANGED
|
@@ -303,6 +303,7 @@ var STOCK_ALERT_CONFIRM_HTML =
|
|
|
303
303
|
" <p style=\"margin:0 0 16px;\">You asked to be emailed when <strong>{{product_title}}</strong> (SKU {{sku}}) is back in stock. Confirm below and we'll email you once, the moment it returns.</p>\n" +
|
|
304
304
|
" <p style=\"margin:24px 0;\"><a href=\"{{confirm_url}}\" style=\"background:#fa4f09;color:#ffffff;padding:12px 20px;text-decoration:none;display:inline-block;font-weight:bold;\">Confirm my alert</a></p>\n" +
|
|
305
305
|
" <p style=\"margin:0;color:#0d0d0d;font-size:13px;\">If you didn't request this, ignore this email — no alert is set until you confirm. Link: {{confirm_url}}</p>\n" +
|
|
306
|
+
" <p style=\"margin:16px 0 0;color:#0d0d0d;font-size:13px;\">Changed your mind? Cancel this alert: {{unsubscribe_url}}</p>\n" +
|
|
306
307
|
"</div>\n" +
|
|
307
308
|
"</body></html>\n";
|
|
308
309
|
|
|
@@ -311,7 +312,8 @@ var STOCK_ALERT_CONFIRM_TEXT =
|
|
|
311
312
|
"You asked to be emailed when {{product_title}} (SKU {{sku}}) is back in stock.\n" +
|
|
312
313
|
"Confirm to set the alert — we'll email you once, the moment it returns.\n\n" +
|
|
313
314
|
"Confirm: {{confirm_url}}\n\n" +
|
|
314
|
-
"If you didn't request this, ignore this email — no alert is set until you confirm.\n"
|
|
315
|
+
"If you didn't request this, ignore this email — no alert is set until you confirm.\n" +
|
|
316
|
+
"Changed your mind? Cancel this alert: {{unsubscribe_url}}\n";
|
|
315
317
|
|
|
316
318
|
var BACK_IN_STOCK_HTML =
|
|
317
319
|
"<!DOCTYPE html>\n" +
|
|
@@ -664,8 +666,9 @@ function create(opts) {
|
|
|
664
666
|
);
|
|
665
667
|
},
|
|
666
668
|
|
|
667
|
-
// Back-in-stock double-opt-in confirmation. `confirm_url`
|
|
668
|
-
// the route from SHOP_ORIGIN + the
|
|
669
|
+
// Back-in-stock double-opt-in confirmation. `confirm_url` +
|
|
670
|
+
// `unsubscribe_url` are built by the route from SHOP_ORIGIN + the
|
|
671
|
+
// plaintext confirm/unsubscribe tokens returned once by
|
|
669
672
|
// stockAlerts.subscribe. product_title / sku are catalog data; the
|
|
670
673
|
// strict renderer escapes every field anyway (escape-by-default).
|
|
671
674
|
sendStockAlertConfirmation: async function (input) {
|
|
@@ -677,9 +680,10 @@ function create(opts) {
|
|
|
677
680
|
throw new TypeError("email.sendStockAlertConfirmation: confirm_url required");
|
|
678
681
|
}
|
|
679
682
|
var vars = {
|
|
680
|
-
product_title:
|
|
681
|
-
sku:
|
|
682
|
-
confirm_url:
|
|
683
|
+
product_title: input.product_title == null ? input.sku || "this item" : String(input.product_title),
|
|
684
|
+
sku: input.sku == null ? "—" : String(input.sku),
|
|
685
|
+
confirm_url: input.confirm_url,
|
|
686
|
+
unsubscribe_url: input.unsubscribe_url == null ? "" : String(input.unsubscribe_url),
|
|
683
687
|
};
|
|
684
688
|
var html = _render(STOCK_ALERT_CONFIRM_HTML, vars);
|
|
685
689
|
var text = _render(STOCK_ALERT_CONFIRM_TEXT, vars);
|
package/lib/payment.js
CHANGED
|
@@ -23,9 +23,26 @@
|
|
|
23
23
|
* token can't smuggle unsigned events into the order pipeline.
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
+
var nodeHttps = require("node:https"); // allow:non-shop-require — the PSP TLS agent must be a real node https.Agent for the vendored httpClient's caller-agent (h1) path; no b.* primitive constructs one, and the framework's downgrade prescription is an explicitly-built agent in the consumer's diff
|
|
27
|
+
|
|
26
28
|
var b = require("./vendor/blamejs");
|
|
27
29
|
var C = b.constants;
|
|
28
30
|
|
|
31
|
+
// External payment processors do not negotiate the framework's PQC-hybrid
|
|
32
|
+
// TLS groups yet: the vendored httpClient offers ONLY ML-KEM hybrid groups
|
|
33
|
+
// (constants.TLS_GROUP_PREFERENCE), and api.stripe.com / api-m.paypal.com
|
|
34
|
+
// answer that ClientHello with handshake_failure (TLS alert 40) — which
|
|
35
|
+
// surfaced as a checkout failure the moment a charge was attempted. The
|
|
36
|
+
// framework's own prescription for a peer that can't meet the PQC list is
|
|
37
|
+
// an explicitly-constructed agent so the downgrade is visible in this
|
|
38
|
+
// diff: TLS 1.3 minimum stays pinned, the key-exchange groups fall back to
|
|
39
|
+
// Node's defaults (classical X25519 / P-256 today). The vendored client
|
|
40
|
+
// honors a caller agent per request (h1 path) while keeping its SSRF-
|
|
41
|
+
// pinned DNS lookup, retries, and response caps. Scoped to the PSP dials
|
|
42
|
+
// only — every other outbound keeps the PQC-first default. Revisit when
|
|
43
|
+
// the processor edges negotiate ML-KEM hybrid groups.
|
|
44
|
+
var _PSP_TLS_AGENT = new nodeHttps.Agent({ keepAlive: true, minVersion: "TLSv1.3" });
|
|
45
|
+
|
|
29
46
|
var STRIPE_API_BASE_DEFAULT = "https://api.stripe.com/v1";
|
|
30
47
|
var STRIPE_WEBHOOK_TOLERANCE = 300; // ± 5 minutes (Stripe default)
|
|
31
48
|
var STRIPE_HTTP_TIMEOUT_MS = 15000;
|
|
@@ -185,6 +202,7 @@ async function _stripeCall(opts, method, path, params, idempotencyKey) {
|
|
|
185
202
|
headers: headers,
|
|
186
203
|
body: body || undefined,
|
|
187
204
|
timeoutMs: opts.timeoutMs || STRIPE_HTTP_TIMEOUT_MS,
|
|
205
|
+
agent: _PSP_TLS_AGENT,
|
|
188
206
|
});
|
|
189
207
|
var text = res.body && res.body.toString ? res.body.toString("utf8") : "";
|
|
190
208
|
var json = null;
|
|
@@ -641,6 +659,7 @@ async function _paypalToken(opts, state) {
|
|
|
641
659
|
},
|
|
642
660
|
body: "grant_type=client_credentials",
|
|
643
661
|
timeoutMs: opts.timeoutMs || PAYPAL_HTTP_TIMEOUT_MS,
|
|
662
|
+
agent: _PSP_TLS_AGENT,
|
|
644
663
|
});
|
|
645
664
|
var text = res.body && res.body.toString ? res.body.toString("utf8") : "";
|
|
646
665
|
var json; try { json = text.length ? JSON.parse(text) : {}; } catch (_e) { json = {}; }
|
|
@@ -675,6 +694,7 @@ async function _paypalCall(opts, state, method, path, bodyObj, requestId) {
|
|
|
675
694
|
headers: headers,
|
|
676
695
|
body: body,
|
|
677
696
|
timeoutMs: opts.timeoutMs || PAYPAL_HTTP_TIMEOUT_MS,
|
|
697
|
+
agent: _PSP_TLS_AGENT,
|
|
678
698
|
});
|
|
679
699
|
var text = res.body && res.body.toString ? res.body.toString("utf8") : "";
|
|
680
700
|
var json; try { json = text.length ? JSON.parse(text) : {}; } catch (_e) { json = { _raw: text }; }
|
package/lib/refund-policy.js
CHANGED
|
@@ -736,10 +736,12 @@ function create(opts) {
|
|
|
736
736
|
if (p.refund_kind === "full" || p.refund_kind === "store_credit_only") {
|
|
737
737
|
maxRefundMinor = input.order_total_minor;
|
|
738
738
|
} else if (p.refund_kind === "partial") {
|
|
739
|
-
// floor(total * bps / 10000) —
|
|
740
|
-
// drift
|
|
741
|
-
//
|
|
742
|
-
|
|
739
|
+
// floor(total * bps / 10000) — exact BigInt math, no float
|
|
740
|
+
// drift on a large order. order_total_minor and bps are
|
|
741
|
+
// integers; BigInt division truncates toward zero (floor for
|
|
742
|
+
// these non-negative inputs). The bps gate (1..9999)
|
|
743
|
+
// guarantees the result is strictly less than the order total.
|
|
744
|
+
maxRefundMinor = Number((BigInt(input.order_total_minor) * BigInt(p.partial_refund_bps)) / 10000n);
|
|
743
745
|
} else /* no_refund */ {
|
|
744
746
|
maxRefundMinor = 0;
|
|
745
747
|
}
|
|
@@ -141,6 +141,13 @@ var EDGE_POST_PATHS = [
|
|
|
141
141
|
// the `edge-form-csrf-exempt` detector lookahead in
|
|
142
142
|
// test/layer-0-primitives/codebase-patterns.test.js.
|
|
143
143
|
"/stock-alert/subscribe",
|
|
144
|
+
// Back-in-stock one-click unsubscribe — same bearer-token model as
|
|
145
|
+
// /unsubscribe above. The opaque, per-row token in the `?token=` link IS
|
|
146
|
+
// the authorization; the POST a mail client fires carries no cookies and no
|
|
147
|
+
// session CSRF token, so the path is exempt from the double-submit check
|
|
148
|
+
// (it keeps SameSite + fetch-metadata defense and is enumeration-safe +
|
|
149
|
+
// timing-safe at the token layer).
|
|
150
|
+
"/stock-alert/unsubscribe",
|
|
144
151
|
];
|
|
145
152
|
|
|
146
153
|
// The TLS-terminated public origin(s) the storefront is served on. Behind
|
|
@@ -221,9 +221,13 @@ function _epochMs(n, label) {
|
|
|
221
221
|
// Integer-only; rounds up on half-cent so the insurer is never short.
|
|
222
222
|
|
|
223
223
|
function _computePremium(declaredValueMinor, premiumRateBps, premiumMinMinor) {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
224
|
+
// Exact BigInt ceil — declaredValueMinor and premiumRateBps are
|
|
225
|
+
// integers, so a JS Number multiply would lose precision once the
|
|
226
|
+
// product exceeds 2^53 (a large declared value at a high rate).
|
|
227
|
+
// ceil(product / 10000) = floor((product + 9999) / 10000) for the
|
|
228
|
+
// non-negative inputs here.
|
|
229
|
+
var product = BigInt(declaredValueMinor) * BigInt(premiumRateBps);
|
|
230
|
+
var quotient = Number((product + 9999n) / 10000n);
|
|
227
231
|
if (quotient < premiumMinMinor) { quotient = premiumMinMinor; }
|
|
228
232
|
return quotient;
|
|
229
233
|
}
|
package/lib/stock-alerts.js
CHANGED
|
@@ -36,9 +36,14 @@
|
|
|
36
36
|
* Composes:
|
|
37
37
|
* - `b.crypto.namespaceHash` — email hash + token hash.
|
|
38
38
|
* - `b.crypto.generateBytes` — 24 random bytes →
|
|
39
|
-
* base64url-encoded to a 32-character plaintext token
|
|
40
|
-
*
|
|
41
|
-
*
|
|
39
|
+
* base64url-encoded to a 32-character plaintext token, used for
|
|
40
|
+
* BOTH the double-opt-in confirmation token and the unsubscribe
|
|
41
|
+
* bearer token. Each plaintext is returned ONCE (confirm + unsub
|
|
42
|
+
* tokens from `subscribe`; a fresh unsub token per fired row from
|
|
43
|
+
* `scanAndNotify`); only the hashes persist.
|
|
44
|
+
* - `b.crypto.timingSafeEqual` — constant-time re-comparison of the
|
|
45
|
+
* stored unsubscribe-token hash against the recomputed hash so the
|
|
46
|
+
* unknown-token miss path can't be distinguished by latency.
|
|
42
47
|
* - `b.guardEmail` — strict-profile email validation +
|
|
43
48
|
* sanitization before normalisation.
|
|
44
49
|
* - `b.guardUuid` — optional customer_id / variant_id shape gate.
|
|
@@ -49,11 +54,13 @@
|
|
|
49
54
|
*
|
|
50
55
|
* @primitive stockAlerts
|
|
51
56
|
* @related b.crypto.namespaceHash, b.crypto.generateBytes,
|
|
52
|
-
* b.guardEmail, b.guardUuid,
|
|
57
|
+
* b.crypto.timingSafeEqual, b.guardEmail, b.guardUuid,
|
|
58
|
+
* notifications
|
|
53
59
|
*/
|
|
54
60
|
|
|
55
61
|
var EMAIL_NAMESPACE = "stock-alert-email";
|
|
56
62
|
var TOKEN_NAMESPACE = "stock-alert-token";
|
|
63
|
+
var UNSUB_NAMESPACE = "stock-alert-unsub-token";
|
|
57
64
|
var TOKEN_BYTES = 24; // 24 raw bytes → 32 base64url chars
|
|
58
65
|
var TOKEN_LEN = 32;
|
|
59
66
|
var TOKEN_RE = /^[A-Za-z0-9_-]{32}$/;
|
|
@@ -146,6 +153,10 @@ function _hashToken(plaintext) {
|
|
|
146
153
|
return b.crypto.namespaceHash(TOKEN_NAMESPACE, plaintext);
|
|
147
154
|
}
|
|
148
155
|
|
|
156
|
+
function _hashUnsubToken(plaintext) {
|
|
157
|
+
return b.crypto.namespaceHash(UNSUB_NAMESPACE, plaintext);
|
|
158
|
+
}
|
|
159
|
+
|
|
149
160
|
// ---- factory ------------------------------------------------------------
|
|
150
161
|
|
|
151
162
|
function create(opts) {
|
|
@@ -180,6 +191,7 @@ function create(opts) {
|
|
|
180
191
|
return {
|
|
181
192
|
EMAIL_NAMESPACE: EMAIL_NAMESPACE,
|
|
182
193
|
TOKEN_NAMESPACE: TOKEN_NAMESPACE,
|
|
194
|
+
UNSUB_NAMESPACE: UNSUB_NAMESPACE,
|
|
183
195
|
TOKEN_LEN: TOKEN_LEN,
|
|
184
196
|
EVENT_TYPE: EVENT_TYPE,
|
|
185
197
|
|
|
@@ -193,12 +205,14 @@ function create(opts) {
|
|
|
193
205
|
// Subscribe to the back-in-stock alert for a SKU (and optionally
|
|
194
206
|
// a specific variant). Returns:
|
|
195
207
|
// { id, status: "subscribed" | "already-pending" |
|
|
196
|
-
// "already-confirmed", confirmation_token?,
|
|
197
|
-
// expires_at }
|
|
198
|
-
// The plaintext `confirmation_token`
|
|
199
|
-
// brand-new subscription
|
|
200
|
-
//
|
|
201
|
-
//
|
|
208
|
+
// "already-confirmed", confirmation_token?, unsubscribe_token?,
|
|
209
|
+
// email_hash, expires_at }
|
|
210
|
+
// The plaintext `confirmation_token` and `unsubscribe_token` are
|
|
211
|
+
// returned ONLY on a brand-new subscription — the confirmation email
|
|
212
|
+
// carries both (the confirm link + a one-click unsubscribe link). Only
|
|
213
|
+
// their hashes persist. Re-subscriptions surface the existing row's
|
|
214
|
+
// status — operators that want to re-send the confirmation email mint
|
|
215
|
+
// a fresh subscription by calling `unsubscribeByToken` first.
|
|
202
216
|
subscribe: async function (input) {
|
|
203
217
|
if (!input || typeof input !== "object") {
|
|
204
218
|
throw new TypeError("stockAlerts.subscribe: input object required");
|
|
@@ -237,23 +251,26 @@ function create(opts) {
|
|
|
237
251
|
await query("DELETE FROM stock_alerts WHERE id = ?1", [existing.id]);
|
|
238
252
|
}
|
|
239
253
|
|
|
240
|
-
var token
|
|
241
|
-
var tokenHash
|
|
242
|
-
var
|
|
243
|
-
var
|
|
254
|
+
var token = _mintToken();
|
|
255
|
+
var tokenHash = _hashToken(token);
|
|
256
|
+
var unsubToken = _mintToken();
|
|
257
|
+
var unsubHash = _hashUnsubToken(unsubToken);
|
|
258
|
+
var id = b.uuid.v7();
|
|
259
|
+
var expiresAt = now + ttlMs;
|
|
244
260
|
|
|
245
261
|
await query(
|
|
246
262
|
"INSERT INTO stock_alerts " +
|
|
247
263
|
"(id, email_hash, email_normalised, sku, variant_id, customer_id, " +
|
|
248
|
-
" confirmation_token_hash, confirmed_at, notified_at, expires_at, created_at) " +
|
|
249
|
-
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, NULL, ?
|
|
250
|
-
[id, emailHash, emailNorm, sku, variantId, customerId, tokenHash, expiresAt, now],
|
|
264
|
+
" confirmation_token_hash, unsubscribe_token_hash, confirmed_at, notified_at, expires_at, created_at) " +
|
|
265
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, NULL, NULL, ?9, ?10)",
|
|
266
|
+
[id, emailHash, emailNorm, sku, variantId, customerId, tokenHash, unsubHash, expiresAt, now],
|
|
251
267
|
);
|
|
252
268
|
return {
|
|
253
269
|
id: id,
|
|
254
270
|
status: "subscribed",
|
|
255
271
|
email_hash: emailHash,
|
|
256
272
|
confirmation_token: token,
|
|
273
|
+
unsubscribe_token: unsubToken,
|
|
257
274
|
expires_at: expiresAt,
|
|
258
275
|
};
|
|
259
276
|
},
|
|
@@ -293,23 +310,45 @@ function create(opts) {
|
|
|
293
310
|
return row;
|
|
294
311
|
},
|
|
295
312
|
|
|
296
|
-
// Unsubscribe by
|
|
297
|
-
// unsubscribe link
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
313
|
+
// Unsubscribe by the per-row bearer token carried in the email's
|
|
314
|
+
// unsubscribe link. The token IS the authorization — knowing a
|
|
315
|
+
// victim's email + a SKU is no longer enough to cancel their alert,
|
|
316
|
+
// only the opaque token the shop emailed them is. Resolves the row by
|
|
317
|
+
// the token's namespaceHash and deletes it.
|
|
318
|
+
//
|
|
319
|
+
// Enumeration-safe by construction: a bad-shape, empty, or unknown
|
|
320
|
+
// token returns the SAME `{ removed: false }` shape a valid-but-
|
|
321
|
+
// already-removed token does (idempotent re-click), and the miss path
|
|
322
|
+
// burns the same constant-time comparison as the hit path so the
|
|
323
|
+
// unknown-vs-known distinction doesn't leak through response latency.
|
|
324
|
+
// Never throws on a token argument — the public route renders a
|
|
325
|
+
// uniform "you're unsubscribed" page for every outcome.
|
|
326
|
+
unsubscribeByToken: async function (plaintext) {
|
|
327
|
+
if (typeof plaintext !== "string" || !TOKEN_RE.test(plaintext)) {
|
|
328
|
+
// Bad shape can't match any stored hash — short-circuit to the
|
|
329
|
+
// same no-op the unknown-token path returns. No oracle.
|
|
330
|
+
return { removed: false };
|
|
303
331
|
}
|
|
304
|
-
var
|
|
305
|
-
var
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
332
|
+
var unsubHash = _hashUnsubToken(plaintext);
|
|
333
|
+
var row = (await query(
|
|
334
|
+
"SELECT id, unsubscribe_token_hash FROM stock_alerts " +
|
|
335
|
+
"WHERE unsubscribe_token_hash = ?1 LIMIT 1",
|
|
336
|
+
[unsubHash],
|
|
337
|
+
)).rows[0];
|
|
338
|
+
if (!row) {
|
|
339
|
+
// Miss path — burn the same comparison work as the hit path so
|
|
340
|
+
// the latency profile matches (the compared values are equal by
|
|
341
|
+
// construction; the result is discarded).
|
|
342
|
+
b.crypto.timingSafeEqual(unsubHash, unsubHash);
|
|
343
|
+
return { removed: false };
|
|
344
|
+
}
|
|
345
|
+
// Constant-time check of the stored hash against the recomputed
|
|
346
|
+
// hash — belt-and-braces against any future change to the index
|
|
347
|
+
// lookup. A mismatch reads as a miss (uniform no-op).
|
|
348
|
+
if (!b.crypto.timingSafeEqual(row.unsubscribe_token_hash, unsubHash)) {
|
|
349
|
+
return { removed: false };
|
|
350
|
+
}
|
|
351
|
+
var r = await query("DELETE FROM stock_alerts WHERE id = ?1", [row.id]);
|
|
313
352
|
return { removed: Number(r.rowCount || 0) > 0 };
|
|
314
353
|
},
|
|
315
354
|
|
|
@@ -391,11 +430,18 @@ function create(opts) {
|
|
|
391
430
|
// Operator-driven sweeper. Walks every pending (confirmed +
|
|
392
431
|
// un-notified + un-expired) subscription whose SKU now has
|
|
393
432
|
// catalog.inventory.stock_on_hand - stock_held > 0 and:
|
|
394
|
-
// 1.
|
|
395
|
-
//
|
|
433
|
+
// 1. mints a fresh unsubscribe bearer token, rotating the row's
|
|
434
|
+
// unsubscribe_token_hash so the notification email's one-click
|
|
435
|
+
// unsubscribe link is a live, single-purpose handle (the prior
|
|
436
|
+
// confirmation-email token is superseded — a notified row is
|
|
437
|
+
// terminal so its older link is moot anyway),
|
|
438
|
+
// 2. stamps notified_at on the row, and
|
|
439
|
+
// 3. (if notifications is wired) enqueues an in-app message
|
|
396
440
|
// keyed by the recipient's email_hash.
|
|
397
441
|
// Returns `{ scanned, notified, enqueued, rows }` — `rows` is the
|
|
398
|
-
// shape that fired, post-update
|
|
442
|
+
// shape that fired, post-update, each carrying the plaintext
|
|
443
|
+
// `unsubscribe_token` (returned ONCE here, for the email builder to
|
|
444
|
+
// embed; only the hash persists). The sweeper is idempotent at the
|
|
399
445
|
// row level: a row that's already been notified is skipped.
|
|
400
446
|
scanAndNotify: async function (sweepOpts) {
|
|
401
447
|
sweepOpts = sweepOpts || {};
|
|
@@ -453,11 +499,23 @@ function create(opts) {
|
|
|
453
499
|
var rows = bySku[skuKey];
|
|
454
500
|
for (var k = 0; k < rows.length; k += 1) {
|
|
455
501
|
var row = rows[k];
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
502
|
+
// Mint + rotate a fresh unsubscribe bearer for this fired row.
|
|
503
|
+
// The plaintext rides out on the row object (the email builder
|
|
504
|
+
// embeds it); only the hash is durable. The `notified_at IS
|
|
505
|
+
// NULL` guard makes the update a claim: when two sweeps race
|
|
506
|
+
// the same pending row, exactly one wins. The loser MUST skip
|
|
507
|
+
// the row — its freshly minted token never persisted, so an
|
|
508
|
+
// email built from it would carry a dead unsubscribe link, and
|
|
509
|
+
// pushing the row would double-notify the subscriber.
|
|
510
|
+
var rowUnsubToken = _mintToken();
|
|
511
|
+
var claim = await query(
|
|
512
|
+
"UPDATE stock_alerts SET notified_at = ?1, unsubscribe_token_hash = ?2 " +
|
|
513
|
+
"WHERE id = ?3 AND notified_at IS NULL",
|
|
514
|
+
[now, _hashUnsubToken(rowUnsubToken), row.id],
|
|
459
515
|
);
|
|
516
|
+
if (!claim || Number(claim.rowCount) !== 1) continue;
|
|
460
517
|
row.notified_at = now;
|
|
518
|
+
row.unsubscribe_token = rowUnsubToken;
|
|
461
519
|
fired.push(row);
|
|
462
520
|
if (notifications) {
|
|
463
521
|
try {
|
|
@@ -541,5 +599,6 @@ module.exports = {
|
|
|
541
599
|
create: create,
|
|
542
600
|
EMAIL_NAMESPACE: EMAIL_NAMESPACE,
|
|
543
601
|
TOKEN_NAMESPACE: TOKEN_NAMESPACE,
|
|
602
|
+
UNSUB_NAMESPACE: UNSUB_NAMESPACE,
|
|
544
603
|
TOKEN_LEN: TOKEN_LEN,
|
|
545
604
|
};
|
package/lib/storefront.js
CHANGED
|
@@ -8156,17 +8156,17 @@ function renderStockAlertConfirm(opts) {
|
|
|
8156
8156
|
});
|
|
8157
8157
|
}
|
|
8158
8158
|
|
|
8159
|
-
// The unsubscribe confirm page (GET). A no-JS form POSTs the
|
|
8160
|
-
//
|
|
8161
|
-
// stop these alerts" step (no link-prefetcher
|
|
8162
|
-
// rides in hidden
|
|
8163
|
-
//
|
|
8164
|
-
//
|
|
8159
|
+
// The unsubscribe confirm page (GET). A no-JS form POSTs the opaque,
|
|
8160
|
+
// single-purpose bearer token back to /stock-alert/unsubscribe — a
|
|
8161
|
+
// deliberate "yes, stop these alerts" step (no link-prefetcher
|
|
8162
|
+
// unsubscribe). The token rides in a hidden field (HTML-escaped) and is
|
|
8163
|
+
// the only handle — no email address or SKU is shown or carried, so the
|
|
8164
|
+
// page reveals nothing about the subscription. The token IS the
|
|
8165
|
+
// authorization (the action is in EDGE_POST_PATHS — CSRF-exempt — exactly
|
|
8166
|
+
// like the newsletter one-click unsubscribe), so no session is needed.
|
|
8165
8167
|
function renderStockAlertUnsubscribeConfirm(opts) {
|
|
8166
8168
|
opts = opts || {};
|
|
8167
|
-
var
|
|
8168
|
-
var sku = typeof opts.sku === "string" ? opts.sku : "";
|
|
8169
|
-
var variantId = typeof opts.variant_id === "string" ? opts.variant_id : "";
|
|
8169
|
+
var token = typeof opts.token === "string" ? opts.token : "";
|
|
8170
8170
|
var body =
|
|
8171
8171
|
"<section class=\"newsletter-thanks\">" +
|
|
8172
8172
|
"<div class=\"newsletter-thanks__card\">" +
|
|
@@ -8174,9 +8174,7 @@ function renderStockAlertUnsubscribeConfirm(opts) {
|
|
|
8174
8174
|
"<h1 class=\"newsletter-thanks__title\">Stop this alert?</h1>" +
|
|
8175
8175
|
"<p class=\"newsletter-thanks__lede\">Confirm below and we'll cancel your back-in-stock alert for this item. You can set it again any time from the product page.</p>" +
|
|
8176
8176
|
"<form class=\"newsletter-unsub__form\" method=\"post\" action=\"/stock-alert/unsubscribe\">" +
|
|
8177
|
-
"<input type=\"hidden\" name=\"
|
|
8178
|
-
"<input type=\"hidden\" name=\"sku\" value=\"" + _escAttr(sku) + "\">" +
|
|
8179
|
-
(variantId ? "<input type=\"hidden\" name=\"variant_id\" value=\"" + _escAttr(variantId) + "\">" : "") +
|
|
8177
|
+
"<input type=\"hidden\" name=\"token\" value=\"" + _escAttr(token) + "\">" +
|
|
8180
8178
|
"<div class=\"newsletter-thanks__cta\">" +
|
|
8181
8179
|
"<button type=\"submit\" class=\"btn-primary\">Stop this alert</button>" +
|
|
8182
8180
|
"<a href=\"/\" class=\"btn-ghost\">Keep me subscribed</a>" +
|
|
@@ -9240,6 +9238,16 @@ function renderPaymentMethods(opts) {
|
|
|
9240
9238
|
var inner = rowsHtml
|
|
9241
9239
|
? "<ul class=\"pm-list\">" + rowsHtml + "</ul>"
|
|
9242
9240
|
: "<div class=\"account-empty\"><p class=\"account-empty__lede\">No saved cards yet. Add one for faster checkout.</p></div>";
|
|
9241
|
+
// "Add a card" CTA — only a live link when the add flow can actually
|
|
9242
|
+
// complete (the Stripe publishable key drives the SetupIntent Payment
|
|
9243
|
+
// Element on the add page). Absent it, render a disabled control + an
|
|
9244
|
+
// honest note instead of a link that dead-ends on the add page's 503.
|
|
9245
|
+
// Back-compat: callers that don't pass the flag keep the live link.
|
|
9246
|
+
var addCardAvailable = opts.add_card_available !== false;
|
|
9247
|
+
var ctaHtml = addCardAvailable
|
|
9248
|
+
? "<a class=\"btn-primary\" href=\"/account/payment-methods/add\">Add a card</a>"
|
|
9249
|
+
: "<button type=\"button\" class=\"btn-primary\" disabled aria-disabled=\"true\">Add a card</button>" +
|
|
9250
|
+
"<p class=\"form-notice form-notice--warn\" role=\"status\">Adding a card isn't available on this store yet — card storage isn't fully configured. Your saved cards still work for checkout.</p>";
|
|
9243
9251
|
var body =
|
|
9244
9252
|
"<section class=\"account-payment-methods\">" +
|
|
9245
9253
|
"<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
|
|
@@ -9250,7 +9258,7 @@ function renderPaymentMethods(opts) {
|
|
|
9250
9258
|
success + notice +
|
|
9251
9259
|
inner +
|
|
9252
9260
|
"<div class=\"account-payment-methods__cta\">" +
|
|
9253
|
-
|
|
9261
|
+
ctaHtml +
|
|
9254
9262
|
"</div>" +
|
|
9255
9263
|
"</section>";
|
|
9256
9264
|
return _wrap({
|
|
@@ -12415,13 +12423,24 @@ function mount(router, deps) {
|
|
|
12415
12423
|
// fix; anything else is a server-side failure. Either way, render a
|
|
12416
12424
|
// styled, recoverable page rather than raw text — back to the cart,
|
|
12417
12425
|
// or back to the shipping form to re-enter the address.
|
|
12426
|
+
// Server-side failure detail (a PSP / TLS / upstream error string)
|
|
12427
|
+
// is operator material, not customer copy — it goes to the audit
|
|
12428
|
+
// sink; the page gets the generic recoverable message. TypeError
|
|
12429
|
+
// messages are this module's own validator prose and stay inline.
|
|
12418
12430
|
var clientErr = (e instanceof TypeError);
|
|
12431
|
+
if (!clientErr) {
|
|
12432
|
+
b.audit.safeEmit({
|
|
12433
|
+
action: "storefront.checkout.confirm.error",
|
|
12434
|
+
outcome: "failure",
|
|
12435
|
+
metadata: { message: msg },
|
|
12436
|
+
});
|
|
12437
|
+
}
|
|
12419
12438
|
return _send(res, clientErr ? 400 : 500, renderCheckoutError({
|
|
12420
12439
|
shop_name: shopName, theme: theme, eyebrow: "Checkout",
|
|
12421
12440
|
title_text: clientErr ? "We couldn't process your shipping details" : "Checkout didn't go through",
|
|
12422
12441
|
reason: clientErr
|
|
12423
12442
|
? "Some shipping details couldn't be read: " + msg + " Go back and check the address fields."
|
|
12424
|
-
: "Something went wrong completing your order. Your cart is saved — please try again
|
|
12443
|
+
: "Something went wrong completing your order. Your cart is saved and nothing was charged — please try again in a moment.",
|
|
12425
12444
|
back_href: "/checkout", back_label: "Edit shipping",
|
|
12426
12445
|
secondary_href: "/cart", secondary_label: "Back to cart",
|
|
12427
12446
|
}));
|
|
@@ -13609,11 +13628,16 @@ function mount(router, deps) {
|
|
|
13609
13628
|
if (okKind === "archived") successCopy = "Card removed.";
|
|
13610
13629
|
if (okKind === "exists") successCopy = "That card is already on file.";
|
|
13611
13630
|
_send(res, code || 200, renderPaymentMethods({
|
|
13612
|
-
payment_methods:
|
|
13613
|
-
notice:
|
|
13614
|
-
success:
|
|
13615
|
-
|
|
13616
|
-
|
|
13631
|
+
payment_methods: rows,
|
|
13632
|
+
notice: notice || null,
|
|
13633
|
+
success: successCopy,
|
|
13634
|
+
// The add-card page needs the Stripe publishable key for the
|
|
13635
|
+
// SetupIntent Payment Element; without it the add route 503s,
|
|
13636
|
+
// so render the disabled "Add a card" state rather than a link
|
|
13637
|
+
// into that dead end.
|
|
13638
|
+
add_card_available: !!deps.stripe_publishable_key,
|
|
13639
|
+
shop_name: shopName,
|
|
13640
|
+
cart_count: cartCount,
|
|
13617
13641
|
}));
|
|
13618
13642
|
}
|
|
13619
13643
|
|
|
@@ -17463,7 +17487,11 @@ function mount(router, deps) {
|
|
|
17463
17487
|
for (var m = 0; m < members.length; m += 1) {
|
|
17464
17488
|
var mem = members[m];
|
|
17465
17489
|
var share = listTotal > 0
|
|
17466
|
-
|
|
17490
|
+
// Exact BigInt floor — bundleTotal and list_line are integer
|
|
17491
|
+
// minor units; their product can exceed 2^53 on a large
|
|
17492
|
+
// bundle, so a JS float multiply would drift. BigInt division
|
|
17493
|
+
// truncates toward zero (floor for these non-negative values).
|
|
17494
|
+
? Number((BigInt(bundleTotal) * BigInt(mem.list_line)) / BigInt(listTotal))
|
|
17467
17495
|
: Math.floor(bundleTotal / members.length);
|
|
17468
17496
|
mem.alloc_line = share;
|
|
17469
17497
|
allocated += share;
|
|
@@ -17723,6 +17751,9 @@ function mount(router, deps) {
|
|
|
17723
17751
|
if (result && result.status === "subscribed" && result.confirmation_token && deps.email) {
|
|
17724
17752
|
var origin = _stockAlertOrigin(req);
|
|
17725
17753
|
var confirmUrl = origin + "/stock-alert/confirm/" + encodeURIComponent(result.confirmation_token);
|
|
17754
|
+
// One-click unsubscribe link — the per-row bearer token IS the
|
|
17755
|
+
// authorization (no email/sku tuple in the URL to guess).
|
|
17756
|
+
var unsubscribeUrl = origin + "/stock-alert/unsubscribe?token=" + encodeURIComponent(result.unsubscribe_token);
|
|
17726
17757
|
// Resolve the product title for the SKU (cheap indexed read; drop-
|
|
17727
17758
|
// silent → fall back to the SKU as the title).
|
|
17728
17759
|
var titleForSku = body.sku;
|
|
@@ -17734,10 +17765,11 @@ function mount(router, deps) {
|
|
|
17734
17765
|
} catch (_e) { /* drop-silent — fall back to the SKU as the title */ }
|
|
17735
17766
|
try {
|
|
17736
17767
|
await deps.email.sendStockAlertConfirmation({
|
|
17737
|
-
to:
|
|
17738
|
-
product_title:
|
|
17739
|
-
sku:
|
|
17740
|
-
confirm_url:
|
|
17768
|
+
to: body.email,
|
|
17769
|
+
product_title: titleForSku,
|
|
17770
|
+
sku: body.sku,
|
|
17771
|
+
confirm_url: confirmUrl,
|
|
17772
|
+
unsubscribe_url: unsubscribeUrl,
|
|
17741
17773
|
});
|
|
17742
17774
|
} catch (_mailErr) { /* drop-silent — the row is written; the cron still fires on confirm */ }
|
|
17743
17775
|
}
|
|
@@ -17782,9 +17814,13 @@ function mount(router, deps) {
|
|
|
17782
17814
|
}));
|
|
17783
17815
|
});
|
|
17784
17816
|
|
|
17785
|
-
// Unsubscribe — by
|
|
17786
|
-
//
|
|
17787
|
-
//
|
|
17817
|
+
// Unsubscribe — by the opaque, single-purpose bearer token carried in
|
|
17818
|
+
// the email's `?token=` link (the same shape as the newsletter
|
|
17819
|
+
// one-click unsubscribe). The token IS the authorization, so knowing a
|
|
17820
|
+
// victim's email + a SKU is no longer enough to cancel their alert.
|
|
17821
|
+
// GET renders a friendly confirm page (no link-prefetcher unsubscribe);
|
|
17822
|
+
// POST validates the token and deletes the row. The route is CSRF-exempt
|
|
17823
|
+
// (EDGE_POST_PATHS) — the token is the bearer, there's no session.
|
|
17788
17824
|
router.get("/stock-alert/unsubscribe", async function (req, res) {
|
|
17789
17825
|
var q = (req.query && typeof req.query === "object") ? req.query : {};
|
|
17790
17826
|
var cartCount = 0;
|
|
@@ -17792,36 +17828,32 @@ function mount(router, deps) {
|
|
|
17792
17828
|
return _send(res, 200, renderStockAlertUnsubscribeConfirm({
|
|
17793
17829
|
shop_name: shopName,
|
|
17794
17830
|
cart_count: cartCount,
|
|
17795
|
-
|
|
17796
|
-
sku: typeof q.sku === "string" ? q.sku : "",
|
|
17797
|
-
variant_id: typeof q.variant_id === "string" ? q.variant_id : "",
|
|
17831
|
+
token: typeof q.token === "string" ? q.token : "",
|
|
17798
17832
|
}));
|
|
17799
17833
|
});
|
|
17800
17834
|
|
|
17801
17835
|
router.post("/stock-alert/unsubscribe", async function (req, res) {
|
|
17802
17836
|
var body = req.body || {};
|
|
17837
|
+
var token = typeof body.token === "string" ? body.token : "";
|
|
17803
17838
|
var cartCount = 0;
|
|
17804
17839
|
try { cartCount = await _cartCountForReq(req); } catch (_e) { /* drop-silent — empty cart fallback */ }
|
|
17805
17840
|
var outcome;
|
|
17806
17841
|
try {
|
|
17807
|
-
//
|
|
17808
|
-
//
|
|
17809
|
-
|
|
17810
|
-
|
|
17811
|
-
|
|
17812
|
-
variant_id: (body.variant_id != null && body.variant_id !== "") ? body.variant_id : null,
|
|
17813
|
-
});
|
|
17842
|
+
// `unsubscribeByToken` never throws on a token argument and returns
|
|
17843
|
+
// a uniform `{ removed }` for valid / unknown / bad-shape tokens —
|
|
17844
|
+
// no subscription-existence oracle. Every outcome renders the same
|
|
17845
|
+
// "you're unsubscribed" copy (re-clicking the link twice is fine).
|
|
17846
|
+
await deps.stockAlerts.unsubscribeByToken(token);
|
|
17814
17847
|
outcome = "unsubscribed";
|
|
17815
17848
|
} catch (e) {
|
|
17816
|
-
//
|
|
17817
|
-
//
|
|
17849
|
+
// Only a real infrastructure fault (D1 unreachable) lands here —
|
|
17850
|
+
// record it and render the generic non-leaking page rather than a
|
|
17851
|
+
// 500 that exposes internals on a public, unauthenticated route.
|
|
17818
17852
|
outcome = "invalid";
|
|
17819
|
-
|
|
17820
|
-
|
|
17821
|
-
|
|
17822
|
-
|
|
17823
|
-
});
|
|
17824
|
-
}
|
|
17853
|
+
_log.error("storefront stock-alert unsubscribe failed", {
|
|
17854
|
+
route: "/stock-alert/unsubscribe", request_id: (req && req.requestId) || null,
|
|
17855
|
+
err: (e && e.message) || String(e),
|
|
17856
|
+
});
|
|
17825
17857
|
}
|
|
17826
17858
|
return _send(res, 200, renderStockAlertUnsubscribeResult({
|
|
17827
17859
|
shop_name: shopName, cart_count: cartCount, outcome: outcome,
|
package/package.json
CHANGED