@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 +4 -0
- package/lib/asset-manifest.json +1 -1
- package/lib/security-middleware.js +28 -1
- package/lib/storefront.js +161 -68
- package/lib/webhooks.js +4 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.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 `<`), 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.
|
package/lib/asset-manifest.json
CHANGED
|
@@ -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
|
|
10027
|
-
//
|
|
10028
|
-
//
|
|
10029
|
-
//
|
|
10030
|
-
//
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
//
|
|
14610
|
-
//
|
|
14611
|
-
//
|
|
14612
|
-
//
|
|
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
|
-
|
|
14619
|
-
|
|
14620
|
-
|
|
14621
|
-
|
|
14622
|
-
|
|
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
|
|
15368
|
-
//
|
|
15369
|
-
// ?ok=updated success notice, matching the addresses
|
|
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
|
|
18982
|
-
//
|
|
18983
|
-
//
|
|
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
|
|
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
|
|
19005
|
-
// id
|
|
19006
|
-
//
|
|
19007
|
-
|
|
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, {
|
|
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
|
|
22
|
-
* only receive event types they
|
|
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