@blamejs/blamejs-shop 0.4.12 → 0.4.14

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 CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.4.x
10
10
 
11
+ - v0.4.14 (2026-06-06) — **Gift wrap charges exactly the fee it displays, the per-order wrap cap is enforced, low-stock events reach subscribed webhooks, and the admin API accepts curl as documented.** Three storefront fixes and two admin-surface fixes. Gift wrap now charges the configured wrap fee rather than the wrap variant's catalog price, and a wrap's per-order cap is enforced at the cart with a clear message. The low-stock inventory event is now a registered webhook event, so endpoints subscribed to specific events receive it. The account profile explains plainly why an email address cannot be changed. And the admin JSON API now answers automation clients such as curl the way the documentation has always shown, with the bearer key as the deciding check. **Changed:** *The account profile explains why the email address cannot be changed* — The store keeps only a one-way hash of each account's email address, so an address can never be read back or rewritten — an email change is not possible by design. The profile screen now says this plainly where an email-change control would normally sit, and points to the alternatives: create a new account under the new address, or contact the store to reconcile order history. **Fixed:** *Gift wrap charges the configured fee, not the wrap variant's catalog price* — The gift-wrap selector displayed the wrap fee configured in the admin console, but checkout added the wrap to the order at the underlying catalog variant's price — when the two diverged, the customer was silently charged a different amount than the page showed. The cart now snapshots the configured fee onto the wrap line, so the displayed fee and the charged fee always agree. A regression test keeps the two amounts pinned together with deliberately different fee and catalog values. · *A gift wrap's per-order cap is enforced at the cart* — A wrap option's maximum-per-order value was validated, stored, and shown in the admin console but never enforced — any quantity could be applied. Applying a wrap beyond its cap is now rejected with a 422 and the cart page re-renders with a readable message; an application at the cap continues to succeed and the cart is left unchanged on rejection. · *The low-stock event is a registered webhook event* — Inventory low-stock alerts emitted `inventory.low_stock`, but the event was missing from the webhook event registry — endpoints subscribed to specific events never received it, and the admin endpoint form never offered it. The event is now registered, appears in the endpoint form's event checklist, and is delivered to endpoints that subscribe to it. Wildcard subscriptions, which already received it, are unchanged. · *Documented curl commands against the admin API work as written* — Every admin endpoint is bearer-key-gated JSON, and the documentation drives it with plain curl — but the bot guard's user-agent deny list refused automation clients on `/admin` before the key was ever checked, so the documented commands answered 403. The admin surface now runs the bot guard in tag mode: automation is recorded in the audit trail (`system.botguard.tag`) instead of refused, and the timing-safe bearer key remains the deciding check. A curl call with a valid key reaches the endpoint; one without a key is refused by the key check with a 401.
12
+
13
+ - v0.4.13 (2026-06-06) — **Security: a passkey can no longer be added to an existing account from the public register page, and the email sign-in link no longer reveals which addresses have accounts.** Two fixes to the account sign-in surface. The public passkey-registration endpoint reused an existing customer record when the submitted email already had an account, which let anyone who knew a registered email enroll their own authenticator onto that account and sign in as its owner. Registration now creates new accounts only: it refuses any email that already has an account — whether it signs in by passkey, by Google or Apple, or is a guest order awaiting a sign-in link — and directs the visitor to sign in instead. Separately, the email sign-in-link request did its account lookup, session creation, and email send before replying, so a registered address took measurably longer to answer than an unregistered one — a timing signal that revealed which addresses have accounts despite the identical on-screen confirmation. The reply is now sent first and the link is prepared and mailed afterward, so both cases answer at the same speed. **Security:** *Passkey registration can no longer enroll a credential onto someone else's account* — The public passkey-registration ceremony reused an existing customer record whenever the submitted email already had an account, then bound the registration challenge to that account — so a visitor who knew a registered email could complete the ceremony on their own device and be issued a sign-in session for the account's owner, without any proof of controlling the email. The path now creates new accounts only: it refuses any email that already has an account and binds no challenge to it, returning a clear "this email already has an account — sign in instead" response. The refusal covers every existing account, not only those with a passkey — a Google or Apple account and a guest order awaiting a sign-in link both have no passkey yet a real owner, and an attacker enrolling onto either would take it over just the same. An owner adding a second passkey continues to use the signed-in add-a-passkey flow, which has always required an active session and matches the challenge to the signed-in account; an owner who has no passkey, or whose first attempt was interrupted, signs in with the email link. · *The email sign-in link no longer leaks which addresses have accounts* — The "email me a sign-in link" request answered with the same confirmation whether or not the address matched an account, but it performed the account lookup, session creation, and email dispatch before replying when the address did match — so a registered address answered measurably more slowly than an unregistered one, a timing side channel that distinguished real accounts from non-accounts. The confirmation is now sent before any of that work; when the address matches an account, the sign-in link is prepared and mailed afterward, off the response path. A matched and an unmatched address now take the same time to answer, and the link still arrives for real accounts.
14
+
11
15
  - v0.4.12 (2026-06-05) — **Blog posts render their Markdown, plural search queries match again, and the search-ranking metrics view gets the impression and click data it was built to read.** Four search-and-content fixes. Published blog posts were showing their raw Markdown source — headings, bold, and links rendered as literal text — even though the authoring screen has always promised Markdown; the edge now renders it through the same sanitized renderer the post primitive uses, so the live page matches the editor's promise. The query stemmer over-stripped plurals ending in a vowel plus "es" ("tees", "shoes", "does"), so those searches missed their synonym groups and matched unrelated products; it now strips those down to the right singular. The search-ranking event log finally has production writers — every search records an impression and every result click records a click against the active weight set, so the admin search-metrics view shows real click-through and conversion instead of an empty table. And the analytics search funnel now applies the same hygiene the autocomplete log does, so "Popular searches" and the analytics top-terms report agree. **Added:** *Search-ranking impressions and clicks are recorded, so the admin metrics view has data* — The search-ranking feature ships a weight-set metrics view that reads an event log to report click-through, conversion, and click-to-purchase per weight set — but nothing ever wrote those events, so the view was always empty. Two writers are now wired. Every search records one impression against the active weight set (a drop-silent, fire-and-forget write that never affects the search response). Every search result link carries a `?from=search` marker plus the query it was ranked for; when a shopper follows one to a product page, that page records a click against the active weight set. Both work with JavaScript off — the click signal is read server-side from the link, with no tracking beacon. Facet narrowing is recorded the same way, so the facet-usage rollup reflects real use. Purchase attribution is deliberately left out of this release: tying a completed order back to the search that led to it needs a session-to-order link the event schema doesn't carry, so the metrics view reports impressions, clicks, and the click-through ratio from real traffic while purchase counts and conversion stay at zero until that linkage ships. **Fixed:** *Blog posts render Markdown instead of showing the raw source* — A published post's body is authored in Markdown — the editor labels the field "Body (Markdown)" and the post primitive's renderer turns headings, lists, blockquotes, rules, inline code, bold, italic, and https-or-rooted links into HTML. But the edge-served public post page painted the body as plain escaped text, so a shopper saw literal `## Heading` and `**bold**` markers rather than formatted copy. The edge now renders the body through the same sanitized renderer the primitive uses, so the live page matches what the editor previews and the authoring screen promises. The security posture is unchanged: every operator-authored byte is HTML-escaped, raw HTML never passes through (any `<` lands as `&lt;`), and link URLs are restricted to https or `/`-rooted paths — a `<script>` or `javascript:` link in a post body renders inert. · *Plural search queries ending in a vowel plus "es" match again* — The query stemmer tried its `-es` plural rule before its `-s` rule, so a word ending in a vowel plus "es" was over-stripped: "tees" became "te", "shoes" became "sho", "does" became "do". Two things broke as a result — the over-stripped term no longer matched its operator-curated synonym group (so a search for "tees" missed a "tee"/"t-shirt" grouping), and the truncated term fed a broad substring match that surfaced unrelated products. The stemmer now strips the full `-es` only when the stem ends in a sibilant ("boxes" → "box", "dishes" → "dish", "churches" → "church") and takes the bare `-s` otherwise ("tees" → "tee", "shoes" → "shoe", "does" → "doe"). The edge search mirror carries the identical rule, so the result is the same whether the page is served from the edge cache or the container. · *The analytics search funnel and the autocomplete log apply the same query hygiene* — Two search sinks recorded the typed query with different cleaning. The autocomplete query log stripped control bytes and lowercased before writing; the analytics funnel that feeds the top-search-terms report trimmed only — so a control byte could reach the analytics table raw, and the two reports counted differently. The storefront now normalizes the query once (strip control bytes, trim, lowercase) and hands the same value to both sinks. The analytics top-terms report also groups case-insensitively, so a term typed as "Hat" and "hat" collapses into one row — folding any mixed-case history written before this — matching how "Popular searches" has always counted. The consent gate on analytics recording is unchanged.
