@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.
- package/CHANGELOG.md +3 -1
- package/README.md +1 -1
- package/lib/asset-manifest.json +5 -1
- package/lib/customers.js +71 -4
- package/lib/storefront.js +462 -0
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +20 -0
- package/lib/vendor/blamejs/api-snapshot.json +6 -2
- package/lib/vendor/blamejs/examples/wiki/docker-compose.prod.yml +29 -0
- package/lib/vendor/blamejs/examples/wiki/docker-compose.yml +7 -0
- package/lib/vendor/blamejs/lib/agent-idempotency.js +15 -3
- package/lib/vendor/blamejs/lib/agent-orchestrator.js +39 -0
- package/lib/vendor/blamejs/lib/app-shutdown.js +32 -0
- package/lib/vendor/blamejs/lib/bounded-map.js +102 -0
- package/lib/vendor/blamejs/lib/cache.js +184 -23
- package/lib/vendor/blamejs/lib/cert.js +68 -11
- package/lib/vendor/blamejs/lib/cluster-storage.js +114 -10
- package/lib/vendor/blamejs/lib/db.js +205 -15
- package/lib/vendor/blamejs/lib/dual-control.js +139 -143
- package/lib/vendor/blamejs/lib/i18n.js +10 -1
- package/lib/vendor/blamejs/lib/mail-crypto-smime.js +43 -9
- package/lib/vendor/blamejs/lib/network-dns.js +10 -2
- package/lib/vendor/blamejs/lib/nonce-store.js +39 -12
- package/lib/vendor/blamejs/lib/queue-local.js +2 -2
- package/lib/vendor/blamejs/lib/redis-client.js +14 -1
- package/lib/vendor/blamejs/lib/subject.js +8 -1
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.13.33.json +26 -0
- package/lib/vendor/blamejs/release-notes/v0.13.34.json +48 -0
- package/lib/vendor/blamejs/release-notes/v0.13.35.json +35 -0
- package/lib/vendor/blamejs/release-notes/v0.13.36.json +27 -0
- package/lib/vendor/blamejs/release-notes/v0.13.37.json +18 -0
- package/lib/vendor/blamejs/release-notes/v0.13.38.json +27 -0
- package/lib/vendor/blamejs/release-notes/v0.13.39.json +27 -0
- package/lib/vendor/blamejs/release-notes/v0.13.40.json +22 -0
- package/lib/vendor/blamejs/release-notes/v0.13.41.json +27 -0
- package/lib/vendor/blamejs/release-notes/v0.13.42.json +18 -0
- package/lib/vendor/blamejs/test/20-db.js +191 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/agent-idempotency.test.js +20 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/agent-orchestrator.test.js +33 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/api-encrypt.test.js +35 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/app-shutdown.test.js +64 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/bounded-map.test.js +87 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/cache.test.js +48 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/cert.test.js +170 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/cluster-storage.test.js +125 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +92 -1
- package/lib/vendor/blamejs/test/layer-0-primitives/dual-control.test.js +32 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/mail-crypto-smime.test.js +31 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/redis-client.test.js +14 -0
- 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.
|
|
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. |
|
package/lib/asset-manifest.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.2.
|
|
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
|
-
|
|
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(
|
|
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(
|
|
513
|
-
|
|
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,
|
package/lib/vendor/MANIFEST.json
CHANGED
|
@@ -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.
|
|
7
|
-
"tag": "v0.13.
|
|
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",
|