@blamejs/blamejs-shop 0.2.15 → 0.2.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +3 -1
  2. package/README.md +1 -1
  3. package/lib/asset-manifest.json +5 -1
  4. package/lib/customers.js +71 -4
  5. package/lib/storefront.js +462 -0
  6. package/lib/vendor/MANIFEST.json +2 -2
  7. package/lib/vendor/blamejs/CHANGELOG.md +20 -0
  8. package/lib/vendor/blamejs/api-snapshot.json +6 -2
  9. package/lib/vendor/blamejs/examples/wiki/docker-compose.prod.yml +29 -0
  10. package/lib/vendor/blamejs/examples/wiki/docker-compose.yml +7 -0
  11. package/lib/vendor/blamejs/lib/agent-idempotency.js +15 -3
  12. package/lib/vendor/blamejs/lib/agent-orchestrator.js +39 -0
  13. package/lib/vendor/blamejs/lib/app-shutdown.js +32 -0
  14. package/lib/vendor/blamejs/lib/bounded-map.js +102 -0
  15. package/lib/vendor/blamejs/lib/cache.js +184 -23
  16. package/lib/vendor/blamejs/lib/cert.js +68 -11
  17. package/lib/vendor/blamejs/lib/cluster-storage.js +114 -10
  18. package/lib/vendor/blamejs/lib/db.js +205 -15
  19. package/lib/vendor/blamejs/lib/dual-control.js +139 -143
  20. package/lib/vendor/blamejs/lib/i18n.js +10 -1
  21. package/lib/vendor/blamejs/lib/mail-crypto-smime.js +43 -9
  22. package/lib/vendor/blamejs/lib/network-dns.js +10 -2
  23. package/lib/vendor/blamejs/lib/nonce-store.js +39 -12
  24. package/lib/vendor/blamejs/lib/queue-local.js +2 -2
  25. package/lib/vendor/blamejs/lib/redis-client.js +14 -1
  26. package/lib/vendor/blamejs/lib/subject.js +8 -1
  27. package/lib/vendor/blamejs/package.json +1 -1
  28. package/lib/vendor/blamejs/release-notes/v0.13.33.json +26 -0
  29. package/lib/vendor/blamejs/release-notes/v0.13.34.json +48 -0
  30. package/lib/vendor/blamejs/release-notes/v0.13.35.json +35 -0
  31. package/lib/vendor/blamejs/release-notes/v0.13.36.json +27 -0
  32. package/lib/vendor/blamejs/release-notes/v0.13.37.json +18 -0
  33. package/lib/vendor/blamejs/release-notes/v0.13.38.json +27 -0
  34. package/lib/vendor/blamejs/release-notes/v0.13.39.json +27 -0
  35. package/lib/vendor/blamejs/release-notes/v0.13.40.json +22 -0
  36. package/lib/vendor/blamejs/release-notes/v0.13.41.json +27 -0
  37. package/lib/vendor/blamejs/release-notes/v0.13.42.json +18 -0
  38. package/lib/vendor/blamejs/test/20-db.js +191 -0
  39. package/lib/vendor/blamejs/test/layer-0-primitives/agent-idempotency.test.js +20 -0
  40. package/lib/vendor/blamejs/test/layer-0-primitives/agent-orchestrator.test.js +33 -0
  41. package/lib/vendor/blamejs/test/layer-0-primitives/api-encrypt.test.js +35 -0
  42. package/lib/vendor/blamejs/test/layer-0-primitives/app-shutdown.test.js +64 -0
  43. package/lib/vendor/blamejs/test/layer-0-primitives/bounded-map.test.js +87 -0
  44. package/lib/vendor/blamejs/test/layer-0-primitives/cache.test.js +48 -0
  45. package/lib/vendor/blamejs/test/layer-0-primitives/cert.test.js +170 -0
  46. package/lib/vendor/blamejs/test/layer-0-primitives/cluster-storage.test.js +125 -0
  47. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +92 -1
  48. package/lib/vendor/blamejs/test/layer-0-primitives/dual-control.test.js +32 -0
  49. package/lib/vendor/blamejs/test/layer-0-primitives/mail-crypto-smime.test.js +31 -0
  50. package/lib/vendor/blamejs/test/layer-0-primitives/redis-client.test.js +14 -0
  51. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -8,7 +8,9 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.2.x
10
10
 
11
- - v0.2.15 (2026-05-28) — **Configure tax, shipping, and discounts from the admin console.** Three commerce-configuration areas that previously existed only behind the JSON API now have admin console screens, so an operator can run a real store from the UI without scripting. Tax rates (per jurisdiction), shipping zones and rates, and discounts (automatic-discount rules plus coupon-stacking policies) each get a list / create / edit / archive screen, content-negotiated like the rest of the console (a bearer-token client still gets JSON). Their nav links appear only when the feature is wired. **Added:** *Tax rates screen* `/admin/tax-rates` pick a jurisdiction to see its rate history, create a rate (category + rate in basis points + effective window + source), edit the rate, and archive it. Overlap/archived conflicts surface as inline notices rather than errors. · *Shipping screen* — `/admin/shipping` create a shipping zone (country/region + a flat-rate service) and manage it on a per-zone screen that lists its rates, edits the title/active state, and archives it; richer multi-region/multi-rate shapes remain available on the JSON path. · *Discounts screen* — `/admin/discounts` create automatic-discount rules (cart-total / item-count / SKU triggers; percent-off / amount-off / free-shipping / BOGO values; priority), edit and archive them, plus manage coupon-stacking policies (max codes, combine toggles, order minimum).
11
+ - v0.2.17 (2026-05-29) — **Manage your passkeys and edit your profile from your account.** Signed-in customers can now manage their own passkeys and edit their profile, instead of only being able to enrol a passkey at registration. A new account screen lists each enrolled passkey (device transport + when it was added) with an add-another flow and a confirm-gated revoke; revocation is scoped to the signed-in account and refuses to remove your last sign-in method when no other (OAuth) method is linked, so you can't lock yourself out. A separate profile screen edits the display name. **Added:** *Passkey management* — `/account/passkeys` lists your enrolled passkeys, lets you register an additional one, and revoke one you no longer use (behind a confirmation step). Revocation only ever affects your own credentials, and the last remaining sign-in method is protected against removal. · *Profile editing* — `/account/profile` edits your display name with a confirmation notice. Account links for both screens were added to the account dashboard.
12
+
13
+ - v0.2.16 (2026-05-29) — **Configure tax, shipping, and discounts from the admin console.** Three commerce-configuration areas that previously existed only behind the JSON API now have admin console screens, so an operator can run a real store from the UI without scripting. Tax rates (per jurisdiction), shipping zones and rates, and discounts (automatic-discount rules plus coupon-stacking policies) each get a list / create / edit / archive screen, content-negotiated like the rest of the console (a bearer-token client still gets JSON). Their nav links appear only when the feature is wired. **Added:** *Tax rates screen* — `/admin/tax-rates` — pick a jurisdiction to see its rate history, create a rate (category + rate in basis points + effective window + source), edit the rate, and archive it. Overlap/archived conflicts surface as inline notices rather than errors. · *Shipping screen* — `/admin/shipping` — create a shipping zone (country/region + a flat-rate service) and manage it on a per-zone screen that lists its rates, edits the title/active state, and archives it; richer multi-region/multi-rate shapes remain available on the JSON path. · *Discounts screen* — `/admin/discounts` — create automatic-discount rules (cart-total / item-count / SKU triggers; percent-off / amount-off / free-shipping / BOGO values; priority), edit and archive them, plus manage coupon-stacking policies (max codes, combine toggles, order minimum).
12
14
 
