@blamejs/blamejs-shop 0.4.11 → 0.4.13
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/analytics.js +11 -3
- package/lib/asset-manifest.json +1 -1
- package/lib/search-synonyms.js +15 -1
- package/lib/storefront.js +182 -27
- 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.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.
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
|
|
11
15
|
- 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.
|
|
12
16
|
|
|
13
17
|
- v0.4.10 (2026-06-05) — **The Promo banners console is back in the admin nav.** The promo-banners management screen introduced in 0.4.4 was fully built and mounted, but the admin nav never showed its link: the nav filters items against an availability map, and the map was missing the one key the Promo banners item checks, so the link was filtered off every page and the screen was reachable only by typing /admin/promo-banners. The key is in the map now, and a contract test pins every nav item's gate key to the map so a future screen can't ship undiscoverable the same way. **Added:** *Nav gate keys are contract-tested* — A test now parses every nav item's gate key against the availability map and fails the suite on any mismatch, in either direction of drift — a new screen whose key is forgotten can't ship with a permanently hidden link again. **Fixed:** *Promo banners nav link renders* — The admin nav gates each item on an availability map keyed by the deps the console was mounted with. The Promo banners item checked a key the map never defined, which reads as permanently unavailable — so the link was hidden on every authenticated admin page even though the screen itself mounted and worked. Operators see the link again wherever the promo-banners primitive is wired.
|
package/lib/analytics.js
CHANGED
|
@@ -492,18 +492,26 @@ function create(opts) {
|
|
|
492
492
|
// GROUPs by the denormalised column so the query never decodes
|
|
493
493
|
// the JSON payload. Default limit 10; max 100 (same envelope as
|
|
494
494
|
// `topSKUs`).
|
|
495
|
+
//
|
|
496
|
+
// GROUPs + sorts on `lower(search_q)` so "Tee" and "tee" collapse
|
|
497
|
+
// into one row, matching how the autocomplete "Popular searches"
|
|
498
|
+
// aggregate (search-suggestions, which lowercases on write)
|
|
499
|
+
// counts them. New writes already arrive lowercased from the
|
|
500
|
+
// storefront record site; lowering at aggregate time folds any
|
|
501
|
+
// mixed-case history written before that. ASCII queries only, so
|
|
502
|
+
// SQLite's built-in `lower()` (ASCII-only) is sufficient.
|
|
495
503
|
topSearchTerms: async function (windowOpts) {
|
|
496
504
|
var w = _resolveEventWindow(windowOpts);
|
|
497
505
|
var limit = (windowOpts && windowOpts.limit) == null ? 10 : windowOpts.limit;
|
|
498
506
|
_limit(limit, "limit");
|
|
499
507
|
var r = await query(
|
|
500
|
-
"SELECT search_q AS search_q, COUNT(*) AS count " +
|
|
508
|
+
"SELECT lower(search_q) AS search_q, COUNT(*) AS count " +
|
|
501
509
|
" FROM analytics_events " +
|
|
502
510
|
" WHERE event_type = 'search_query' " +
|
|
503
511
|
" AND search_q IS NOT NULL " +
|
|
504
512
|
" AND occurred_at >= ?1 AND occurred_at < ?2 " +
|
|
505
|
-
" GROUP BY search_q " +
|
|
506
|
-
" ORDER BY count DESC, search_q ASC " +
|
|
513
|
+
" GROUP BY lower(search_q) " +
|
|
514
|
+
" ORDER BY count DESC, lower(search_q) ASC " +
|
|
507
515
|
" LIMIT ?3",
|
|
508
516
|
[w.from, w.to, limit],
|
|
509
517
|
);
|
package/lib/asset-manifest.json
CHANGED
package/lib/search-synonyms.js
CHANGED
|
@@ -276,7 +276,21 @@ function _stem(token) {
|
|
|
276
276
|
if (token.length >= 5 && token.slice(-3) === "ies") return token.slice(0, -3) + "y";
|
|
277
277
|
if (token.length >= 5 && token.slice(-3) === "ing") return token.slice(0, -3);
|
|
278
278
|
if (token.length >= 4 && token.slice(-2) === "ed") return token.slice(0, -2);
|
|
279
|
-
|
|
279
|
+
// `-es` is only a true plural suffix when the stem ends in a sibilant
|
|
280
|
+
// (`s`/`x`/`z`/`ch`/`sh`): boxes -> box, dishes -> dish, churches ->
|
|
281
|
+
// church. For vowel-final stems the singular keeps a trailing `e` and
|
|
282
|
+
// the plural is a bare `-s` — stripping `-es` there over-strips
|
|
283
|
+
// (tees -> te, shoes -> sho, does -> do). Strip `-es` only for the
|
|
284
|
+
// sibilant case; everything else falls through to the bare `-s` rule
|
|
285
|
+
// (tees -> tee, shoes -> shoe, does -> doe).
|
|
286
|
+
if (token.length >= 4 && token.slice(-2) === "es") {
|
|
287
|
+
var esStem = token.slice(0, -2);
|
|
288
|
+
var esLast = esStem.slice(-1);
|
|
289
|
+
var esLast2 = esStem.slice(-2);
|
|
290
|
+
if (esLast === "s" || esLast === "x" || esLast === "z" || esLast2 === "ch" || esLast2 === "sh") {
|
|
291
|
+
return esStem;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
280
294
|
if (token.length >= 4 && token.slice(-1) === "s") return token.slice(0, -1);
|
|
281
295
|
return token;
|
|
282
296
|
}
|
package/lib/storefront.js
CHANGED
|
@@ -1854,6 +1854,9 @@ var SEARCH_MAX_FACET_KEYS = 32;
|
|
|
1854
1854
|
var SEARCH_MAX_FACET_VALUES = 64;
|
|
1855
1855
|
var SEARCH_MAX_VALUE_LEN = 256;
|
|
1856
1856
|
var SEARCH_CONTROL_BYTE_RE = /[\x00-\x1f\x7f]/;
|
|
1857
|
+
// Global twin for strip-all replaces (the non-global one above is used for
|
|
1858
|
+
// presence tests; `.replace` needs the `g` flag to clear every byte).
|
|
1859
|
+
var SEARCH_CONTROL_BYTE_RE_G = /[\x00-\x1f\x7f]/g;
|
|
1857
1860
|
|
|
1858
1861
|
// Parse `?key=value` repeats off a parsed URL into the
|
|
1859
1862
|
// `{ facetKey: [value, ...] }` shape the searchFacets primitive
|
|
@@ -1942,13 +1945,20 @@ function renderSearch(opts) {
|
|
|
1942
1945
|
} else {
|
|
1943
1946
|
var assetPrefix = opts.asset_prefix || "/assets/";
|
|
1944
1947
|
var fmt = _priceFormatter(opts);
|
|
1948
|
+
// `?from=search&sq=<query>` marks the click as originating in a ranked
|
|
1949
|
+
// result list. The PDP route reads it to log a click event against the
|
|
1950
|
+
// active ranking weight set (the impression/click signals the admin
|
|
1951
|
+
// search-metrics view aggregates); `sq` carries the query the list was
|
|
1952
|
+
// ranked for, which recordSearchEvent requires. Works with JS off; no
|
|
1953
|
+
// beacon. Mirrored byte-for-byte by worker/render/search.js.
|
|
1954
|
+
var resultLinkSuffix = "?from=search&sq=" + encodeURIComponent(qTrim.slice(0, 200));
|
|
1945
1955
|
var cards = products.map(function (p) {
|
|
1946
1956
|
var priceStr = p.starting_price_minor != null
|
|
1947
1957
|
? fmt(p.starting_price_minor, p.starting_price_currency || "USD")
|
|
1948
1958
|
: "—";
|
|
1949
1959
|
var imageUrl = p.hero_media ? assetPrefix + p.hero_media.r2_key : null;
|
|
1950
1960
|
var imageAlt = p.hero_media ? (p.hero_media.alt_text || p.title) : null;
|
|
1951
|
-
return _buildProductCard({ title: p.title, price: priceStr, slug: p.slug, image_url: imageUrl, image_alt: imageAlt });
|
|
1961
|
+
return _buildProductCard({ title: p.title, price: priceStr, slug: p.slug + resultLinkSuffix, image_url: imageUrl, image_alt: imageAlt });
|
|
1952
1962
|
}).join("\n");
|
|
1953
1963
|
resultsInner = "<section class=\"search-grid\"><div class=\"grid\">" + cards + "</div></section>";
|
|
1954
1964
|
}
|
|
@@ -10661,6 +10671,88 @@ function mount(router, deps) {
|
|
|
10661
10671
|
} catch (_e) { /* drop-silent — analytics is supplementary to every request */ }
|
|
10662
10672
|
}
|
|
10663
10673
|
|
|
10674
|
+
// Search-ranking impression. Resolves the active weight set, then logs a
|
|
10675
|
+
// single list-level impression against it. The metrics view aggregates
|
|
10676
|
+
// impressions per weights_slug to compute CTR / conversion, so the slug
|
|
10677
|
+
// is the join key; query is denormalised for operator inspection. No
|
|
10678
|
+
// active set → no-op (recordSearchEvent would throw SEARCH_WEIGHTS_NOT
|
|
10679
|
+
// _FOUND, which the catch swallows). HOT PATH: fire-and-forget +
|
|
10680
|
+
// drop-silent — a metrics write must never affect the search response.
|
|
10681
|
+
function _recordSearchImpression(req, term) {
|
|
10682
|
+
if (!deps.searchRanking) return;
|
|
10683
|
+
try {
|
|
10684
|
+
var sid = null;
|
|
10685
|
+
try { sid = _readSidCookie(req); } catch (_e) { sid = null; }
|
|
10686
|
+
Promise.resolve(deps.searchRanking.activeWeights())
|
|
10687
|
+
.then(function (active) {
|
|
10688
|
+
if (!active || !active.slug) return; // ranking not configured → nothing to attribute
|
|
10689
|
+
var ev = { query: term, event_type: "impression", weights_slug: active.slug };
|
|
10690
|
+
if (sid) ev.session_id = sid;
|
|
10691
|
+
return deps.searchRanking.recordSearchEvent(ev);
|
|
10692
|
+
})
|
|
10693
|
+
.catch(function () { /* drop-silent — impression bump never fails the search */ });
|
|
10694
|
+
} catch (_e) { /* drop-silent — supplementary to every search */ }
|
|
10695
|
+
}
|
|
10696
|
+
|
|
10697
|
+
// Search-ranking click. Logged from the PDP route when the inbound link
|
|
10698
|
+
// carried `?from=search`. Attributes the click to whatever weight set is
|
|
10699
|
+
// active at click time — the same set new impressions log against — so
|
|
10700
|
+
// CTR stays coherent (an operator rarely flips the active set mid-session;
|
|
10701
|
+
// if they do, both new impressions and new clicks follow the new set).
|
|
10702
|
+
// `productId` is denormalised for inspection; the metrics view keys on the
|
|
10703
|
+
// weights_slug + time window. HOT PATH: fire-and-forget + drop-silent.
|
|
10704
|
+
function _recordSearchClick(req, term, productId) {
|
|
10705
|
+
if (!deps.searchRanking) return;
|
|
10706
|
+
// recordSearchEvent requires a non-empty query; a click that lost its
|
|
10707
|
+
// `sq` marker can't be attributed, so skip rather than fire a call that
|
|
10708
|
+
// would throw.
|
|
10709
|
+
if (typeof term !== "string" || term.trim().length === 0) return;
|
|
10710
|
+
try {
|
|
10711
|
+
var sid = null;
|
|
10712
|
+
try { sid = _readSidCookie(req); } catch (_e) { sid = null; }
|
|
10713
|
+
Promise.resolve(deps.searchRanking.activeWeights())
|
|
10714
|
+
.then(function (active) {
|
|
10715
|
+
if (!active || !active.slug) return;
|
|
10716
|
+
var ev = { query: term, event_type: "click", weights_slug: active.slug };
|
|
10717
|
+
if (productId) ev.product_id = productId;
|
|
10718
|
+
if (sid) ev.session_id = sid;
|
|
10719
|
+
return deps.searchRanking.recordSearchEvent(ev);
|
|
10720
|
+
})
|
|
10721
|
+
.catch(function () { /* drop-silent — click bump never fails the PDP */ });
|
|
10722
|
+
} catch (_e) { /* drop-silent — supplementary to every PDP view */ }
|
|
10723
|
+
}
|
|
10724
|
+
|
|
10725
|
+
// Facet-usage events. One per applied facet value the shopper narrowed
|
|
10726
|
+
// by, so the admin facet-usage rollup reflects real use. `filters` is the
|
|
10727
|
+
// `{ key: [value, ...] }` shape the search handler already parsed +
|
|
10728
|
+
// validated against the live facet defs. HOT PATH: fire-and-forget +
|
|
10729
|
+
// drop-silent — a usage write must never affect the search response.
|
|
10730
|
+
function _recordFacetUses(req, filters) {
|
|
10731
|
+
if (!deps.searchFacets) return;
|
|
10732
|
+
try {
|
|
10733
|
+
var sid = null;
|
|
10734
|
+
try { sid = _readSidCookie(req); } catch (_e) { sid = null; }
|
|
10735
|
+
var sessionId = sid || "anon";
|
|
10736
|
+
// searchFacets is a per-request factory (bound to a catalog); the
|
|
10737
|
+
// usage write needs no catalog, so an instance with an empty list
|
|
10738
|
+
// adapter is enough for recordFacetUse.
|
|
10739
|
+
var sf = deps.searchFacets({ list: function () { return Promise.resolve({ rows: [] }); } });
|
|
10740
|
+
var keys = Object.keys(filters);
|
|
10741
|
+
for (var k = 0; k < keys.length; k += 1) {
|
|
10742
|
+
var values = filters[keys[k]];
|
|
10743
|
+
if (!Array.isArray(values)) continue;
|
|
10744
|
+
for (var v = 0; v < values.length; v += 1) {
|
|
10745
|
+
(function (key, value) {
|
|
10746
|
+
try {
|
|
10747
|
+
Promise.resolve(sf.recordFacetUse({ key: key, value: value, session_id: sessionId }))
|
|
10748
|
+
.catch(function () { /* drop-silent — usage bump never fails the search */ });
|
|
10749
|
+
} catch (_e) { /* drop-silent — skip a value that fails validation */ }
|
|
10750
|
+
})(keys[k], values[v]);
|
|
10751
|
+
}
|
|
10752
|
+
}
|
|
10753
|
+
} catch (_e) { /* drop-silent — supplementary to every search */ }
|
|
10754
|
+
}
|
|
10755
|
+
|
|
10664
10756
|
// Resolve the active trust badges for a container-only placement and
|
|
10665
10757
|
// concatenate each one's sanitized renderHtml. Fires an impression per
|
|
10666
10758
|
// rendered badge (fire-and-forget — the counter is drop-silent on the hot
|
|
@@ -12353,12 +12445,20 @@ function mount(router, deps) {
|
|
|
12353
12445
|
shop_name: shopName,
|
|
12354
12446
|
cart_count: cartCount,
|
|
12355
12447
|
}, _requestUrls(req), ccy)));
|
|
12448
|
+
// One hygiene pass for every search sink. The autocomplete query log
|
|
12449
|
+
// (searchSuggestions.recordQuery) strips control bytes + lowercases
|
|
12450
|
+
// before it writes; the analytics funnel must see the SAME shape so
|
|
12451
|
+
// "Popular searches" and the analytics "top search terms" aggregate
|
|
12452
|
+
// the same rows. Strip control bytes, trim, lowercase once here — a
|
|
12453
|
+
// query that was only control bytes / whitespace collapses to "" and
|
|
12454
|
+
// every sink below skips it.
|
|
12455
|
+
var searchTerm = q.replace(SEARCH_CONTROL_BYTE_RE_G, "").trim().toLowerCase();
|
|
12356
12456
|
// Consent-gated funnel event — feeds the top-search-terms aggregate.
|
|
12357
|
-
// Only a real (non-empty) query counts; the
|
|
12358
|
-
//
|
|
12457
|
+
// Only a real (non-empty) query counts; the term is bounded to 200
|
|
12458
|
+
// chars above (recordEvent caps search_q at 256). Container-served
|
|
12359
12459
|
// only; fire-and-forget + drop-silent.
|
|
12360
|
-
if (
|
|
12361
|
-
_recordAnalyticsEvent(req, { event_type: "search_query", search_q:
|
|
12460
|
+
if (searchTerm.length > 0) {
|
|
12461
|
+
_recordAnalyticsEvent(req, { event_type: "search_query", search_q: searchTerm });
|
|
12362
12462
|
}
|
|
12363
12463
|
// Log the query for the admin "Popular searches" view + the autocomplete
|
|
12364
12464
|
// popular-terms group. Observability sink — drop-silent by design: a
|
|
@@ -12367,10 +12467,10 @@ function mount(router, deps) {
|
|
|
12367
12467
|
// hashes it before any write, so neither value is recoverable). Only a
|
|
12368
12468
|
// real (non-empty) query is logged. Alongside it, a self-gated retention
|
|
12369
12469
|
// sweep runs at most once a day to prune rows past the 90-day window.
|
|
12370
|
-
if (deps.searchSuggestions &&
|
|
12470
|
+
if (deps.searchSuggestions && searchTerm.length > 0) {
|
|
12371
12471
|
try {
|
|
12372
12472
|
deps.searchSuggestions.recordQuery({
|
|
12373
|
-
q:
|
|
12473
|
+
q: searchTerm,
|
|
12374
12474
|
session_id: _readSidCookie(req) || "anon",
|
|
12375
12475
|
result_count: totalCount,
|
|
12376
12476
|
}).catch(function () { /* drop-silent — logging never fails the search */ });
|
|
@@ -12384,6 +12484,20 @@ function mount(router, deps) {
|
|
|
12384
12484
|
} catch (_e2) { /* drop-silent — retention is best-effort */ }
|
|
12385
12485
|
}
|
|
12386
12486
|
}
|
|
12487
|
+
// Search-ranking impression — one event per rendered result list,
|
|
12488
|
+
// keyed to the active weight set (the slug the admin search-metrics
|
|
12489
|
+
// view aggregates CTR / conversion against). recordSearchEvent rejects
|
|
12490
|
+
// a missing weight set, so this is a no-op until the operator activates
|
|
12491
|
+
// one. Observability sink — drop-silent by design: a metrics hiccup
|
|
12492
|
+
// must never fail the search the shopper just ran.
|
|
12493
|
+
if (deps.searchRanking && searchTerm.length > 0) {
|
|
12494
|
+
_recordSearchImpression(req, searchTerm);
|
|
12495
|
+
}
|
|
12496
|
+
// Facet-narrowing usage — one event per applied facet value, so the
|
|
12497
|
+
// admin can see which facets shoppers actually use. Drop-silent.
|
|
12498
|
+
if (deps.searchFacets && searchTerm.length > 0 && filters && Object.keys(filters).length > 0) {
|
|
12499
|
+
_recordFacetUses(req, filters);
|
|
12500
|
+
}
|
|
12387
12501
|
});
|
|
12388
12502
|
|
|
12389
12503
|
// Autocomplete data for the header search box — the island (search-suggest.js)
|
|
@@ -12602,6 +12716,18 @@ function mount(router, deps) {
|
|
|
12602
12716
|
// most-viewed-products aggregate. Container-served only (anonymous PDPs
|
|
12603
12717
|
// are edge-cached and never reach here); fire-and-forget + drop-silent.
|
|
12604
12718
|
_recordAnalyticsEvent(req, { event_type: "pdp_view", product_id: product.id });
|
|
12719
|
+
// Search-ranking click — a PDP reached via a ranked result list carries
|
|
12720
|
+
// `?from=search&sq=<query>`. Defensive reader: a missing / unrelated
|
|
12721
|
+
// marker is the common case (direct nav, related-rail) and logs nothing;
|
|
12722
|
+
// a blank / over-long `sq` is dropped by the primitive's own validation
|
|
12723
|
+
// (caught drop-silent in _recordSearchClick). The click is attributed to
|
|
12724
|
+
// the active weight set, the same set new impressions log against, so the
|
|
12725
|
+
// admin search-metrics CTR stays coherent.
|
|
12726
|
+
if (deps.searchRanking && pdpUrl && pdpUrl.searchParams.get("from") === "search") {
|
|
12727
|
+
var clickQ = pdpUrl.searchParams.get("sq");
|
|
12728
|
+
if (typeof clickQ === "string" && clickQ.length > 200) clickQ = clickQ.slice(0, 200);
|
|
12729
|
+
_recordSearchClick(req, clickQ, product.id);
|
|
12730
|
+
}
|
|
12605
12731
|
_send(res, 200, html);
|
|
12606
12732
|
});
|
|
12607
12733
|
|
|
@@ -14393,16 +14519,34 @@ function mount(router, deps) {
|
|
|
14393
14519
|
if (cust && cust.id) customerId = cust.id;
|
|
14394
14520
|
} catch (_e) { customerId = null; }
|
|
14395
14521
|
|
|
14522
|
+
// Resolve the absolute origin while the request is in hand (the
|
|
14523
|
+
// mint + send below runs after the response, but reads no further
|
|
14524
|
+
// request state).
|
|
14525
|
+
var origin = "";
|
|
14526
|
+
try { origin = new URL(_requestUrls(req).canonical_url).origin; }
|
|
14527
|
+
catch (_e2) { origin = ""; }
|
|
14528
|
+
|
|
14529
|
+
// SECURITY (no account-existence oracle): send the generic
|
|
14530
|
+
// confirmation 303 BEFORE any matched-only work, so a registered
|
|
14531
|
+
// and an unregistered address are indistinguishable by response
|
|
14532
|
+
// latency. The session mint + the outbound email round-trip used
|
|
14533
|
+
// to run inside `if (customerId)` and be AWAITED before the 303 —
|
|
14534
|
+
// a non-matching email skipped both and answered near-instantly,
|
|
14535
|
+
// restoring exactly the account-existence oracle the identical
|
|
14536
|
+
// body is meant to remove. The work now runs fire-and-forget on
|
|
14537
|
+
// the next microtask, off the response's critical path; it stays
|
|
14538
|
+
// drop-silent and best-effort (a failure simply means the link
|
|
14539
|
+
// doesn't arrive), unchanged but for no longer being timed.
|
|
14540
|
+
res.status(303);
|
|
14541
|
+
res.setHeader && res.setHeader("location", "/account/login/link?sent=1");
|
|
14542
|
+
if (res.end) res.end(); else res.send("");
|
|
14543
|
+
|
|
14396
14544
|
if (customerId) {
|
|
14397
|
-
|
|
14545
|
+
Promise.resolve().then(async function () {
|
|
14398
14546
|
var minted = await deps.customerPortal.createSession({
|
|
14399
14547
|
customer_id: customerId,
|
|
14400
14548
|
scope: "full",
|
|
14401
14549
|
});
|
|
14402
|
-
// Build the absolute redemption link from this request's origin.
|
|
14403
|
-
var origin = "";
|
|
14404
|
-
try { origin = new URL(_requestUrls(req).canonical_url).origin; }
|
|
14405
|
-
catch (_e2) { origin = ""; }
|
|
14406
14550
|
var linkUrl = origin + "/account/portal/" + encodeURIComponent(minted.plaintext_token);
|
|
14407
14551
|
// The customer's plaintext address: the portal flow needs a
|
|
14408
14552
|
// deliverable address. The customers store keeps only the hash,
|
|
@@ -14412,12 +14556,8 @@ function mount(router, deps) {
|
|
|
14412
14556
|
customer_email: emailRaw,
|
|
14413
14557
|
link_url: linkUrl,
|
|
14414
14558
|
});
|
|
14415
|
-
}
|
|
14559
|
+
}).catch(function () { /* drop-silent — best-effort; the link simply doesn't arrive */ });
|
|
14416
14560
|
}
|
|
14417
|
-
// 303 to the GET with the generic confirmation flag.
|
|
14418
|
-
res.status(303);
|
|
14419
|
-
res.setHeader && res.setHeader("location", "/account/login/link?sent=1");
|
|
14420
|
-
return res.end ? res.end() : res.send("");
|
|
14421
14561
|
});
|
|
14422
14562
|
|
|
14423
14563
|
// GET — redeem the magic-link token. verifyToken is single-use (flips
|
|
@@ -14480,22 +14620,37 @@ function mount(router, deps) {
|
|
|
14480
14620
|
res.status(regCaptcha.status || 400);
|
|
14481
14621
|
return res.end ? res.end(regCaptcha.message) : res.send(regCaptcha.message);
|
|
14482
14622
|
}
|
|
14483
|
-
//
|
|
14484
|
-
//
|
|
14485
|
-
//
|
|
14486
|
-
//
|
|
14623
|
+
// SECURITY: the public passkey-register path creates NEW accounts
|
|
14624
|
+
// ONLY. It must never reuse — or mint a ceremony challenge bound to
|
|
14625
|
+
// — an email that already has an account, for ANY reason. Reusing
|
|
14626
|
+
// the row would let a visitor who merely knows the email finish the
|
|
14627
|
+
// WebAuthn ceremony on their own device and be issued a sign-in
|
|
14628
|
+
// session for the account's owner: account takeover. A row carrying
|
|
14629
|
+
// zero passkeys is NOT a safely-reusable "interrupted registration"
|
|
14630
|
+
// — a Google/Apple (OIDC) account and a guest-order row awaiting a
|
|
14631
|
+
// magic-link claim both have no passkey yet a real owner, and an
|
|
14632
|
+
// attacker enrolling onto either takes it over just the same. So
|
|
14633
|
+
// refuse uniformly on ANY existing account (the duplicate-email
|
|
14634
|
+
// refusal customers.register already enforces) and point to
|
|
14635
|
+
// sign-in. An owner adding another passkey uses the session-gated
|
|
14636
|
+
// /account/passkey/add-* flow; a passwordless owner — or one whose
|
|
14637
|
+
// first ceremony was interrupted — signs in with the magic-link.
|
|
14487
14638
|
var existing = await deps.customers.byEmailHash(
|
|
14488
14639
|
deps.customers.hashEmail(body.email),
|
|
14489
14640
|
);
|
|
14490
|
-
var customer;
|
|
14491
14641
|
if (existing) {
|
|
14492
|
-
|
|
14493
|
-
|
|
14494
|
-
|
|
14495
|
-
|
|
14496
|
-
|
|
14642
|
+
res.status(409);
|
|
14643
|
+
res.setHeader && res.setHeader("content-type", "application/json");
|
|
14644
|
+
var dupMsg = JSON.stringify({
|
|
14645
|
+
error: "account_exists",
|
|
14646
|
+
message: "An account with this email already exists. Sign in instead.",
|
|
14497
14647
|
});
|
|
14648
|
+
return res.end ? res.end(dupMsg) : res.send(dupMsg);
|
|
14498
14649
|
}
|
|
14650
|
+
var customer = await deps.customers.register({
|
|
14651
|
+
email: body.email,
|
|
14652
|
+
display_name: body.display_name,
|
|
14653
|
+
});
|
|
14499
14654
|
var startOpts = await b.auth.passkey.startRegistration({
|
|
14500
14655
|
rpName: rpName,
|
|
14501
14656
|
rpId: rpId,
|
package/package.json
CHANGED