@blamejs/blamejs-shop 0.3.55 → 0.3.57
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 +39 -2
- package/SECURITY.md +14 -0
- package/lib/asset-manifest.json +1 -1
- package/lib/email.js +169 -0
- package/lib/storefront.js +368 -0
- package/lib/vendor/MANIFEST.json +84 -72
- package/lib/vendor/blamejs/CHANGELOG.md +6 -0
- package/lib/vendor/blamejs/README.md +3 -3
- package/lib/vendor/blamejs/SECURITY.md +5 -0
- package/lib/vendor/blamejs/api-snapshot.json +15 -3
- package/lib/vendor/blamejs/lib/agent-orchestrator.js +10 -4
- package/lib/vendor/blamejs/lib/ai-prompt.js +1 -1
- package/lib/vendor/blamejs/lib/app-shutdown.js +28 -0
- package/lib/vendor/blamejs/lib/archive-read.js +215 -16
- package/lib/vendor/blamejs/lib/archive.js +206 -52
- package/lib/vendor/blamejs/lib/auth/oauth.js +58 -0
- package/lib/vendor/blamejs/lib/auth/oid4vci.js +84 -27
- package/lib/vendor/blamejs/lib/breach-deadline.js +166 -1
- package/lib/vendor/blamejs/lib/cloud-events.js +3 -1
- package/lib/vendor/blamejs/lib/codepoint-class.js +21 -0
- package/lib/vendor/blamejs/lib/db-schema.js +120 -3
- package/lib/vendor/blamejs/lib/db.js +10 -3
- package/lib/vendor/blamejs/lib/error-page.js +93 -9
- package/lib/vendor/blamejs/lib/external-db.js +164 -13
- package/lib/vendor/blamejs/lib/guard-email.js +36 -3
- package/lib/vendor/blamejs/lib/http-client.js +37 -7
- package/lib/vendor/blamejs/lib/mail-auth.js +554 -55
- package/lib/vendor/blamejs/lib/mail-send-deliver.js +15 -5
- package/lib/vendor/blamejs/lib/mail-sieve.js +2 -1
- package/lib/vendor/blamejs/lib/middleware/ai-act-disclosure.js +88 -19
- package/lib/vendor/blamejs/lib/middleware/api-encrypt.js +58 -11
- package/lib/vendor/blamejs/lib/middleware/asyncapi-serve.js +56 -4
- package/lib/vendor/blamejs/lib/middleware/attach-user.js +45 -10
- package/lib/vendor/blamejs/lib/middleware/body-parser.js +70 -14
- package/lib/vendor/blamejs/lib/middleware/csp-report.js +30 -2
- package/lib/vendor/blamejs/lib/middleware/deny-response.js +29 -9
- package/lib/vendor/blamejs/lib/middleware/openapi-serve.js +56 -4
- package/lib/vendor/blamejs/lib/middleware/scim-server.js +301 -14
- package/lib/vendor/blamejs/lib/openapi-paths-builder.js +105 -29
- package/lib/vendor/blamejs/lib/openapi.js +225 -100
- package/lib/vendor/blamejs/lib/problem-details.js +15 -3
- package/lib/vendor/blamejs/lib/queue-local.js +148 -38
- package/lib/vendor/blamejs/lib/queue.js +41 -11
- package/lib/vendor/blamejs/lib/render.js +21 -3
- package/lib/vendor/blamejs/lib/router.js +13 -6
- package/lib/vendor/blamejs/lib/safe-buffer.js +55 -0
- package/lib/vendor/blamejs/lib/sse.js +7 -5
- package/lib/vendor/blamejs/lib/static.js +46 -17
- package/lib/vendor/blamejs/lib/uri-template.js +3 -1
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.14.17.json +57 -0
- package/lib/vendor/blamejs/release-notes/v0.14.18.json +127 -0
- package/lib/vendor/blamejs/release-notes/v0.14.19.json +61 -0
- package/lib/vendor/blamejs/test/00-primitives.js +151 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/agent-orchestrator.test.js +18 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/api-encrypt.test.js +86 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/app-shutdown.test.js +58 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/archive-read.test.js +201 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/archive.test.js +179 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/asyncapi.test.js +19 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/attach-user-bearer-scheme.test.js +154 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/body-parser-chunked-malformed.test.js +10 -8
- package/lib/vendor/blamejs/test/layer-0-primitives/body-parser-smuggling.test.js +99 -20
- package/lib/vendor/blamejs/test/layer-0-primitives/breach-deadline.test.js +85 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +50 -1
- package/lib/vendor/blamejs/test/layer-0-primitives/compliance-ai-act.test.js +63 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/csp-report.test.js +107 -2
- package/lib/vendor/blamejs/test/layer-0-primitives/db-schema-drift.test.js +145 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/deny-response.test.js +32 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/external-db-hardening.test.js +119 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/federation-vc-suite.test.js +121 -1
- package/lib/vendor/blamejs/test/layer-0-primitives/guard-email.test.js +14 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/http-client-stream.test.js +53 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-auth.test.js +179 -5
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-send-deliver.test.js +45 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/oauth-callback.test.js +80 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/openapi.test.js +177 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/queue-byo-db.test.js +312 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/scim-server.test.js +165 -2
- package/lib/vendor/blamejs/test/layer-0-primitives/sse.test.js +33 -1
- package/lib/vendor/blamejs/test/layer-0-primitives/static.test.js +59 -0
- package/lib/wishlist-alerts.js +22 -0
- package/lib/wishlist-digest.js +18 -0
- package/package.json +1 -1
package/lib/storefront.js
CHANGED
|
@@ -2805,6 +2805,11 @@ function renderWishlist(opts) {
|
|
|
2805
2805
|
// sharing primitive isn't wired, or a unit test calling the renderer
|
|
2806
2806
|
// directly) the panel is empty so the page renders unchanged.
|
|
2807
2807
|
var sharePanel = opts.share_panel || "";
|
|
2808
|
+
// Alert + digest opt-in panel (server-rendered, no client JS). The
|
|
2809
|
+
// route builds it (it owns the per-trigger / per-schedule prefs reads);
|
|
2810
|
+
// absent it (the alerts/digest primitives aren't wired, or a unit test
|
|
2811
|
+
// calling the renderer directly) the panel is empty.
|
|
2812
|
+
var prefsPanel = opts.prefs_panel || "";
|
|
2808
2813
|
var body =
|
|
2809
2814
|
"<section class=\"account-wishlist\">" +
|
|
2810
2815
|
"<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
|
|
@@ -2813,6 +2818,7 @@ function renderWishlist(opts) {
|
|
|
2813
2818
|
"</ol></nav>" +
|
|
2814
2819
|
"<h1 class=\"account-wishlist__title\">Saved items</h1>" +
|
|
2815
2820
|
notice +
|
|
2821
|
+
prefsPanel +
|
|
2816
2822
|
sharePanel +
|
|
2817
2823
|
inner +
|
|
2818
2824
|
"</section>";
|
|
@@ -2898,6 +2904,81 @@ function _wishlistSharePanel(opts) {
|
|
|
2898
2904
|
"</section>";
|
|
2899
2905
|
}
|
|
2900
2906
|
|
|
2907
|
+
// The wishlist alert + digest opt-in panel (mounts on /account/wishlist
|
|
2908
|
+
// when the alerts / digest primitives are wired). Server-rendered, no
|
|
2909
|
+
// client JS: each toggle is a `<form method="post">` whose hidden `on`
|
|
2910
|
+
// field flips on submit (the submit button carries the new state). The
|
|
2911
|
+
// `_csrf` token is injected automatically by the `_wrap` form chokepoint
|
|
2912
|
+
// (_injectCsrfFields) like every other container POST form — the panel
|
|
2913
|
+
// emits no token itself.
|
|
2914
|
+
//
|
|
2915
|
+
// opts.alerts — [{ trigger, label, subscribed }] (subscribed drives
|
|
2916
|
+
// the button's on/off action)
|
|
2917
|
+
// opts.digests — [{ slug, label, enrolled, enrollment_id }]
|
|
2918
|
+
// opts.notice — "alerts" | "digest" → a saved confirmation
|
|
2919
|
+
//
|
|
2920
|
+
// Every operator-controlled string (a schedule slug used as a label) is
|
|
2921
|
+
// escaped via the storefront `esc()` path.
|
|
2922
|
+
var WISHLIST_PREFS_NOTICES = {
|
|
2923
|
+
alerts: "Alert preferences saved.",
|
|
2924
|
+
digest: "Digest subscription updated.",
|
|
2925
|
+
};
|
|
2926
|
+
function _wishlistPrefsPanel(opts) {
|
|
2927
|
+
opts = opts || {};
|
|
2928
|
+
var esc = b.template.escapeHtml;
|
|
2929
|
+
var alerts = opts.alerts || [];
|
|
2930
|
+
var digests = opts.digests || [];
|
|
2931
|
+
if (alerts.length === 0 && digests.length === 0) return "";
|
|
2932
|
+
var noticeMsg = WISHLIST_PREFS_NOTICES[opts.notice];
|
|
2933
|
+
var notice = noticeMsg
|
|
2934
|
+
? "<p class=\"form-notice form-notice--ok\" role=\"status\">" + esc(noticeMsg) + "</p>"
|
|
2935
|
+
: "";
|
|
2936
|
+
|
|
2937
|
+
var alertRows = "";
|
|
2938
|
+
for (var i = 0; i < alerts.length; i += 1) {
|
|
2939
|
+
var a = alerts[i];
|
|
2940
|
+
var on = a.subscribed === true;
|
|
2941
|
+
// Submitting flips the state: a subscribed trigger posts on="" (off);
|
|
2942
|
+
// an unsubscribed one posts on="1".
|
|
2943
|
+
alertRows +=
|
|
2944
|
+
"<form class=\"wishlist-prefs__row\" method=\"post\" action=\"/account/wishlist/alerts\">" +
|
|
2945
|
+
"<input type=\"hidden\" name=\"trigger\" value=\"" + esc(a.trigger) + "\">" +
|
|
2946
|
+
"<input type=\"hidden\" name=\"on\" value=\"" + (on ? "" : "1") + "\">" +
|
|
2947
|
+
"<span class=\"wishlist-prefs__label\">" + esc(a.label || a.trigger) + "</span>" +
|
|
2948
|
+
"<span class=\"wishlist-prefs__state\">" + (on ? "On" : "Off") + "</span>" +
|
|
2949
|
+
"<button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">" + (on ? "Turn off" : "Turn on") + "</button>" +
|
|
2950
|
+
"</form>";
|
|
2951
|
+
}
|
|
2952
|
+
var alertsBlock = alertRows
|
|
2953
|
+
? "<h3 class=\"wishlist-prefs__subhead\">Price-drop & back-in-stock alerts</h3>" + alertRows
|
|
2954
|
+
: "";
|
|
2955
|
+
|
|
2956
|
+
var digestRows = "";
|
|
2957
|
+
for (var j = 0; j < digests.length; j += 1) {
|
|
2958
|
+
var d = digests[j];
|
|
2959
|
+
var enrolled = d.enrolled === true;
|
|
2960
|
+
digestRows +=
|
|
2961
|
+
"<form class=\"wishlist-prefs__row\" method=\"post\" action=\"/account/wishlist/digest\">" +
|
|
2962
|
+
"<input type=\"hidden\" name=\"schedule_slug\" value=\"" + esc(d.slug) + "\">" +
|
|
2963
|
+
"<input type=\"hidden\" name=\"on\" value=\"" + (enrolled ? "" : "1") + "\">" +
|
|
2964
|
+
"<span class=\"wishlist-prefs__label\">" + esc(d.label || d.slug) + "</span>" +
|
|
2965
|
+
"<span class=\"wishlist-prefs__state\">" + (enrolled ? "Subscribed" : "Not subscribed") + "</span>" +
|
|
2966
|
+
"<button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">" + (enrolled ? "Unsubscribe" : "Subscribe") + "</button>" +
|
|
2967
|
+
"</form>";
|
|
2968
|
+
}
|
|
2969
|
+
var digestBlock = digestRows
|
|
2970
|
+
? "<h3 class=\"wishlist-prefs__subhead\">Periodic wishlist digest</h3>" + digestRows
|
|
2971
|
+
: "";
|
|
2972
|
+
|
|
2973
|
+
return "<section class=\"wishlist-prefs-panel\" aria-labelledby=\"wishlist-prefs-heading\">" +
|
|
2974
|
+
"<h2 id=\"wishlist-prefs-heading\" class=\"wishlist-prefs-panel__title\">Wishlist notifications</h2>" +
|
|
2975
|
+
"<p class=\"wishlist-prefs-panel__lede\">Choose how you'd like to hear about your saved items. We'll only email you if your store has email delivery configured.</p>" +
|
|
2976
|
+
notice +
|
|
2977
|
+
alertsBlock +
|
|
2978
|
+
digestBlock +
|
|
2979
|
+
"</section>";
|
|
2980
|
+
}
|
|
2981
|
+
|
|
2901
2982
|
// Public, no-auth shared-wishlist page (`GET /wishlist/shared/:token`). It
|
|
2902
2983
|
// renders ONLY the shared product cards (title, image, link to each PDP) —
|
|
2903
2984
|
// the owner's identity, the per-entry private notes, and the owner customer
|
|
@@ -8602,6 +8683,7 @@ var ACCOUNT_LOGIN_PAGE =
|
|
|
8602
8683
|
" <p id=\"login-message\" class=\"auth-form__message\"></p>\n" +
|
|
8603
8684
|
" </form>\n" +
|
|
8604
8685
|
" RAW_LOGIN_OAUTH\n" +
|
|
8686
|
+
" RAW_LOGIN_MAGIC\n" +
|
|
8605
8687
|
" <p class=\"auth-card__alt\">New here? <a href=\"/account/register\">Create an account →</a></p>\n" +
|
|
8606
8688
|
" </div>\n" +
|
|
8607
8689
|
" RAW_LOGIN_SCRIPT\n" +
|
|
@@ -8610,6 +8692,7 @@ var ACCOUNT_LOGIN_PAGE =
|
|
|
8610
8692
|
var LOGIN_ERROR_MESSAGES = {
|
|
8611
8693
|
oauth: "We couldn't complete that sign-in. Please try again.",
|
|
8612
8694
|
"email-conflict": "That email already has an account — sign in with your passkey instead.",
|
|
8695
|
+
link: "That sign-in link is invalid or has expired. Request a fresh one.",
|
|
8613
8696
|
};
|
|
8614
8697
|
|
|
8615
8698
|
function renderAccountLogin(opts) {
|
|
@@ -8630,8 +8713,12 @@ function renderAccountLogin(opts) {
|
|
|
8630
8713
|
var errHtml = (opts.error && LOGIN_ERROR_MESSAGES[opts.error])
|
|
8631
8714
|
? "<p class=\"auth-form__message auth-form__message--err\">" + b.template.escapeHtml(LOGIN_ERROR_MESSAGES[opts.error]) + "</p>"
|
|
8632
8715
|
: "";
|
|
8716
|
+
var magicHtml = opts.magic_link_enabled
|
|
8717
|
+
? "<p class=\"auth-card__alt\"><a href=\"/account/login/link\">Email me a sign-in link instead →</a></p>"
|
|
8718
|
+
: "";
|
|
8633
8719
|
var body = ACCOUNT_LOGIN_PAGE
|
|
8634
8720
|
.replace("RAW_LOGIN_OAUTH", oauthHtml)
|
|
8721
|
+
.replace("RAW_LOGIN_MAGIC", magicHtml)
|
|
8635
8722
|
.replace("RAW_LOGIN_ERROR", errHtml)
|
|
8636
8723
|
// Login captcha is gated separately (CAPTCHA_GATE_LOGIN): the widget
|
|
8637
8724
|
// renders only when the operator has a provider active AND opted login
|
|
@@ -8648,6 +8735,50 @@ function renderAccountLogin(opts) {
|
|
|
8648
8735
|
});
|
|
8649
8736
|
}
|
|
8650
8737
|
|
|
8738
|
+
// Magic-link sign-in — a passwordless entry for shoppers without a
|
|
8739
|
+
// passkey or a social login. The page is a single email field that POSTs
|
|
8740
|
+
// to /account/login/link. The response is always the same enumeration-
|
|
8741
|
+
// safe confirmation ("if an account exists, we've emailed a link")
|
|
8742
|
+
// regardless of whether the address resolves — no account-existence
|
|
8743
|
+
// oracle. Server-rendered, no client JS.
|
|
8744
|
+
var ACCOUNT_MAGIC_LINK_PAGE =
|
|
8745
|
+
"<section class=\"auth-page\">\n" +
|
|
8746
|
+
" <div class=\"auth-card\">\n" +
|
|
8747
|
+
" <p class=\"eyebrow\">Sign in by email</p>\n" +
|
|
8748
|
+
" <h1 class=\"auth-card__title\">Email me a sign-in link</h1>\n" +
|
|
8749
|
+
" <p class=\"auth-card__lede\">No passkey or social login? Enter your email and we'll send a single-use link that signs you in.</p>\n" +
|
|
8750
|
+
" RAW_MAGIC_LINK_NOTICE\n" +
|
|
8751
|
+
" <form method=\"post\" action=\"/account/login/link\" class=\"form-stack auth-form\">\n" +
|
|
8752
|
+
" <div class=\"form-row\"><label class=\"form-field\"><span class=\"form-field__label\">Email</span><input type=\"email\" name=\"email\" required autocomplete=\"email\" autofocus></label></div>\n" +
|
|
8753
|
+
" <div class=\"form-actions\"><button type=\"submit\" class=\"btn-primary auth-form__submit\">Email me a link</button></div>\n" +
|
|
8754
|
+
" </form>\n" +
|
|
8755
|
+
" <p class=\"auth-card__alt\"><a href=\"/account/login\">← Back to sign in</a></p>\n" +
|
|
8756
|
+
" </div>\n" +
|
|
8757
|
+
"</section>\n";
|
|
8758
|
+
|
|
8759
|
+
function renderMagicLinkPage(opts) {
|
|
8760
|
+
opts = opts || {};
|
|
8761
|
+
var esc = b.template.escapeHtml;
|
|
8762
|
+
// The post-submit confirmation (sent=1) is the enumeration-safe message
|
|
8763
|
+
// — identical whether or not the address matched an account. The
|
|
8764
|
+
// unconfigured-mailer case carries its own honest notice.
|
|
8765
|
+
var noticeHtml = "";
|
|
8766
|
+
if (opts.sent) {
|
|
8767
|
+
noticeHtml = "<p class=\"form-notice form-notice--ok\" role=\"status\">If an account exists for that email, we've sent a sign-in link. Check your inbox.</p>";
|
|
8768
|
+
} else if (opts.unavailable) {
|
|
8769
|
+
noticeHtml = "<p class=\"auth-form__message auth-form__message--err\">Email sign-in isn't available on this store. Use a passkey or social login instead.</p>";
|
|
8770
|
+
}
|
|
8771
|
+
var body = ACCOUNT_MAGIC_LINK_PAGE.replace("RAW_MAGIC_LINK_NOTICE", noticeHtml);
|
|
8772
|
+
void esc;
|
|
8773
|
+
return _wrap({
|
|
8774
|
+
title: "Email sign-in",
|
|
8775
|
+
shop_name: opts.shop_name || "blamejs.shop",
|
|
8776
|
+
cart_count: opts.cart_count,
|
|
8777
|
+
theme_css: opts.theme_css,
|
|
8778
|
+
body: body,
|
|
8779
|
+
});
|
|
8780
|
+
}
|
|
8781
|
+
|
|
8651
8782
|
var ACCOUNT_REGISTER_PAGE =
|
|
8652
8783
|
"<section class=\"auth-page\">\n" +
|
|
8653
8784
|
" <div class=\"auth-card\">\n" +
|
|
@@ -12626,6 +12757,7 @@ function mount(router, deps) {
|
|
|
12626
12757
|
cart_count: cartCount,
|
|
12627
12758
|
google_enabled: !!deps.oauthGoogle,
|
|
12628
12759
|
apple_enabled: !!deps.oauthApple,
|
|
12760
|
+
magic_link_enabled: !!(deps.customerPortal && deps.customerPortalEmail),
|
|
12629
12761
|
error: url && url.searchParams.get("error"),
|
|
12630
12762
|
captcha_kind: captchaLoginOn ? captchaKind : null,
|
|
12631
12763
|
captcha_public_key: captchaLoginOn ? captchaPubKey : null,
|
|
@@ -12645,6 +12777,105 @@ function mount(router, deps) {
|
|
|
12645
12777
|
}));
|
|
12646
12778
|
});
|
|
12647
12779
|
|
|
12780
|
+
// ---- magic-link sign-in (passwordless email entry) ------------------
|
|
12781
|
+
//
|
|
12782
|
+
// A minimal passwordless login for shoppers without a passkey or a
|
|
12783
|
+
// social login. Composes the customer-portal primitive: createSession
|
|
12784
|
+
// mints a single-use, hashed-at-rest token; the link is emailed; the
|
|
12785
|
+
// GET redemption verifies (single-use) and sets the sealed shop_auth
|
|
12786
|
+
// cookie. The whole surface mounts only when BOTH the portal primitive
|
|
12787
|
+
// AND a transactional mailer are wired — absent either, /account/login/
|
|
12788
|
+
// link renders an "unavailable" state and passkey / OAuth are unchanged.
|
|
12789
|
+
if (deps.customerPortal && deps.customerPortalEmail) {
|
|
12790
|
+
// GET — the email-entry form.
|
|
12791
|
+
router.get("/account/login/link", async function (req, res) {
|
|
12792
|
+
var cartCount = await _cartCountForReq(req);
|
|
12793
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
12794
|
+
_send(res, 200, renderMagicLinkPage({
|
|
12795
|
+
shop_name: shopName,
|
|
12796
|
+
cart_count: cartCount,
|
|
12797
|
+
sent: url ? url.searchParams.get("sent") === "1" : false,
|
|
12798
|
+
}));
|
|
12799
|
+
});
|
|
12800
|
+
|
|
12801
|
+
// POST — resolve the email to a customer, mint a portal session,
|
|
12802
|
+
// email the link. The response is ALWAYS the same enumeration-safe
|
|
12803
|
+
// confirmation (sent=1) regardless of whether the address matched —
|
|
12804
|
+
// no account-existence oracle. A bad-shaped email re-renders the
|
|
12805
|
+
// form. Every send is best-effort: a mailer failure still shows the
|
|
12806
|
+
// generic confirmation (the link simply doesn't arrive).
|
|
12807
|
+
router.post("/account/login/link", async function (req, res) {
|
|
12808
|
+
var body = req.body || {};
|
|
12809
|
+
var emailRaw = typeof body.email === "string" ? body.email : "";
|
|
12810
|
+
var customerId = null;
|
|
12811
|
+
// Resolve the customer by email hash. A malformed address (hashEmail
|
|
12812
|
+
// throws on bad shape) or a no-match both fall through to the
|
|
12813
|
+
// generic confirmation — no oracle.
|
|
12814
|
+
try {
|
|
12815
|
+
var hash = deps.customers.hashEmail(emailRaw);
|
|
12816
|
+
var cust = await deps.customers.byEmailHash(hash);
|
|
12817
|
+
if (cust && cust.id) customerId = cust.id;
|
|
12818
|
+
} catch (_e) { customerId = null; }
|
|
12819
|
+
|
|
12820
|
+
if (customerId) {
|
|
12821
|
+
try {
|
|
12822
|
+
var minted = await deps.customerPortal.createSession({
|
|
12823
|
+
customer_id: customerId,
|
|
12824
|
+
scope: "full",
|
|
12825
|
+
});
|
|
12826
|
+
// Build the absolute redemption link from this request's origin.
|
|
12827
|
+
var origin = "";
|
|
12828
|
+
try { origin = new URL(_requestUrls(req).canonical_url).origin; }
|
|
12829
|
+
catch (_e2) { origin = ""; }
|
|
12830
|
+
var linkUrl = origin + "/account/portal/" + encodeURIComponent(minted.plaintext_token);
|
|
12831
|
+
// The customer's plaintext address: the portal flow needs a
|
|
12832
|
+
// deliverable address. The customers store keeps only the hash,
|
|
12833
|
+
// so reuse the submitted address (the customer just typed it);
|
|
12834
|
+
// the email handle validates the address shape.
|
|
12835
|
+
await deps.customerPortalEmail.sendMagicLink({
|
|
12836
|
+
customer_email: emailRaw,
|
|
12837
|
+
link_url: linkUrl,
|
|
12838
|
+
});
|
|
12839
|
+
} catch (_e3) { /* drop-silent — generic confirmation regardless */ }
|
|
12840
|
+
}
|
|
12841
|
+
// 303 to the GET with the generic confirmation flag.
|
|
12842
|
+
res.status(303);
|
|
12843
|
+
res.setHeader && res.setHeader("location", "/account/login/link?sent=1");
|
|
12844
|
+
return res.end ? res.end() : res.send("");
|
|
12845
|
+
});
|
|
12846
|
+
|
|
12847
|
+
// GET — redeem the magic-link token. verifyToken is single-use (flips
|
|
12848
|
+
// the row to consumed) and re-checks expiry; on success set the sealed
|
|
12849
|
+
// shop_auth cookie + 303 to /account. An unknown / expired / already-
|
|
12850
|
+
// used token bounces to login with a soft error (no oracle on why).
|
|
12851
|
+
router.get("/account/portal/:token", async function (req, res) {
|
|
12852
|
+
var token = (req.params && req.params.token) || "";
|
|
12853
|
+
var rv = null;
|
|
12854
|
+
try { rv = await deps.customerPortal.verifyToken(token); }
|
|
12855
|
+
catch (e) {
|
|
12856
|
+
if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
|
|
12857
|
+
rv = null;
|
|
12858
|
+
}
|
|
12859
|
+
if (!rv || !rv.customer_id) {
|
|
12860
|
+
res.status(303);
|
|
12861
|
+
res.setHeader && res.setHeader("location", "/account/login?error=link");
|
|
12862
|
+
return res.end ? res.end() : res.send("");
|
|
12863
|
+
}
|
|
12864
|
+
// Adopt the guest cart into the now-authenticated account, mirroring
|
|
12865
|
+
// the OAuth / passkey login paths.
|
|
12866
|
+
var sid = _readSidCookie(req);
|
|
12867
|
+
if (sid) {
|
|
12868
|
+
try {
|
|
12869
|
+
var anonCart = await deps.cart.bySession(sid);
|
|
12870
|
+
if (anonCart) await deps.cart.setCustomer(anonCart.id, rv.customer_id);
|
|
12871
|
+
} catch (_e) { /* best-effort merge; sign-in itself succeeds */ }
|
|
12872
|
+
}
|
|
12873
|
+
_setAuthCookie(req, res, { customer_id: rv.customer_id, exp: Date.now() + b.constants.TIME.days(14) });
|
|
12874
|
+
res.status(303); res.setHeader && res.setHeader("location", "/account");
|
|
12875
|
+
return res.end ? res.end() : res.send("");
|
|
12876
|
+
});
|
|
12877
|
+
}
|
|
12878
|
+
|
|
12648
12879
|
router.post("/account/passkey/register-begin", async function (req, res) {
|
|
12649
12880
|
try {
|
|
12650
12881
|
var body = _readJsonBody(req);
|
|
@@ -13754,15 +13985,152 @@ function mount(router, deps) {
|
|
|
13754
13985
|
notice: wlUrl ? wlUrl.searchParams.get("share") : null,
|
|
13755
13986
|
});
|
|
13756
13987
|
}
|
|
13988
|
+
// Alert + digest opt-in panel — only when those primitives are
|
|
13989
|
+
// wired (a mailer-configured deploy). The current per-trigger
|
|
13990
|
+
// opt-out + per-schedule enrollment state drives each toggle's
|
|
13991
|
+
// checked/unchecked render. A reads failure degrades to no panel
|
|
13992
|
+
// rather than 500-ing the page.
|
|
13993
|
+
var prefsPanel = await _buildWishlistPrefsPanel(
|
|
13994
|
+
auth.customer_id,
|
|
13995
|
+
wlUrl ? wlUrl.searchParams.get("prefs") : null,
|
|
13996
|
+
);
|
|
13757
13997
|
_send(res, 200, renderWishlist({
|
|
13758
13998
|
items: items,
|
|
13759
13999
|
notice: wlUrl ? wlUrl.searchParams.get("ok") : null,
|
|
14000
|
+
prefs_panel: prefsPanel,
|
|
13760
14001
|
share_panel: sharePanel,
|
|
13761
14002
|
shop_name: shopName,
|
|
13762
14003
|
cart_count: cartCount,
|
|
13763
14004
|
asset_prefix: deps.asset_prefix || "/assets/",
|
|
13764
14005
|
}));
|
|
13765
14006
|
});
|
|
14007
|
+
|
|
14008
|
+
// Build the alert + digest opt-in panel for a signed-in customer.
|
|
14009
|
+
// Reads the current per-trigger opt-out state (isUnsubscribedFrom
|
|
14010
|
+
// Trigger, now exported) for each scannable alert trigger, and the
|
|
14011
|
+
// active-enrollment state per live digest schedule. Each read is
|
|
14012
|
+
// best-effort: a missing table / read error drops that section
|
|
14013
|
+
// rather than throwing. Returns "" when neither primitive is wired.
|
|
14014
|
+
async function _buildWishlistPrefsPanel(customerId, notice) {
|
|
14015
|
+
var alerts = [];
|
|
14016
|
+
var digests = [];
|
|
14017
|
+
if (deps.wishlistAlerts) {
|
|
14018
|
+
var ALERT_TRIGGERS = [
|
|
14019
|
+
{ trigger: "price_drop", label: "Email me when a saved item drops in price" },
|
|
14020
|
+
{ trigger: "back_in_stock", label: "Email me when a saved item is back in stock" },
|
|
14021
|
+
];
|
|
14022
|
+
for (var i = 0; i < ALERT_TRIGGERS.length; i += 1) {
|
|
14023
|
+
var t = ALERT_TRIGGERS[i];
|
|
14024
|
+
var unsub = true;
|
|
14025
|
+
try { unsub = await deps.wishlistAlerts.isUnsubscribedFromTrigger(customerId, t.trigger); }
|
|
14026
|
+
catch (_e) { unsub = false; }
|
|
14027
|
+
alerts.push({ trigger: t.trigger, label: t.label, subscribed: !unsub });
|
|
14028
|
+
}
|
|
14029
|
+
}
|
|
14030
|
+
if (deps.wishlistDigest) {
|
|
14031
|
+
var schedules = [];
|
|
14032
|
+
try { schedules = await deps.wishlistDigest.listSchedules({ active_only: true }); }
|
|
14033
|
+
catch (_e) { schedules = []; }
|
|
14034
|
+
var enrollments = [];
|
|
14035
|
+
try { enrollments = await deps.wishlistDigest.enrollmentsForCustomer(customerId); }
|
|
14036
|
+
catch (_e) { enrollments = []; }
|
|
14037
|
+
var activeBySlug = {};
|
|
14038
|
+
for (var k = 0; k < enrollments.length; k += 1) {
|
|
14039
|
+
var en = enrollments[k];
|
|
14040
|
+
if (en.status === "active") activeBySlug[en.schedule_slug] = en.id;
|
|
14041
|
+
}
|
|
14042
|
+
for (var s = 0; s < schedules.length; s += 1) {
|
|
14043
|
+
var sch = schedules[s];
|
|
14044
|
+
var label = (sch.frequency === "weekly" ? "Weekly" : "Monthly") +
|
|
14045
|
+
" digest (" + sch.slug + ")";
|
|
14046
|
+
digests.push({
|
|
14047
|
+
slug: sch.slug,
|
|
14048
|
+
label: label,
|
|
14049
|
+
enrolled: Object.prototype.hasOwnProperty.call(activeBySlug, sch.slug),
|
|
14050
|
+
enrollment_id: activeBySlug[sch.slug] || null,
|
|
14051
|
+
});
|
|
14052
|
+
}
|
|
14053
|
+
}
|
|
14054
|
+
return _wishlistPrefsPanel({ alerts: alerts, digests: digests, notice: notice });
|
|
14055
|
+
}
|
|
14056
|
+
|
|
14057
|
+
// POST /account/wishlist/alerts — flip a per-trigger alert opt-out.
|
|
14058
|
+
// Body { trigger, on }: a falsey `on` unsubscribes (insert opt-out
|
|
14059
|
+
// row); a truthy `on` re-subscribes (delete the opt-out row). Gated
|
|
14060
|
+
// on the session customer; CSRF rides the container form chokepoint.
|
|
14061
|
+
if (deps.wishlistAlerts) {
|
|
14062
|
+
router.post("/account/wishlist/alerts", async function (req, res) {
|
|
14063
|
+
var auth;
|
|
14064
|
+
try { auth = _currentCustomer(req); }
|
|
14065
|
+
catch (e) {
|
|
14066
|
+
if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
|
|
14067
|
+
throw e;
|
|
14068
|
+
}
|
|
14069
|
+
if (!auth) {
|
|
14070
|
+
res.status(303); res.setHeader && res.setHeader("location", "/account/login");
|
|
14071
|
+
return res.end ? res.end() : res.send("");
|
|
14072
|
+
}
|
|
14073
|
+
var body = req.body || {};
|
|
14074
|
+
var trigger = body.trigger;
|
|
14075
|
+
var on = body.on === "1" || body.on === "on" || body.on === true;
|
|
14076
|
+
try {
|
|
14077
|
+
if (on) {
|
|
14078
|
+
await deps.wishlistAlerts.resubscribeToAlertKind({ customer_id: auth.customer_id, trigger: trigger });
|
|
14079
|
+
} else {
|
|
14080
|
+
await deps.wishlistAlerts.unsubscribeFromAlertKind({ customer_id: auth.customer_id, trigger: trigger });
|
|
14081
|
+
}
|
|
14082
|
+
} catch (e) {
|
|
14083
|
+
res.status(e instanceof TypeError ? 400 : 500);
|
|
14084
|
+
return res.end ? res.end((e && e.message) || "Error") : res.send((e && e.message) || "Error");
|
|
14085
|
+
}
|
|
14086
|
+
res.status(303); res.setHeader && res.setHeader("location", "/account/wishlist?prefs=alerts");
|
|
14087
|
+
return res.end ? res.end() : res.send("");
|
|
14088
|
+
});
|
|
14089
|
+
}
|
|
14090
|
+
|
|
14091
|
+
// POST /account/wishlist/digest — enroll / pause a digest schedule.
|
|
14092
|
+
// Body { schedule_slug, on }: a truthy `on` enrolls; a falsey `on`
|
|
14093
|
+
// pauses the active enrollment for that slug (looked up via
|
|
14094
|
+
// enrollmentsForCustomer). Gated on the session customer.
|
|
14095
|
+
if (deps.wishlistDigest) {
|
|
14096
|
+
router.post("/account/wishlist/digest", async function (req, res) {
|
|
14097
|
+
var auth;
|
|
14098
|
+
try { auth = _currentCustomer(req); }
|
|
14099
|
+
catch (e) {
|
|
14100
|
+
if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
|
|
14101
|
+
throw e;
|
|
14102
|
+
}
|
|
14103
|
+
if (!auth) {
|
|
14104
|
+
res.status(303); res.setHeader && res.setHeader("location", "/account/login");
|
|
14105
|
+
return res.end ? res.end() : res.send("");
|
|
14106
|
+
}
|
|
14107
|
+
var body = req.body || {};
|
|
14108
|
+
var scheduleSlug = body.schedule_slug;
|
|
14109
|
+
var on = body.on === "1" || body.on === "on" || body.on === true;
|
|
14110
|
+
try {
|
|
14111
|
+
if (on) {
|
|
14112
|
+
await deps.wishlistDigest.enrollCustomer({ customer_id: auth.customer_id, schedule_slug: scheduleSlug });
|
|
14113
|
+
} else {
|
|
14114
|
+
// Find the active enrollment for this slug and pause it.
|
|
14115
|
+
var enrollments = await deps.wishlistDigest.enrollmentsForCustomer(auth.customer_id);
|
|
14116
|
+
var target = null;
|
|
14117
|
+
for (var i = 0; i < enrollments.length; i += 1) {
|
|
14118
|
+
if (enrollments[i].schedule_slug === scheduleSlug && enrollments[i].status === "active") {
|
|
14119
|
+
target = enrollments[i]; break;
|
|
14120
|
+
}
|
|
14121
|
+
}
|
|
14122
|
+
if (target) {
|
|
14123
|
+
await deps.wishlistDigest.pauseEnrollment({ enrollment_id: target.id, reason: "customer opted out" });
|
|
14124
|
+
}
|
|
14125
|
+
}
|
|
14126
|
+
} catch (e) {
|
|
14127
|
+
res.status(e instanceof TypeError ? 400 : 500);
|
|
14128
|
+
return res.end ? res.end((e && e.message) || "Error") : res.send((e && e.message) || "Error");
|
|
14129
|
+
}
|
|
14130
|
+
res.status(303); res.setHeader && res.setHeader("location", "/account/wishlist?prefs=digest");
|
|
14131
|
+
return res.end ? res.end() : res.send("");
|
|
14132
|
+
});
|
|
14133
|
+
}
|
|
13766
14134
|
}
|
|
13767
14135
|
|
|
13768
14136
|
// ---- wishlist sharing -----------------------------------------------
|