13
15
  - v0.2.14 (2026-05-28) — **Manage products end-to-end from the admin console — variants, prices, and images.** The admin console can now build a complete, sellable product without dropping to the JSON API. Previously creating a product only captured its name, slug, status, and description — there was no way to add a variant, set a price, or attach an image from the console, so a product made there was not sellable. Each product now has a management screen (reachable from the product list) with variant create/edit/delete, per-variant price setting plus price history, and media attach / upload / delete, with confirmation steps for deletes. The screen is content-negotiated: a bearer-token API client still gets JSON (now including the full product model), a signed-in browser gets the management UI. **Added:** *Product management screen in the admin console* — Each product's title in the admin product list now links to a detail screen where an operator can edit the product's fields, add/edit/remove variants, set a variant's price and see its price history, and attach/upload/remove product images — the full path to a sellable product, all from the console. Destructive actions (remove variant/image) go through a confirmation step. · *Catalog read helpers* — `prices.currencies(variantId)` (the distinct currencies a variant is priced in) and `media.get(id)` (single media row) were added to support the screen. **Changed:** *`GET /admin/products/:id` is content-negotiated* — It previously returned JSON only; it now returns the management screen to a signed-in browser and a fuller JSON model (product + variants + prices + media) to a bearer-token client. The existing variant/price/media write endpoints are unchanged in contract — the new browser forms route to the same handlers.
14
16
 
package/README.md CHANGED
@@ -62,7 +62,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
62
62
  | **`lib/checkout.js`** | Orchestrator. `quote()` returns priced quote; `confirm()` creates a Stripe PaymentIntent + persists order pending; `handleStripeEvent()` verifies webhook + fires the FSM transition. PayPal path: `createPaypalOrder()` opens a PayPal order + persists pending, `capturePaypalOrder()` captures → paid, `handlePaypalEvent()` is the webhook backstop. All idempotent on re-delivery. |
63
63
  | **`lib/email.js`** | Transactional templates — order receipt, ship notification, refund confirmation. Strict `{{var}}` renderer with HTML escape + refusal of unknown / unused placeholders. Composed on `b.mail` (DKIM/SPF/DMARC/BIMI upstream). |
64
64
  | **`lib/storefront.js`** | Server-rendered HTML — utility bar + sticky header + dark hero with code-preview card + primitives marquee + featured-product callout + collections grid + framework feature band + designed catalog grid + newsletter band + four-column footer. Designed surfaces also for PDP, cart, checkout, pay, order, account login / register / dashboard, search results, `/admin` API landing, 404. Image-bearing cards on the home + search grids pull from `catalog.media`. The default theme stylesheet is external (R2-served `themes/default/assets/css/main.css`) and CSP-compliant; the typeface (Inter / Inter Tight) is self-hosted from `themes/default/assets/fonts`, so no page loads a cross-origin font. Operators override by uploading a replacement at the same key, by passing `opts.theme_css` to renderers, or by registering a named theme through the `theme` primitive. |
65
- | **`lib/customers.js`** | Customer accounts — passkey (WebAuthn) + **Sign in with Google / Apple** (OIDC). Email is stored hash-only (`b.crypto.namespaceHash` namespace `customer-email`); the raw address never lands in D1. Passkey credentials carry CBOR-encoded public keys, transport hints, and SHA3-512-fingerprinted attestation. `signInWithOIDC` keys federated accounts on the provider `(provider, subject)` and links an existing account only on a provider-verified email (never on an unverified one). `mintAppleClientSecret` produces Apple's required ES256 client-secret JWT from a Services-ID `.p8` key (the one classical signature the protocol mandates; the PQC default doesn't apply to an external IdP's wire format). Account routes (`/account/login`, `/account/register`, `/account`, `/account/login/google`, `/account/login/apple`) ship as designed cards on the storefront. |
65
+ | **`lib/customers.js`** | Customer accounts — passkey (WebAuthn) + **Sign in with Google / Apple** (OIDC). Email is stored hash-only (`b.crypto.namespaceHash` namespace `customer-email`); the raw address never lands in D1. Passkey credentials carry CBOR-encoded public keys, transport hints, and SHA3-512-fingerprinted attestation. `signInWithOIDC` keys federated accounts on the provider `(provider, subject)` and links an existing account only on a provider-verified email (never on an unverified one). `mintAppleClientSecret` produces Apple's required ES256 client-secret JWT from a Services-ID `.p8` key (the one classical signature the protocol mandates; the PQC default doesn't apply to an external IdP's wire format). Account routes (`/account/login`, `/account/register`, `/account`, `/account/login/google`, `/account/login/apple`) ship as designed cards on the storefront; signed-in customers manage their own passkeys (`/account/passkeys` — list, add another, confirm-gated revoke scoped to the account with a last-sign-in-method guard) and edit their profile (`/account/profile`). |
66
66
  | **`lib/reviews.js`** | Operator-moderated product ratings. Submission requires a signed-in customer **and** a verified purchase — `/products/:slug/review` confirms a completed order for the product (via `order.hasPurchasedProduct`) before accepting, re-checked on POST; reviews land `pending`. Author identity is hash-only (`b.crypto.namespaceHash`); the raw email is never stored. The PDP renders the average, per-star distribution, and published reviews with `AggregateRating` JSON-LD. `/admin/reviews` is the moderation queue (`listByStatus` → publish / reject). |