12
16
 
13
17
  - v0.4.11 (2026-06-05) — **Search autocomplete: a live suggestions dropdown in the header, plus an admin screen to curate it.** Typing in the storefront search box now opens an autocomplete dropdown with matching products, what other shoppers are searching for, and operator-curated featured links. A new admin screen pins those featured suggestions and surfaces a popular-searches report so you can see demand and spot terms that return nothing. The search box keeps working with JavaScript off — the dropdown is a progressive enhancement on top of the plain form. **Added:** *Search autocomplete in the storefront header* — As a shopper types in the header search box, a dropdown opens beneath it with up to three groups: matching products (linking straight to the product page), popular recent searches (click one to run it), and operator-curated featured suggestions (linking wherever you point them). It's keyboard-navigable — arrow keys move through the list, Enter picks, Escape dismisses — and announces itself to screen readers as a combobox. The dropdown is served from a cacheable JSON endpoint that carries no per-visitor data, and it's a pure enhancement: with JavaScript off, or before the data loads, the box stays an ordinary search form that submits to the results page. · *Search-suggestions admin screen* — A new Search suggestions screen under the admin console lets you curate featured suggestions: pin a typed prefix (for example, prefix "free" surfaces "Free shipping over $50"), set its destination link, priority, and an optional active window, then edit the priority or status inline or remove it. Below the curation table, a read-only Popular searches view reports what shoppers have typed over the last 30 days — each term's search count, its zero-result share, and when it was last seen — so a high zero-result term flags a stock or naming gap worth closing. Every search a shopper runs is logged for this report (the visitor's session identifier is hashed before storage), and entries older than 90 days are pruned automatically.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.12",
