@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.
Files changed (85) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +39 -2
  3. package/SECURITY.md +14 -0
  4. package/lib/asset-manifest.json +1 -1
  5. package/lib/email.js +169 -0
  6. package/lib/storefront.js +368 -0
  7. package/lib/vendor/MANIFEST.json +84 -72
  8. package/lib/vendor/blamejs/CHANGELOG.md +6 -0
  9. package/lib/vendor/blamejs/README.md +3 -3
  10. package/lib/vendor/blamejs/SECURITY.md +5 -0
  11. package/lib/vendor/blamejs/api-snapshot.json +15 -3
  12. package/lib/vendor/blamejs/lib/agent-orchestrator.js +10 -4
  13. package/lib/vendor/blamejs/lib/ai-prompt.js +1 -1
  14. package/lib/vendor/blamejs/lib/app-shutdown.js +28 -0
  15. package/lib/vendor/blamejs/lib/archive-read.js +215 -16
  16. package/lib/vendor/blamejs/lib/archive.js +206 -52
  17. package/lib/vendor/blamejs/lib/auth/oauth.js +58 -0
  18. package/lib/vendor/blamejs/lib/auth/oid4vci.js +84 -27
  19. package/lib/vendor/blamejs/lib/breach-deadline.js +166 -1
  20. package/lib/vendor/blamejs/lib/cloud-events.js +3 -1
  21. package/lib/vendor/blamejs/lib/codepoint-class.js +21 -0
  22. package/lib/vendor/blamejs/lib/db-schema.js +120 -3
  23. package/lib/vendor/blamejs/lib/db.js +10 -3
  24. package/lib/vendor/blamejs/lib/error-page.js +93 -9
  25. package/lib/vendor/blamejs/lib/external-db.js +164 -13
  26. package/lib/vendor/blamejs/lib/guard-email.js +36 -3
  27. package/lib/vendor/blamejs/lib/http-client.js +37 -7
  28. package/lib/vendor/blamejs/lib/mail-auth.js +554 -55
  29. package/lib/vendor/blamejs/lib/mail-send-deliver.js +15 -5
  30. package/lib/vendor/blamejs/lib/mail-sieve.js +2 -1
  31. package/lib/vendor/blamejs/lib/middleware/ai-act-disclosure.js +88 -19
  32. package/lib/vendor/blamejs/lib/middleware/api-encrypt.js +58 -11
  33. package/lib/vendor/blamejs/lib/middleware/asyncapi-serve.js +56 -4
  34. package/lib/vendor/blamejs/lib/middleware/attach-user.js +45 -10
  35. package/lib/vendor/blamejs/lib/middleware/body-parser.js +70 -14
  36. package/lib/vendor/blamejs/lib/middleware/csp-report.js +30 -2
  37. package/lib/vendor/blamejs/lib/middleware/deny-response.js +29 -9
  38. package/lib/vendor/blamejs/lib/middleware/openapi-serve.js +56 -4
  39. package/lib/vendor/blamejs/lib/middleware/scim-server.js +301 -14
  40. package/lib/vendor/blamejs/lib/openapi-paths-builder.js +105 -29
  41. package/lib/vendor/blamejs/lib/openapi.js +225 -100
  42. package/lib/vendor/blamejs/lib/problem-details.js +15 -3
  43. package/lib/vendor/blamejs/lib/queue-local.js +148 -38
  44. package/lib/vendor/blamejs/lib/queue.js +41 -11
  45. package/lib/vendor/blamejs/lib/render.js +21 -3
  46. package/lib/vendor/blamejs/lib/router.js +13 -6
  47. package/lib/vendor/blamejs/lib/safe-buffer.js +55 -0
  48. package/lib/vendor/blamejs/lib/sse.js +7 -5
  49. package/lib/vendor/blamejs/lib/static.js +46 -17
  50. package/lib/vendor/blamejs/lib/uri-template.js +3 -1
  51. package/lib/vendor/blamejs/package.json +1 -1
  52. package/lib/vendor/blamejs/release-notes/v0.14.17.json +57 -0
  53. package/lib/vendor/blamejs/release-notes/v0.14.18.json +127 -0
  54. package/lib/vendor/blamejs/release-notes/v0.14.19.json +61 -0
  55. package/lib/vendor/blamejs/test/00-primitives.js +151 -0
  56. package/lib/vendor/blamejs/test/layer-0-primitives/agent-orchestrator.test.js +18 -0
  57. package/lib/vendor/blamejs/test/layer-0-primitives/api-encrypt.test.js +86 -0
  58. package/lib/vendor/blamejs/test/layer-0-primitives/app-shutdown.test.js +58 -0
  59. package/lib/vendor/blamejs/test/layer-0-primitives/archive-read.test.js +201 -0
  60. package/lib/vendor/blamejs/test/layer-0-primitives/archive.test.js +179 -0
  61. package/lib/vendor/blamejs/test/layer-0-primitives/asyncapi.test.js +19 -0
  62. package/lib/vendor/blamejs/test/layer-0-primitives/attach-user-bearer-scheme.test.js +154 -0
  63. package/lib/vendor/blamejs/test/layer-0-primitives/body-parser-chunked-malformed.test.js +10 -8
  64. package/lib/vendor/blamejs/test/layer-0-primitives/body-parser-smuggling.test.js +99 -20
  65. package/lib/vendor/blamejs/test/layer-0-primitives/breach-deadline.test.js +85 -0
  66. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +50 -1
  67. package/lib/vendor/blamejs/test/layer-0-primitives/compliance-ai-act.test.js +63 -0
  68. package/lib/vendor/blamejs/test/layer-0-primitives/csp-report.test.js +107 -2
  69. package/lib/vendor/blamejs/test/layer-0-primitives/db-schema-drift.test.js +145 -0
  70. package/lib/vendor/blamejs/test/layer-0-primitives/deny-response.test.js +32 -0
  71. package/lib/vendor/blamejs/test/layer-0-primitives/external-db-hardening.test.js +119 -0
  72. package/lib/vendor/blamejs/test/layer-0-primitives/federation-vc-suite.test.js +121 -1
  73. package/lib/vendor/blamejs/test/layer-0-primitives/guard-email.test.js +14 -0
  74. package/lib/vendor/blamejs/test/layer-0-primitives/http-client-stream.test.js +53 -0
  75. package/lib/vendor/blamejs/test/layer-0-primitives/mail-auth.test.js +179 -5
  76. package/lib/vendor/blamejs/test/layer-0-primitives/mail-send-deliver.test.js +45 -0
  77. package/lib/vendor/blamejs/test/layer-0-primitives/oauth-callback.test.js +80 -0
  78. package/lib/vendor/blamejs/test/layer-0-primitives/openapi.test.js +177 -0
  79. package/lib/vendor/blamejs/test/layer-0-primitives/queue-byo-db.test.js +312 -0
  80. package/lib/vendor/blamejs/test/layer-0-primitives/scim-server.test.js +165 -2
  81. package/lib/vendor/blamejs/test/layer-0-primitives/sse.test.js +33 -1
  82. package/lib/vendor/blamejs/test/layer-0-primitives/static.test.js +59 -0
  83. package/lib/wishlist-alerts.js +22 -0
  84. package/lib/wishlist-digest.js +18 -0
  85. 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 &amp; 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 -----------------------------------------------