67
67
  | **`lib/product-qa.js`** | Customer questions and operator/customer answers per product, operator-moderated, distinct from the rating-based reviews. A signed-in shopper asks at `/products/:slug/question`; questions land `pending` and surface only after approval. Author identity is the customer id (verified against the customers primitive) or a hash-only email — the raw address is never stored. The product page renders approved questions with their approved answers (seller / customer / system badge, pinned 'top answer' first) in both the edge and container paths. `/admin/questions` is the moderation console: the cross-product queue (`listQuestionsByStatus`), and a per-question detail to approve / reject the question, post the seller answer (`submitAnswer`), approve / reject / pin answers. |
68
68
  | **`lib/wishlist.js`** | Per-customer saved products. The PDP renders a login-gated "Save to wishlist" toggle and a "N shoppers saved this" social-proof count; `/account/wishlist` lists saved items (remove + reopen, orphan-tolerant when a product is archived). `POST /wishlist/toggle` is idempotent (`INSERT OR IGNORE`) and redirects to the canonical product slug or a safe same-origin `return_to`. UUID-shape-validated ids, `b.pagination` HMAC cursors. |
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.2.15",
2
+ "version": "0.2.17",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-1SIn6oAf1DjECbRfKENZasdKHJiywGdXR58wn0hsFGcdVHzUmvfgMkEz5ANIAZJ3",
@@ -21,6 +21,10 @@
21
21
  "integrity": "sha384-XY3GHA5QDj/Nri03ZO1t7nsr9RGepZuanY45CTpFQnnTorQnygbtAVG4vVGfkNIP",
22
22
  "fingerprinted": "js/consent.59060d7723d050b2.js"
23
23
  },
24
+ "js/passkey-add.js": {
25
+ "integrity": "sha384-HSi+w0ampPZWcfPnnpV2Dkn/oGdgnyLe7QykGM5Sregud4CbH2KsU5Hb1FQQxJTy",
26
+ "fingerprinted": "js/passkey-add.c4b7350f2cac5477.js"
27
+ },
24
28
  "js/passkey-login.js": {
25
29
  "integrity": "sha384-65i1wVHIkiS2e0YiOV6oUOF9+tNR7s6QRvpnWaEne43P+B8UvKLTpDgp4MSKNGqX",
26
30
  "fingerprinted": "js/passkey-login.f3d6787bdd5e20df.js"
package/lib/customers.js CHANGED
@@ -505,14 +505,81 @@ function create(opts) {
505
505
  return row;
506
506
  },
507
507
 
508
- removePasskey: async function (passkeyId) {
508
+ // Revoke an enrolled credential, scoped to its owner. The customer
509
+ // id is part of the WHERE clause (not just the lookup) so a forged or
510
+ // guessed passkey id belonging to ANOTHER customer is a silent no-op
511
+ // rather than a cross-customer deletion — the route layer hands us the
512
+ // authed customer id, and a credential only disappears when both keys
513
+ // agree. Idempotent: returns true when a row was removed, false when
514
+ // nothing matched (already-removed id, or an id owned by someone else).
515
+ removePasskey: async function (customerId, passkeyId) {
516
+ _uuid(customerId, "customer id");
509
517
  _uuid(passkeyId, "passkey id");
510
- var row = (await query("SELECT customer_id FROM customer_passkeys WHERE id = ?1", [passkeyId])).rows[0];
518
+ var row = (await query(
519
+ "SELECT id FROM customer_passkeys WHERE id = ?1 AND customer_id = ?2",
520
+ [passkeyId, customerId],
521
+ )).rows[0];
511
522
  if (!row) return false;
512
- await query("DELETE FROM customer_passkeys WHERE id = ?1", [passkeyId]);
513
- await query("UPDATE customers SET updated_at = ?1 WHERE id = ?2", [_now(), row.customer_id]);
523
+ await query(
524
+ "DELETE FROM customer_passkeys WHERE id = ?1 AND customer_id = ?2",
525
+ [passkeyId, customerId],
526
+ );
527
+ await query("UPDATE customers SET updated_at = ?1 WHERE id = ?2", [_now(), customerId]);
514
528
  return true;
515
529
  },
530
+
531
+ // Mutate a customer's editable profile fields. v1 covers display_name
532
+ // only — the one field a customer can safely change without a
533
+ // verification round trip.
534
+ //
535
+ // Email is deliberately NOT updatable here. The address is stored
536
+ // hash-only (raw never lands in D1) AND it's the OAuth account-linking
537
+ // key: signInWithOIDC links a federated identity to an existing
538
+ // customer ONLY on a provider-verified email match, so silently
539
+ // re-pointing email_hash would let a customer claim a federated login
540
+ // that belongs to a different address — an account-takeover vector.
541
+ // A correct email change needs a proof-of-control ceremony (mail a
542
+ // one-time code to the NEW address, confirm, only then re-derive the
543
+ // hash). No transactional-mail surface is wired into the storefront
544
+ // yet, so shipping an unverified email change would be the
545
+ // half-measure the no-MVP rule forbids. Re-open when an outbound-mail
546
+ // primitive lands: add `verifyEmailChange` (issue + confirm token) and
547
+ // let `update` accept `email` only behind a confirmed token. A caller
548
+ // that passes `email` today is refused with a typed error so the gap
549
+ // is loud, never silent.
550
+ update: async function (id, patch) {
551
+ _uuid(id, "customer id");
552
+ if (!patch || typeof patch !== "object") {
553
+ throw new TypeError("customers.update: patch object required");
554
+ }
555
+ if (Object.prototype.hasOwnProperty.call(patch, "email")) {
556
+ var emailErr = new Error("customers.update: email cannot be changed without a verification ceremony — not supported in this build");
557
+ emailErr.code = "EMAIL_CHANGE_UNSUPPORTED";
558
+ throw emailErr;
559
+ }
560
+ var sets = [];
561
+ var params = [];
562
+ var p = 1;
563
+ if (Object.prototype.hasOwnProperty.call(patch, "display_name")) {
564
+ var displayName = _displayName(patch.display_name);
565
+ sets.push("display_name = ?" + p); params.push(displayName); p += 1;
566
+ }
567
+ if (sets.length === 0) {
568
+ throw new TypeError("customers.update: nothing to update (display_name is the only mutable field)");
569
+ }
570
+ var owner = (await query("SELECT id FROM customers WHERE id = ?1", [id])).rows[0];
571
+ if (!owner) {
572
+ throw new TypeError("customers.update: customer " + id + " not found");
573
+ }
574
+ var ts = _now();
575
+ sets.push("updated_at = ?" + p); params.push(ts); p += 1;
576
+ params.push(id);
577
+ await query(
578
+ "UPDATE customers SET " + sets.join(", ") + " WHERE id = ?" + p,
579
+ params,
580
+ );
581
+ return (await query("SELECT * FROM customers WHERE id = ?1", [id])).rows[0];
582
+ },
516
583
  };
517
584
  return api;
518
585
  }