2
+ "version": "0.4.14",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-6k53cvkRrxMgmeStLIoLjVXZQHqIJgTmv1Izd8TYhh1HOC4POgE6GCvx1bsalyEP",
@@ -561,10 +561,21 @@ function globalRateLimitOpts() {
561
561
  * are machine-to-machine and authenticate with the shared
562
562
  * D1_BRIDGE_SECRET, so on those paths the secret gate — not a browser
563
563
  * fingerprint — is the deciding check (see INTERNAL_BRIDGE_PATHS).
564
+ *
565
+ * `/admin` is skipped HERE but re-guarded in tag mode inside
566
+ * `mountRouteGuards`: every admin route is gated on the timing-safe
567
+ * bearer-key check, and the documented way to drive the admin JSON
568
+ * surface is curl — whose User-Agent is on the vendored deny list, and
569
+ * the UA check fires before auth is ever consulted. In block mode the
570
+ * README / onboarding curl examples answer 403 "Forbidden" instead of
571
+ * reaching the 401/200 bearer gate; in tag mode automation is audited
572
+ * (`system.botguard.tag` + `req.suspectedBot`) while the bearer key
573
+ * stays the deciding check. The regex matches `/admin` and
574
+ * `/admin/...` only — not other `/admin…`-prefixed names.
564
575
  */
565
576
  function botGuardOpts() {
566
577
  return {
567
- skipPaths: INTERNAL_BRIDGE_PATHS.slice(),
578
+ skipPaths: INTERNAL_BRIDGE_PATHS.slice().concat([/^\/admin(\/|$)/]),
568
579
  };
569
580
  }
570
581
 
@@ -584,6 +595,22 @@ function _hasPrefix(pathname, prefixes) {
584
595
  * @param r the blamejs Router passed to createApp's routes(r) callback.
585
596
  */
586
597
  function mountRouteGuards(r) {
598
+ // --- bot-guard, tag mode, /admin only -------------------------------
599
+ //
600
+ // The app-level bot-guard (block mode) skips /admin — see
601
+ // botGuardOpts: curl is the documented admin client and the UA
602
+ // deny-list check fires before the bearer gate, so block mode turns
603
+ // every documented example into a 403. The surface still wants the
604
+ // signal, though: this tag-mode instance audits automation
605
+ // (system.botguard.tag + req.suspectedBot) without refusing it, and
606
+ // the timing-safe bearer key stays the deciding check.
607
+ var adminBotTag = b.middleware.botGuard({ mode: "tag" });
608
+ r.use(function adminBotTagGuard(req, res, next) {
609
+ var pathname = req.pathname || req.url || "/";
610
+ if (!/^\/admin(\/|$)/.test(pathname)) return next();
611
+ return adminBotTag(req, res, next);
612
+ });
613
+
587
614
  // --- CSRF: double-submit token on authenticated state-changing POSTs ---
588
615
  //
589
616
  // The vendored createApp enforces CSRF by default; the entry points pass
package/lib/storefront.js CHANGED
@@ -8085,9 +8085,18 @@ function _cartGiftBlock(opts) {
8085
8085
  (w.wrap_sku === current ? " selected" : "") + ">" +
8086
8086
  esc(String(w.title)) + " (" + esc(feeStr) + ")</option>";
8087
8087
  }).join("");
8088
+ // Over-limit (or any rejected /cart/gift apply) renders an inline error
8089
+ // banner. The message carries the operator-authored wrap title, so it is
8090
+ // escaped at the sink — the cap value the route interpolates is a plain
8091
+ // integer, but escaping the whole string is the safe default. `role=alert`
8092
+ // so a screen reader announces the rejection.
8093
+ var giftError = (typeof opts.gift_error === "string" && opts.gift_error)
8094
+ ? "<p class=\"cart-gift__error\" role=\"alert\">" + esc(opts.gift_error) + "</p>"
8095
+ : "";
8088
8096
  return "<section class=\"cart-gift\">" +
8089
- "<details class=\"cart-gift__details\"" + (current ? " open" : "") + ">" +
8097
+ "<details class=\"cart-gift__details\"" + (current || giftError ? " open" : "") + ">" +
8090
8098
  "<summary class=\"cart-gift__summary\">Add a gift wrap</summary>" +
8099
+ giftError +
8091
8100
  "<form method=\"post\" action=\"/cart/gift\" class=\"cart-gift__form\">" +
8092
8101
  "<label class=\"form-field\"><span>Gift wrap</span>" +
8093
8102
  "<select name=\"wrap_sku\">" + options + "</select>" +
@@ -10023,11 +10032,13 @@ function renderAddPaymentMethod(opts) {
10023
10032
  // ---- profile edit ------------------------------------------------------
10024
10033
  //
10025
10034
  // Display-name edit for the signed-in customer. Email is stored hash-only
10026
- // and is the OAuth account-linking key, so it's shown read-only (masked)
10027
- // and cannot be changed here the primitive refuses an email patch until
10028
- // a verification ceremony exists. The form is a plain server-rendered
10029
- // POST with a PRG `?ok=updated` success notice, matching the addresses
10030
- // pattern.
10035
+ // (a one-way hash, never the plaintext) and is the OAuth account-linking
10036
+ // key, so it is shown read-only and genuinely cannot be changed there is
10037
+ // no readable address to edit. The field's hint tells the customer what to
10038
+ // do instead (sign in with the registered address; create a new account to
10039
+ // use a different one; contact support to move order history). The form is
10040
+ // a plain server-rendered POST with a PRG `?ok=updated` success notice,
10041
+ // matching the addresses pattern.
10031
10042
  function renderProfile(opts) {
10032
10043
  opts = opts || {};
10033
10044
  var esc = b.template.escapeHtml;
@@ -10054,7 +10065,7 @@ function renderProfile(opts) {
10054
10065
  "<div class=\"form-row\"><label class=\"form-field\"><span class=\"form-field__label\">Email</span>" +
10055
10066
  "<input type=\"text\" value=\"Hidden for privacy — stored as a one-way hash\" disabled aria-describedby=\"email-note\"></label></div>" +
10056
10067
  "<p id=\"email-note\" class=\"form-field__hint\">Your email address is never stored in readable form, so it can't be changed or shown here. " +
10057
- "Sign in with the address you registered.</p>" +
10068
+ "Sign in with the address you registered. To use a different address, create a new account with it, or contact support if you need help moving your order history.</p>" +
10058
10069
  "<div class=\"form-actions\"><button type=\"submit\" class=\"btn-primary\">Save changes</button> " +
10059
10070
  "<a class=\"btn-ghost\" href=\"/account\">Cancel</a></div>" +
10060
10071
  "</form>" +
@@ -13088,38 +13099,27 @@ function mount(router, deps) {
13088
13099
  return res.end ? res.end(payload) : res.send(payload);
13089
13100
  });
13090
13101
 
13091
- router.get("/cart", async function (req, res) {
13102
+ // Assemble the full cart-page render and emit it at `status`. Shared by
13103
+ // GET /cart and by POST /cart/gift's over-limit rejection (a 4xx that must
13104
+ // still show the customer their cart, with a gift error banner), so the two
13105
+ // surfaces never drift. `extraOpts` overlays render flags the caller knows
13106
+ // (the post-add banner, the coupon PRG notices, a gift_error message).
13107
+ async function _renderCartResponse(req, res, status, extraOpts) {
13108
+ extraOpts = extraOpts || {};
13092
13109
  var ccy = await _currencyForReq(req);
13093
13110
  var sid = _readSidCookie(req);
13094
- // `?added=1` after a POST /cart/lines redirect — drives the
13095
- // "Added to cart" status banner. Read from the parsed query when the
13096
- // router populated it, else from the raw URL.
13097
- var cartUrl = req.url ? new URL(req.url, "http://localhost") : null;
13098
- var added = (req.query && req.query.added === "1") ||
13099
- (cartUrl && cartUrl.searchParams.get("added") === "1") || false;
13100
- // Coupon-entry PRG outcomes (set by POST /cart/coupon[/remove]). One of
13101
- // applied / removed / err so the cart shows an inline notice. `?code_err`
13102
- // carries no detail beyond "couldn't apply" — a uniform message, no
13103
- // code-existence oracle.
13104
- function _cartQp(name) {
13105
- return (req.query && req.query[name] === "1") ||
13106
- (cartUrl && cartUrl.searchParams.get(name) === "1") || false;
13107
- }
13108
- var codeApplied = _cartQp("code_applied");
13109
- var codeRemoved = _cartQp("code_removed");
13110
- var codeErr = _cartQp("code_err");
13111
13111
  if (!sid) {
13112
- return _send(res, 200, renderCart(Object.assign({
13112
+ return _send(res, status, renderCart(Object.assign({
13113
13113
  lines: [], totals: { subtotal_minor: 0, grand_total_minor: 0, currency: "USD" },
13114
13114
  shop_name: shopName, theme: theme,
13115
- }, ccy)));
13115
+ }, ccy, extraOpts)));
13116
13116
  }
13117
13117
  var c = await deps.cart.bySession(sid);
13118
13118
  if (!c) {
13119
- return _send(res, 200, renderCart(Object.assign({
13119
+ return _send(res, status, renderCart(Object.assign({
13120
13120
  lines: [], totals: { subtotal_minor: 0, grand_total_minor: 0, currency: "USD" },
13121
13121
  shop_name: shopName, theme: theme,
13122
- }, ccy)));
13122
+ }, ccy, extraOpts)));
13123
13123
  }
13124
13124
  var rawLines = await deps.cart.listLines(c.id);
13125
13125
  // Reapply the active quantity-break for each line at its current
@@ -13197,7 +13197,7 @@ function mount(router, deps) {
13197
13197
  // the primitive falls back to its weight-agnostic transit rows. Drop-
13198
13198
  // silent → null, and the summary renders no estimate.
13199
13199
  var cartEstimate = await _resolveDeliveryEstimate(req, { dest: estimateDest });
13200
- _send(res, 200, renderCart(Object.assign({
13200
+ _send(res, status, renderCart(Object.assign({
13201
13201
  lines: lines,
13202
13202
  totals: totals,
13203
13203
  totals_detail: totalsDetail,
@@ -13208,19 +13208,38 @@ function mount(router, deps) {
13208
13208
  checkout_available: !!(deps.checkout && deps.order),
13209
13209
  gift_wraps: giftWraps,
13210
13210
  gift_wrap_in_cart: giftWrapInCart,
13211
- added: added,
13212
13211
  // Coupon entry: surfaced only when the discount engine is wired (the
13213
13212
  // POST routes mount on the same condition). `applied_codes` echoes the
13214
13213
  // typed codes so each gets a remove control; the *_notice flags drive
13215
13214
  // the inline PRG banner.
13216
13215
  coupon_enabled: !!(deps.autoDiscount && typeof deps.cart.listDiscountCodes === "function"),
13217
13216
  applied_codes: appliedCodes,
13218
- code_applied: codeApplied,
13219
- code_removed: codeRemoved,
13220
- code_err: codeErr,
13221
13217
  shop_name: shopName,
13222
13218
  theme: theme,
13223
- }, ccy)));
13219
+ }, ccy, extraOpts)));
13220
+ }
13221
+
13222
+ router.get("/cart", async function (req, res) {
13223
+ // `?added=1` after a POST /cart/lines redirect — drives the
13224
+ // "Added to cart" status banner. Read from the parsed query when the
13225
+ // router populated it, else from the raw URL.
13226
+ var cartUrl = req.url ? new URL(req.url, "http://localhost") : null;
13227
+ var added = (req.query && req.query.added === "1") ||
13228
+ (cartUrl && cartUrl.searchParams.get("added") === "1") || false;
13229
+ // Coupon-entry PRG outcomes (set by POST /cart/coupon[/remove]). One of
13230
+ // applied / removed / err so the cart shows an inline notice. `?code_err`
13231
+ // carries no detail beyond "couldn't apply" — a uniform message, no
13232
+ // code-existence oracle.
13233
+ function _cartQp(name) {
13234
+ return (req.query && req.query[name] === "1") ||
13235
+ (cartUrl && cartUrl.searchParams.get(name) === "1") || false;
13236
+ }
13237
+ return await _renderCartResponse(req, res, 200, {
13238
+ added: added,
13239
+ code_applied: _cartQp("code_applied"),
13240
+ code_removed: _cartQp("code_removed"),
13241
+ code_err: _cartQp("code_err"),
13242
+ });
13224
13243
  });
13225
13244
 
13226
13245
  // ---- checkout flow -------------------------------------------------
@@ -14519,16 +14538,34 @@ function mount(router, deps) {
14519
14538
  if (cust && cust.id) customerId = cust.id;
14520
14539
  } catch (_e) { customerId = null; }
14521
14540
 
14541
+ // Resolve the absolute origin while the request is in hand (the
14542
+ // mint + send below runs after the response, but reads no further
14543
+ // request state).
14544
+ var origin = "";
14545
+ try { origin = new URL(_requestUrls(req).canonical_url).origin; }
14546
+ catch (_e2) { origin = ""; }
14547
+
14548
+ // SECURITY (no account-existence oracle): send the generic
14549
+ // confirmation 303 BEFORE any matched-only work, so a registered
14550
+ // and an unregistered address are indistinguishable by response
14551
+ // latency. The session mint + the outbound email round-trip used
14552
+ // to run inside `if (customerId)` and be AWAITED before the 303 —
14553
+ // a non-matching email skipped both and answered near-instantly,
14554
+ // restoring exactly the account-existence oracle the identical
14555
+ // body is meant to remove. The work now runs fire-and-forget on
14556
+ // the next microtask, off the response's critical path; it stays
14557
+ // drop-silent and best-effort (a failure simply means the link
14558
+ // doesn't arrive), unchanged but for no longer being timed.
14559
+ res.status(303);
14560
+ res.setHeader && res.setHeader("location", "/account/login/link?sent=1");
14561
+ if (res.end) res.end(); else res.send("");
14562
+
14522
14563
  if (customerId) {
14523
- try {
14564
+ Promise.resolve().then(async function () {
14524
14565
  var minted = await deps.customerPortal.createSession({
14525
14566
  customer_id: customerId,
14526
14567
  scope: "full",
14527
14568
  });
14528
- // Build the absolute redemption link from this request's origin.
14529
- var origin = "";
14530
- try { origin = new URL(_requestUrls(req).canonical_url).origin; }
14531
- catch (_e2) { origin = ""; }
14532
14569
  var linkUrl = origin + "/account/portal/" + encodeURIComponent(minted.plaintext_token);
14533
14570
  // The customer's plaintext address: the portal flow needs a
14534
14571
  // deliverable address. The customers store keeps only the hash,
@@ -14538,12 +14575,8 @@ function mount(router, deps) {
14538
14575
  customer_email: emailRaw,
14539
14576
  link_url: linkUrl,
14540
14577
  });
14541
- } catch (_e3) { /* drop-silent — generic confirmation regardless */ }
14578
+ }).catch(function () { /* drop-silent — best-effort; the link simply doesn't arrive */ });
14542
14579
  }
14543
- // 303 to the GET with the generic confirmation flag.
14544
- res.status(303);
14545
- res.setHeader && res.setHeader("location", "/account/login/link?sent=1");
14546
- return res.end ? res.end() : res.send("");
14547
14580
  });
14548
14581
 
14549
14582
  // GET — redeem the magic-link token. verifyToken is single-use (flips
@@ -14606,22 +14639,37 @@ function mount(router, deps) {
14606
14639
  res.status(regCaptcha.status || 400);
14607
14640
  return res.end ? res.end(regCaptcha.message) : res.send(regCaptcha.message);
14608
14641
  }
14609
- // Persist the customer row up-front. The address is the
14610
- // registration's natural identifierif enrollment fails
14611
- // the customer can re-attempt with the same email; the
14612
- // primitive's duplicate-refusal surfaces as a typed code.
14642
+ // SECURITY: the public passkey-register path creates NEW accounts
14643
+ // ONLY. It must never reuse or mint a ceremony challenge bound to
14644
+ // an email that already has an account, for ANY reason. Reusing
14645
+ // the row would let a visitor who merely knows the email finish the
14646
+ // WebAuthn ceremony on their own device and be issued a sign-in
14647
+ // session for the account's owner: account takeover. A row carrying
14648
+ // zero passkeys is NOT a safely-reusable "interrupted registration"
14649
+ // — a Google/Apple (OIDC) account and a guest-order row awaiting a
14650
+ // magic-link claim both have no passkey yet a real owner, and an
14651
+ // attacker enrolling onto either takes it over just the same. So
14652
+ // refuse uniformly on ANY existing account (the duplicate-email
14653
+ // refusal customers.register already enforces) and point to
14654
+ // sign-in. An owner adding another passkey uses the session-gated
14655
+ // /account/passkey/add-* flow; a passwordless owner — or one whose
14656
+ // first ceremony was interrupted — signs in with the magic-link.
14613
14657
  var existing = await deps.customers.byEmailHash(
14614
14658
  deps.customers.hashEmail(body.email),
14615
14659
  );
14616
- var customer;
14617
14660
  if (existing) {
14618
- customer = existing;
14619
- } else {
14620
- customer = await deps.customers.register({
14621
- email: body.email,
14622
- display_name: body.display_name,
14661
+ res.status(409);
14662
+ res.setHeader && res.setHeader("content-type", "application/json");
14663
+ var dupMsg = JSON.stringify({
14664
+ error: "account_exists",
14665
+ message: "An account with this email already exists. Sign in instead.",
14623
14666
  });
14667
+ return res.end ? res.end(dupMsg) : res.send(dupMsg);
14624
14668
  }
14669
+ var customer = await deps.customers.register({
14670
+ email: body.email,
14671
+ display_name: body.display_name,
14672
+ });
14625
14673
  var startOpts = await b.auth.passkey.startRegistration({
14626
14674
  rpName: rpName,
14627
14675
  rpId: rpId,
@@ -15364,9 +15412,10 @@ function mount(router, deps) {
15364
15412
  }
15365
15413
 
15366
15414
  // Profile edit — display-name only. Email is hash-only + the OAuth
15367
- // linking key, so the primitive refuses an email change without a
15368
- // verification ceremony; the form shows it read-only. PRG with a
15369
- // ?ok=updated success notice, matching the addresses pattern.
15415
+ // linking key, so it genuinely can't be changed (no readable address to
15416
+ // edit); the form shows it read-only and the route never accepts an email
15417
+ // field. PRG with a ?ok=updated success notice, matching the addresses
15418
+ // pattern.
15370
15419
  async function _renderProfilePage(req, res, auth, customer, notice, code) {
15371
15420
  var cartCount = await _cartCountForReq(req);
15372
15421
  var url = req.url ? new URL(req.url, "http://localhost") : null;
@@ -18978,21 +19027,59 @@ function mount(router, deps) {
18978
19027
  // a REAL cart line so its fee flows through pricing.totals and is charged
18979
19028
  // by checkout.confirm (NEVER a post-commit hook — that would mis-charge).
18980
19029
  // Selecting "No gift wrap" removes any wrap line. Selecting a wrap removes
18981
- // any prior wrap line then adds the chosen one (qty 1, capped by
18982
- // max_per_order). The message / recipient are collected at checkout (they
18983
- // need the order id). Mounts only when the gift-options primitive is wired.
19030
+ // any prior wrap line then adds the chosen one.
19031
+ //
19032
+ // Authoritative fee: the wrap line's price snapshot is the operator-
19033
+ // configured gift_wraps.fee_minor — NOT the wrap variant's catalog price.
19034
+ // fee_minor is the single source the admin sets and the cart selector
19035
+ // displays, so the charge must read it too (an explicit unit_amount_minor
19036
+ // snapshot on cart.addLine), or the displayed fee and the charged fee would
19037
+ // silently diverge whenever the catalog price differs from the wrap fee.
19038
+ //
19039
+ // Cap: gift_wraps.max_per_order bounds how many of that wrap one order may
19040
+ // carry. The request quantity (defensive reader, default 1) is checked
19041
+ // against it BEFORE any mutation — over the cap is rejected with a 422 and
19042
+ // the cart re-rendered with a customer-readable error, the cart unchanged.
19043
+ //
19044
+ // The message / recipient are collected at checkout (they need the order
19045
+ // id). Mounts only when the gift-options primitive is wired.
18984
19046
  if (deps.giftOptions) {
18985
19047
  router.post("/cart/gift", async function (req, res) {
18986
19048
  var body = req.body || {};
18987
19049
  var wrapSku = typeof body.wrap_sku === "string" ? body.wrap_sku.trim() : "";
19050
+ // Defensive request-shape reader: qty defaults to 1, and any non-
19051
+ // positive-integer field (blank, garbage, fractional, negative) falls
19052
+ // back to 1 rather than throwing — the cap check below is the only gate
19053
+ // that rejects, and it rejects on "too many", never on a malformed qty.
19054
+ var reqQty = 1;
19055
+ var rawQty = body.qty;
19056
+ if (rawQty != null && rawQty !== "") {
19057
+ var s = String(rawQty).trim();
19058
+ if (/^\d+$/.test(s)) {
19059
+ var n = Number(s);
19060
+ if (Number.isInteger(n) && n > 0) reqQty = n;
19061
+ }
19062
+ }
18988
19063
  var resolved = await _getOrCreateCart(req, res, "USD");
18989
- var cartId = resolved.cart.id;
19064
+ var cart = resolved.cart;
19065
+ var cartId = cart.id;
18990
19066
  try {
18991
19067
  // Resolve the active wrap catalog so we know every wrap sku (to
18992
- // remove a stale wrap line) and the selected wrap's cap.
19068
+ // remove a stale wrap line) and the selected wrap's fee + cap.
18993
19069
  var wraps = await deps.giftOptions.listWraps({ active_only: true });
18994
19070
  var wrapBySku = {};
18995
19071
  for (var i = 0; i < wraps.length; i += 1) wrapBySku[wraps[i].wrap_sku] = wraps[i];
19072
+ var chosen = (wrapSku && wrapBySku[wrapSku]) ? wrapBySku[wrapSku] : null;
19073
+ // Enforce max_per_order before mutating anything. A null cap means
19074
+ // "no limit"; a set cap rejects a request for more wraps than the
19075
+ // order may carry. The cart is left untouched so the customer can
19076
+ // resubmit with a smaller quantity.
19077
+ if (chosen && chosen.max_per_order != null && reqQty > chosen.max_per_order) {
19078
+ return await _renderCartResponse(req, res, 422, {
19079
+ gift_error: "You can add at most " + chosen.max_per_order + " of “" +
19080
+ chosen.title + "” to one order. Please choose a smaller quantity.",
19081
+ });
19082
+ }
18996
19083
  // Remove any existing wrap line first (every active wrap's sku).
18997
19084
  var existingLines = await deps.cart.listLines(cartId);
18998
19085
  for (var li = 0; li < existingLines.length; li += 1) {
@@ -19001,13 +19088,19 @@ function mount(router, deps) {
19001
19088
  }
19002
19089
  }
19003
19090
  // Add the selected wrap (when one was chosen + it's a real active
19004
- // wrap). The wrap_sku is a real catalog variant; resolve its variant
19005
- // id and add it as a line the price snapshot comes from the catalog
19006
- // price, so the fee is charged through the normal quote path.
19007
- if (wrapSku && wrapBySku[wrapSku]) {
19091
+ // wrap). The wrap_sku is a real catalog variant resolve its variant
19092
+ // id, then add it as a line whose unit price is the AUTHORITATIVE
19093
+ // gift_wraps.fee_minor (the configured + displayed wrap fee), pinned
19094
+ // via an explicit price snapshot so the charge matches the display.
19095
+ if (chosen) {
19008
19096
  var variant = await deps.catalog.variants.bySku(wrapSku);
19009
19097
  if (variant) {
19010
- await deps.cart.addLine(cartId, { variant_id: variant.id, qty: 1 });
19098
+ await deps.cart.addLine(cartId, {
19099
+ variant_id: variant.id,
19100
+ qty: reqQty,
19101
+ unit_amount_minor: chosen.fee_minor,
19102
+ unit_currency: cart.currency || "USD",
19103
+ });
19011
19104
  }
19012
19105
  }
19013
19106
  } catch (e) {
package/lib/webhooks.js CHANGED
@@ -18,8 +18,9 @@
18
18
  * `events` is either the wildcard `*` or a comma-separated allowlist
19
19
  * of recognized event types — `order.mark_paid`,
20
20
  * `order.start_fulfillment`, `order.mark_shipped`,
21
- * `order.mark_delivered`, `order.cancel`, `order.refund`. Endpoints
22
- * only receive event types they subscribed to.
21
+ * `order.mark_delivered`, `order.cancel`, `order.refund`,
22
+ * `inventory.low_stock`. Endpoints only receive event types they
23
+ * subscribed to.
23
24
  *
24
25
  * `send(eventType, payload)` is fire-and-flag-failure: every active
25
26
  * endpoint subscribed to the event gets a delivery row before the
@@ -65,6 +66,7 @@ var KNOWN_EVENTS = Object.freeze([
65
66
  "order.mark_delivered",
66
67
  "order.cancel",
67
68
  "order.refund",
69
+ "inventory.low_stock",
68
70
  ]);
69
71
 
70
72
  var SECRET_BYTES = 32;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.12",
3
+ "version": "0.4.14",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {