@blamejs/blamejs-shop 0.4.12 → 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 CHANGED
@@ -8,6 +8,8 @@ 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
+
11
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 `&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
14
 
13
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.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.12",
2
+ "version": "0.4.13",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-6k53cvkRrxMgmeStLIoLjVXZQHqIJgTmv1Izd8TYhh1HOC4POgE6GCvx1bsalyEP",
package/lib/storefront.js CHANGED
@@ -14519,16 +14519,34 @@ function mount(router, deps) {
14519
14519
  if (cust && cust.id) customerId = cust.id;
14520
14520
  } catch (_e) { customerId = null; }
14521
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
+
14522
14544
  if (customerId) {
14523
- try {
14545
+ Promise.resolve().then(async function () {
14524
14546
  var minted = await deps.customerPortal.createSession({
14525
14547
  customer_id: customerId,
14526
14548
  scope: "full",
14527
14549
  });
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
14550
  var linkUrl = origin + "/account/portal/" + encodeURIComponent(minted.plaintext_token);
14533
14551
  // The customer's plaintext address: the portal flow needs a
14534
14552
  // deliverable address. The customers store keeps only the hash,
@@ -14538,12 +14556,8 @@ function mount(router, deps) {
14538
14556
  customer_email: emailRaw,
14539
14557
  link_url: linkUrl,
14540
14558
  });
14541
- } catch (_e3) { /* drop-silent — generic confirmation regardless */ }
14559
+ }).catch(function () { /* drop-silent — best-effort; the link simply doesn't arrive */ });
14542
14560
  }
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
14561
  });
14548
14562
 
14549
14563
  // GET — redeem the magic-link token. verifyToken is single-use (flips
@@ -14606,22 +14620,37 @@ function mount(router, deps) {
14606
14620
  res.status(regCaptcha.status || 400);
14607
14621
  return res.end ? res.end(regCaptcha.message) : res.send(regCaptcha.message);
14608
14622
  }
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.
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.
14613
14638
  var existing = await deps.customers.byEmailHash(
14614
14639
  deps.customers.hashEmail(body.email),
14615
14640
  );
14616
- var customer;
14617
14641
  if (existing) {
14618
- customer = existing;
14619
- } else {
14620
- customer = await deps.customers.register({
14621
- email: body.email,
14622
- display_name: body.display_name,
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.",
14623
14647
  });
14648
+ return res.end ? res.end(dupMsg) : res.send(dupMsg);
14624
14649
  }
14650
+ var customer = await deps.customers.register({
14651
+ email: body.email,
14652
+ display_name: body.display_name,
14653
+ });
14625
14654
  var startOpts = await b.auth.passkey.startRegistration({
14626
14655
  rpName: rpName,
14627
14656
  rpId: rpId,
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.13",
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": {