package/lib/storefront.js CHANGED
@@ -4942,6 +4942,10 @@ var ACCOUNT_DASH_PAGE =
4942
4942
  " <a class=\"btn-secondary\" href=\"/account/loyalty\">Rewards</a>\n" +
4943
4943
  " <a class=\"btn-secondary\" href=\"/account/referrals\">Refer a friend</a>\n" +
4944
4944
  " <a class=\"btn-secondary\" href=\"/account/subscriptions\">Subscriptions</a>\n" +
4945
+ // begin: profile + passkey management actions
4946
+ " <a class=\"btn-secondary\" href=\"/account/profile\">Edit profile</a>\n" +
4947
+ " <a class=\"btn-secondary\" href=\"/account/passkeys\">Manage passkeys</a>\n" +
4948
+ // end: profile + passkey management actions
4945
4949
  " <form method=\"post\" action=\"/account/logout\"><button type=\"submit\" class=\"btn-ghost\">Sign out</button></form>\n" +
4946
4950
  " </div>\n" +
4947
4951
  " </header>\n" +
@@ -5046,6 +5050,200 @@ function renderAccount(opts) {
5046
5050
  });
5047
5051
  }
5048
5052
 
5053
+ // ---- passkey management ------------------------------------------------
5054
+ //
5055
+ // The signed-in customer's enrolled WebAuthn credentials, each with a
5056
+ // confirm-gated revoke, plus an "add another passkey" flow that reuses
5057
+ // the same begin/finish ceremony the registration page drives (here
5058
+ // bound to the AUTHED customer, so no email form is involved). Email is
5059
+ // stored hash-only and a passkey is the account's sign-in method, so the
5060
+ // list never leaks the address and the last-credential guard (enforced in
5061
+ // the route, surfaced here) keeps a customer from locking themselves out.
5062
+
5063
+ // Map the WebAuthn transports hint to a short human label for the list.
5064
+ function _transportLabel(t) {
5065
+ if (t === "internal") return "This device";
5066
+ if (t === "hybrid") return "Phone / nearby device";
5067
+ if (t === "usb") return "Security key (USB)";
5068
+ if (t === "nfc") return "Security key (NFC)";
5069
+ if (t === "ble") return "Security key (Bluetooth)";
5070
+ return t;
5071
+ }
5072
+
5073
+ function renderPasskeys(opts) {
5074
+ opts = opts || {};
5075
+ var esc = b.template.escapeHtml;
5076
+ var list = opts.passkeys || [];
5077
+ // Only offer per-credential revoke when removing it still leaves the
5078
+ // customer a way back in — another passkey, OR a linked OAuth identity.
5079
+ // When this is the last credential and there's no federated fallback,
5080
+ // the revoke control is replaced by a disabled note so the customer
5081
+ // can't lock themselves out. The route enforces the same rule server-
5082
+ // side; this is the matching display.
5083
+ var canRevokeAny = list.length > 1 || (opts.has_oauth === true && list.length >= 1);
5084
+ var rowsHtml = "";
5085
+ for (var i = 0; i < list.length; i += 1) {
5086
+ var p = list[i];
5087
+ var transports = (p.transports ? String(p.transports).split(",") : [])
5088
+ .filter(Boolean)
5089
+ .map(function (t) { return "<span class=\"passkey-card__transport\">" + esc(_transportLabel(t)) + "</span>"; })
5090
+ .join("");
5091
+ var added = "";
5092
+ if (p.created_at) {
5093
+ added = "<p class=\"passkey-card__meta\">Added " + esc(new Date(p.created_at).toISOString().slice(0, 10)) + "</p>";
5094
+ }
5095
+ // The credential handle is opaque; show a short fingerprint so the
5096
+ // customer can tell two devices apart without exposing the full key.
5097
+ var fingerprint = p.credential_id ? esc(String(p.credential_id).slice(0, 12)) : esc(String(p.id).slice(0, 12));
5098
+ var actionCell = canRevokeAny
5099
+ ? "<a class=\"btn-ghost btn-ghost--sm\" href=\"/account/passkeys/" + esc(String(p.id)) + "/remove\">Revoke</a>"
5100
+ : "<span class=\"passkey-card__last\" title=\"This is your only way to sign in\">Only sign-in method</span>";
5101
+ rowsHtml +=
5102
+ "<li class=\"passkey-card\">" +
5103
+ "<div class=\"passkey-card__body\">" +
5104
+ "<p class=\"passkey-card__id\"><code>" + fingerprint + "…</code></p>" +
5105
+ (transports ? "<p class=\"passkey-card__transports\">" + transports + "</p>" : "") +
5106
+ added +
5107
+ "</div>" +
5108
+ "<div class=\"passkey-card__actions\">" + actionCell + "</div>" +
5109
+ "</li>";
5110
+ }
5111
+ var listHtml = rowsHtml
5112
+ ? "<ul class=\"passkey-list\">" + rowsHtml + "</ul>"
5113
+ : "<div class=\"account-empty\">" +
5114
+ "<p class=\"account-empty__lede\">No passkeys enrolled. Add one below so you can sign in from this device.</p>" +
5115
+ "</div>";
5116
+ // Success / notice banners, driven by the ?ok=<kind> PRG redirect.
5117
+ var notice = opts.notice
5118
+ ? "<p class=\"form-notice form-notice--error\" role=\"alert\">" + esc(String(opts.notice)) + "</p>"
5119
+ : "";
5120
+ var success = opts.success
5121
+ ? "<div class=\"form-notice form-notice--ok\" role=\"status\"><span>" + esc(String(opts.success)) + "</span></div>"
5122
+ : "";
5123
+ // The "add another" control is an island-driven button (CSP forbids
5124
+ // inline script): it runs navigator.credentials.create against the
5125
+ // authed begin/finish endpoints. With JS off the button does nothing,
5126
+ // so it's framed as an enhancement, not the only path (a JS-off
5127
+ // customer who needs another device can re-register through the normal
5128
+ // flow — same credential lands on the same account by email).
5129
+ var addBlock =
5130
+ "<section class=\"passkey-add\">" +
5131
+ "<h2 class=\"account-addresses__form-title\">Add another passkey</h2>" +
5132
+ "<p class=\"passkey-add__lede\">Enroll a passkey on another device or security key so you can sign in from more than one place.</p>" +
5133
+ "<div class=\"form-actions\"><button type=\"button\" id=\"passkey-add-btn\" class=\"btn-primary\">Add a passkey</button></div>" +
5134
+ "<p id=\"passkey-add-message\" class=\"auth-form__message\"></p>" +
5135
+ "RAW_PASSKEY_ADD_SCRIPT" +
5136
+ "</section>";
5137
+ var body =
5138
+ "<section class=\"account-passkeys\">" +
5139
+ "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
5140
+ "<li><a href=\"/account\">Account</a></li>" +
5141
+ "<li aria-current=\"page\">Passkeys</li>" +
5142
+ "</ol></nav>" +
5143
+ "<h1 class=\"account-addresses__title\">Passkeys</h1>" +
5144
+ "<p class=\"section-head__lede\">Devices that can sign in to your account. Revoke any you no longer use.</p>" +
5145
+ success +
5146
+ notice +
5147
+ listHtml +
5148
+ addBlock +
5149
+ "</section>";
5150
+ body = body.replace("RAW_PASSKEY_ADD_SCRIPT", _islandScript("passkey-add.js"));
5151
+ return _wrap({
5152
+ title: "Passkeys",
5153
+ shop_name: opts.shop_name || "blamejs.shop",
5154
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
5155
+ theme_css: opts.theme_css,
5156
+ body: body,
5157
+ });
5158
+ }
5159
+
5160
+ // Server-rendered confirm step for revoking a passkey — CSP forbids an
5161
+ // inline confirm() dialog, so the destructive action is gated behind a
5162
+ // second page whose POST actually revokes. Mirrors renderAddressRemoveConfirm.
5163
+ function renderPasskeyRemoveConfirm(opts) {
5164
+ opts = opts || {};
5165
+ var esc = b.template.escapeHtml;
5166
+ var p = opts.passkey || {};
5167
+ var fingerprint = p.credential_id ? esc(String(p.credential_id).slice(0, 12)) : esc(String(p.id).slice(0, 12));
5168
+ var added = p.created_at
5169
+ ? "<p class=\"passkey-card__meta\">Added " + esc(new Date(p.created_at).toISOString().slice(0, 10)) + "</p>"
5170
+ : "";
5171
+ var body =
5172
+ "<section class=\"account-confirm\">" +
5173
+ "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
5174
+ "<li><a href=\"/account\">Account</a></li>" +
5175
+ "<li><a href=\"/account/passkeys\">Passkeys</a></li>" +
5176
+ "<li aria-current=\"page\">Revoke passkey</li>" +
5177
+ "</ol></nav>" +
5178
+ "<h1 class=\"account-confirm__title\">Revoke this passkey?</h1>" +
5179
+ "<p class=\"passkey-card__id\"><code>" + fingerprint + "…</code></p>" +
5180
+ added +
5181
+ "<p class=\"account-confirm__lede\">The device holding this passkey will no longer be able to sign in. " +
5182
+ "Make sure you still have another way into your account before revoking.</p>" +
5183
+ "<div class=\"account-confirm__actions\">" +
5184
+ "<form method=\"post\" action=\"/account/passkeys/" + esc(String(p.id)) + "/revoke\">" +
5185
+ "<button type=\"submit\" class=\"btn-primary\">Revoke passkey</button>" +
5186
+ "</form>" +
5187
+ "<a class=\"btn-ghost\" href=\"/account/passkeys\">Cancel</a>" +
5188
+ "</div>" +
5189
+ "</section>";
5190
+ return _wrap({
5191
+ title: "Revoke passkey",
5192
+ shop_name: opts.shop_name || "blamejs.shop",
5193
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
5194
+ theme_css: opts.theme_css,
5195
+ body: body,
5196
+ });
5197
+ }
5198
+
5199
+ // ---- profile edit ------------------------------------------------------
5200
+ //
5201
+ // Display-name edit for the signed-in customer. Email is stored hash-only
5202
+ // and is the OAuth account-linking key, so it's shown read-only (masked)
5203
+ // and cannot be changed here — the primitive refuses an email patch until
5204
+ // a verification ceremony exists. The form is a plain server-rendered
5205
+ // POST with a PRG `?ok=updated` success notice, matching the addresses
5206
+ // pattern.
5207
+ function renderProfile(opts) {
5208
+ opts = opts || {};
5209
+ var esc = b.template.escapeHtml;
5210
+ var customer = opts.customer || {};
5211
+ var notice = opts.notice
5212
+ ? "<p class=\"form-notice form-notice--error\" role=\"alert\">" + esc(String(opts.notice)) + "</p>"
5213
+ : "";
5214
+ var success = opts.success
5215
+ ? "<div class=\"form-notice form-notice--ok\" role=\"status\"><span>" + esc(String(opts.success)) + "</span></div>"
5216
+ : "";
5217
+ var displayValue = esc(String(customer.display_name == null ? "" : customer.display_name));
5218
+ var body =
5219
+ "<section class=\"account-profile\">" +
5220
+ "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
5221
+ "<li><a href=\"/account\">Account</a></li>" +
5222
+ "<li aria-current=\"page\">Profile</li>" +
5223
+ "</ol></nav>" +
5224
+ "<h1 class=\"account-addresses__title\">Edit profile</h1>" +
5225
+ success +
5226
+ notice +
5227
+ "<form method=\"post\" action=\"/account/profile\" class=\"form-stack\">" +
5228
+ "<div class=\"form-row\"><label class=\"form-field\"><span class=\"form-field__label\">Display name</span>" +
5229
+ "<input type=\"text\" name=\"display_name\" maxlength=\"128\" required autocomplete=\"name\" value=\"" + displayValue + "\"></label></div>" +
5230
+ "<div class=\"form-row\"><label class=\"form-field\"><span class=\"form-field__label\">Email</span>" +
5231
+ "<input type=\"text\" value=\"Hidden for privacy — stored as a one-way hash\" disabled aria-describedby=\"email-note\"></label></div>" +
5232
+ "<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. " +
5233
+ "Sign in with the address you registered.</p>" +
5234
+ "<div class=\"form-actions\"><button type=\"submit\" class=\"btn-primary\">Save changes</button> " +
5235
+ "<a class=\"btn-ghost\" href=\"/account\">Cancel</a></div>" +
5236
+ "</form>" +
5237
+ "</section>";
5238
+ return _wrap({
5239
+ title: "Edit profile",
5240
+ shop_name: opts.shop_name || "blamejs.shop",
5241
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
5242
+ theme_css: opts.theme_css,
5243
+ body: body,
5244
+ });
5245
+ }
5246
+
5049
5247
  // ---- customer survey page ----------------------------------------------
5050
5248
 
5051
5249
  // Render one survey question as a fieldset. Rating → a 0/1..max radio
@@ -7551,6 +7749,267 @@ function mount(router, deps) {
7551
7749
  return res.end ? res.end() : res.send("");
7552
7750
  });
7553
7751
 
7752
+ // ---- passkey self-management + profile edit ----------------------
7753
+ //
7754
+ // The signed-in customer manages their own credentials: list enrolled
7755
+ // passkeys, revoke one (confirm-gated), or enroll another device. The
7756
+ // revoke is guarded so a customer can't strip away their last sign-in
7757
+ // method when they have no federated (OAuth) fallback — that would lock
7758
+ // them out. The add flow reuses the WebAuthn begin/finish ceremony the
7759
+ // registration page drives, but bound to the ALREADY-authed customer
7760
+ // (no email form), so a new credential always lands on the right
7761
+ // account.
7762
+ function _accountAuth(req, res) {
7763
+ var auth;
7764
+ try { auth = _currentCustomer(req); }
7765
+ catch (e) {
7766
+ if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
7767
+ throw e;
7768
+ }
7769
+ if (!auth) {
7770
+ res.status(303); res.setHeader && res.setHeader("location", "/account/login");
7771
+ res.end ? res.end() : res.send("");
7772
+ return null;
7773
+ }
7774
+ return auth;
7775
+ }
7776
+
7777
+ // Does this customer have a federated (OAuth) sign-in to fall back on?
7778
+ // Used as the "is it safe to revoke the last passkey" gate. Composes
7779
+ // the existing batched sign-in-methods aggregate (no bespoke query).
7780
+ async function _hasOAuthFallback(customerId) {
7781
+ var methods = await deps.customers.signInMethodsByCustomer([customerId]);
7782
+ var providers = methods.oauth[customerId];
7783
+ return Array.isArray(providers) && providers.length > 0;
7784
+ }
7785
+
7786
+ function _passkeySuccessCopy(kind) {
7787
+ if (kind === "revoked") return "Passkey revoked.";
7788
+ if (kind === "added") return "Passkey added.";
7789
+ return null;
7790
+ }
7791
+
7792
+ async function _renderPasskeysPage(req, res, auth, notice, code) {
7793
+ var pks = await deps.customers.listPasskeys(auth.customer_id);
7794
+ var hasOAuth = await _hasOAuthFallback(auth.customer_id);
7795
+ var cartCount = await _cartCountForReq(req);
7796
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
7797
+ var okKind = url ? url.searchParams.get("ok") : null;
7798
+ _send(res, code || 200, renderPasskeys({
7799
+ passkeys: pks,
7800
+ has_oauth: hasOAuth,
7801
+ notice: notice || null,
7802
+ success: _passkeySuccessCopy(okKind),
7803
+ shop_name: shopName,
7804
+ cart_count: cartCount,
7805
+ }));
7806
+ }
7807
+
7808
+ // Resolve a passkey by path id AND confirm it belongs to the authed
7809
+ // customer. A non-UUID segment or a credential owned by someone else
7810
+ // is a 404 — never a cross-customer reveal.
7811
+ async function _ownedPasskey(req, res, auth) {
7812
+ var pks;
7813
+ try { pks = await deps.customers.listPasskeys(auth.customer_id); }
7814
+ catch (e) { throw e; }
7815
+ var id = req.params && req.params.id;
7816
+ for (var i = 0; i < pks.length; i += 1) {
7817
+ if (pks[i].id === id) return pks[i];
7818
+ }
7819
+ _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
7820
+ return null;
7821
+ }
7822
+
7823
+ router.get("/account/passkeys", async function (req, res) {
7824
+ var auth = _accountAuth(req, res); if (!auth) return;
7825
+ await _renderPasskeysPage(req, res, auth, null);
7826
+ });
7827
+
7828
+ // Revoke is destructive + CSP forbids confirm(), so it routes through a
7829
+ // server-rendered confirm page; the POST that actually revokes lives
7830
+ // behind it.
7831
+ router.get("/account/passkeys/:id/remove", async function (req, res) {
7832
+ var auth = _accountAuth(req, res); if (!auth) return;
7833
+ var pk = await _ownedPasskey(req, res, auth); if (!pk) return;
7834
+ var cartCount = await _cartCountForReq(req);
7835
+ _send(res, 200, renderPasskeyRemoveConfirm({
7836
+ passkey: pk,
7837
+ shop_name: shopName,
7838
+ cart_count: cartCount,
7839
+ }));
7840
+ });
7841
+
7842
+ router.post("/account/passkeys/:id/revoke", async function (req, res) {
7843
+ var auth = _accountAuth(req, res); if (!auth) return;
7844
+ var pk = await _ownedPasskey(req, res, auth); if (!pk) return;
7845
+ // Last-credential guard: refuse to remove the only sign-in method
7846
+ // when there's no federated fallback — surface a clear notice rather
7847
+ // than silently locking the customer out.
7848
+ var pks = await deps.customers.listPasskeys(auth.customer_id);
7849
+ if (pks.length <= 1 && !(await _hasOAuthFallback(auth.customer_id))) {
7850
+ return _renderPasskeysPage(
7851
+ req, res, auth,
7852
+ "That's your only way to sign in — add another passkey (or link a Google / Apple account) before revoking this one.",
7853
+ 409,
7854
+ );
7855
+ }
7856
+ try {
7857
+ await deps.customers.removePasskey(auth.customer_id, pk.id);
7858
+ } catch (e) {
7859
+ if (e instanceof TypeError) return _renderPasskeysPage(req, res, auth, (e && e.message) || "Could not revoke that passkey.", 400);
7860
+ throw e;
7861
+ }
7862
+ res.status(303); res.setHeader && res.setHeader("location", "/account/passkeys?ok=revoked");
7863
+ return res.end ? res.end() : res.send("");
7864
+ });
7865
+
7866
+ // Add-another-passkey ceremony — same begin/finish shape as
7867
+ // registration, but for the AUTHED customer (no email form). The
7868
+ // challenge cookie carries kind "add" + the customer id; finish gates
7869
+ // on both so an add-finish can't be replayed against a register/login
7870
+ // challenge.
7871
+ router.post("/account/passkey/add-begin", async function (req, res) {
7872
+ var auth = _accountAuth(req, res); if (!auth) return;
7873
+ try {
7874
+ var customer = await deps.customers.get(auth.customer_id);
7875
+ if (!customer) { res.status(401); return res.end ? res.end("unknown customer") : res.send("unknown customer"); }
7876
+ // Exclude already-enrolled credentials so the authenticator won't
7877
+ // create a duplicate on the same device.
7878
+ var existing = await deps.customers.listPasskeys(customer.id);
7879
+ var exclude = existing.map(function (p) {
7880
+ return {
7881
+ id: p.credential_id,
7882
+ type: "public-key",
7883
+ transports: p.transports ? p.transports.split(",") : undefined,
7884
+ };
7885
+ });
7886
+ var startOpts = await b.auth.passkey.startRegistration({
7887
+ rpName: rpName,
7888
+ rpId: rpId,
7889
+ userName: customer.email_hash.slice(0, 16),
7890
+ userDisplayName: customer.display_name,
7891
+ attestationType: "none",
7892
+ excludeCredentials: exclude,
7893
+ });
7894
+ _setChallengeCookie(res, {
7895
+ kind: "add",
7896
+ customer_id: customer.id,
7897
+ challenge: startOpts.challenge,
7898
+ created_at: Date.now(),
7899
+ });
7900
+ res.status(200);
7901
+ res.setHeader && res.setHeader("content-type", "application/json");
7902
+ return res.end ? res.end(JSON.stringify(startOpts)) : res.send(JSON.stringify(startOpts));
7903
+ } catch (e) {
7904
+ if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
7905
+ res.status(e instanceof TypeError ? 400 : 500);
7906
+ return res.end ? res.end((e && e.message) || "add-begin failed") : res.send((e && e.message) || "add-begin failed");
7907
+ }
7908
+ });
7909
+
7910
+ router.post("/account/passkey/add-finish", async function (req, res) {
7911
+ var auth = _accountAuth(req, res); if (!auth) return;
7912
+ try {
7913
+ var env = _readChallengeEnv(req);
7914
+ if (!env) { res.status(400); return res.end ? res.end("missing challenge") : res.send("missing challenge"); }
7915
+ if (env.kind !== "add") { res.status(400); return res.end ? res.end("bad challenge") : res.send("bad challenge"); }
7916
+ // The challenge must belong to the customer driving this request —
7917
+ // a stolen "add" challenge can't enroll a credential elsewhere.
7918
+ if (env.customer_id !== auth.customer_id) {
7919
+ res.status(403); return res.end ? res.end("challenge / account mismatch") : res.send("challenge / account mismatch");
7920
+ }
7921
+ var att = _readJsonBody(req);
7922
+ var rv = await b.auth.passkey.verifyRegistration({
7923
+ response: att,
7924
+ expectedChallenge: env.challenge,
7925
+ expectedOrigin: expectedOrigin,
7926
+ expectedRPID: rpId,
7927
+ });
7928
+ if (!rv || !rv.verified) {
7929
+ res.status(400); return res.end ? res.end("attestation refused") : res.send("attestation refused");
7930
+ }
7931
+ var info = rv.registrationInfo || {};
7932
+ var credentialId = info.credentialID || att.rawId || att.id;
7933
+ var publicKey = info.credentialPublicKey;
7934
+ if (credentialId && typeof credentialId !== "string") credentialId = _b64u(credentialId);
7935
+ if (publicKey && typeof publicKey !== "string") publicKey = _b64u(publicKey);
7936
+ var transports = "";
7937
+ if (att.response && Array.isArray(att.response.transports)) {
7938
+ transports = att.response.transports.filter(function (t) { return /^[a-z]+$/.test(t); }).join(",");
7939
+ }
7940
+ try {
7941
+ await deps.customers.addPasskey(env.customer_id, {
7942
+ credential_id: credentialId,
7943
+ public_key: publicKey,
7944
+ counter: info.counter || 0,
7945
+ transports: transports,
7946
+ });
7947
+ } catch (e) {
7948
+ if (e && e.code === "PASSKEY_DUPLICATE") {
7949
+ res.status(409); return res.end ? res.end("credential already registered") : res.send("credential already registered");
7950
+ }
7951
+ throw e;
7952
+ }
7953
+ _clearChallengeCookie(res);
7954
+ res.status(200);
7955
+ return res.end ? res.end("ok") : res.send("ok");
7956
+ } catch (e) {
7957
+ if (e && e.code === "vault/not-initialized") return _serviceUnavailable(res, "auth not configured");
7958
+ res.status(e instanceof TypeError ? 400 : 500);
7959
+ return res.end ? res.end((e && e.message) || "add-finish failed") : res.send((e && e.message) || "add-finish failed");
7960
+ }
7961
+ });
7962
+
7963
+ // Profile edit — display-name only. Email is hash-only + the OAuth
7964
+ // linking key, so the primitive refuses an email change without a
7965
+ // verification ceremony; the form shows it read-only. PRG with a
7966
+ // ?ok=updated success notice, matching the addresses pattern.
7967
+ async function _renderProfilePage(req, res, auth, customer, notice, code) {
7968
+ var cartCount = await _cartCountForReq(req);
7969
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
7970
+ var okKind = url ? url.searchParams.get("ok") : null;
7971
+ _send(res, code || 200, renderProfile({
7972
+ customer: customer,
7973
+ notice: notice || null,
7974
+ success: okKind === "updated" ? "Profile updated." : null,
7975
+ shop_name: shopName,
7976
+ cart_count: cartCount,
7977
+ }));
7978
+ }
7979
+
7980
+ router.get("/account/profile", async function (req, res) {
7981
+ var auth = _accountAuth(req, res); if (!auth) return;
7982
+ var customer = await deps.customers.get(auth.customer_id);
7983
+ if (!customer) {
7984
+ _clearAuthCookie(res);
7985
+ res.status(303); res.setHeader && res.setHeader("location", "/account/login");
7986
+ return res.end ? res.end() : res.send("");
7987
+ }
7988
+ await _renderProfilePage(req, res, auth, customer, null);
7989
+ });
7990
+
7991
+ router.post("/account/profile", async function (req, res) {
7992
+ var auth = _accountAuth(req, res); if (!auth) return;
7993
+ var customer = await deps.customers.get(auth.customer_id);
7994
+ if (!customer) {
7995
+ _clearAuthCookie(res);
7996
+ res.status(303); res.setHeader && res.setHeader("location", "/account/login");
7997
+ return res.end ? res.end() : res.send("");
7998
+ }
7999
+ var body = req.body || {};
8000
+ try {
8001
+ await deps.customers.update(auth.customer_id, { display_name: body.display_name });
8002
+ } catch (e) {
8003
+ if (e instanceof TypeError) {
8004
+ var merged = Object.assign({}, customer, { display_name: body.display_name });
8005
+ return _renderProfilePage(req, res, auth, merged, (e && e.message) || "Please check the form.", 400);
8006
+ }
8007
+ throw e;
8008
+ }
8009
+ res.status(303); res.setHeader && res.setHeader("location", "/account/profile?ok=updated");
8010
+ return res.end ? res.end() : res.send("");
8011
+ });
8012
+
7554
8013
  // Sign in with Google (OIDC). Mounts when the operator wires an
7555
8014
  // `oauthGoogle` adapter (b.auth.oauth, google preset). The framework
7556
8015
  // adapter owns discovery + PKCE + ID-token verification (signature,
@@ -9433,6 +9892,9 @@ module.exports = {
9433
9892
  renderAccountLogin: renderAccountLogin,
9434
9893
  renderAccountRegister: renderAccountRegister,
9435
9894
  renderAccount: renderAccount,
9895
+ renderPasskeys: renderPasskeys,
9896
+ renderPasskeyRemoveConfirm: renderPasskeyRemoveConfirm,
9897
+ renderProfile: renderProfile,
9436
9898
  renderAccountSubscriptions: renderAccountSubscriptions,
9437
9899
  renderCookiePreferences: renderCookiePreferences,
9438
9900
  renderNotFound: renderNotFound,
@@ -3,8 +3,8 @@
3
3
  "_about": "blamejs.shop vendors a single framework — blamejs — which itself bundles every server-side crypto/identity dependency. The transitive packages blamejs ships are surfaced in its own MANIFEST.json at lib/vendor/blamejs/lib/vendor/MANIFEST.json — Trivy / Grype rely on that nested data for CVE attribution.",
4
4
  "packages": {
5
5
  "blamejs": {
6
- "version": "0.13.32",
7
- "tag": "v0.13.32",
6
+ "version": "0.13.42",
7
+ "tag": "v0.13.42",
8
8
  "license": "Apache-2.0",
9
9
  "author": "blamejs contributors",
10
10
  "source": "https://github.com/blamejs/blamejs",