@blamejs/blamejs-shop 0.4.74 → 0.4.76

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 (50) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/lib/asset-manifest.json +1 -1
  3. package/lib/customers.js +30 -0
  4. package/lib/storefront.js +150 -40
  5. package/lib/vendor/MANIFEST.json +48 -44
  6. package/lib/vendor/blamejs/CHANGELOG.md +2 -0
  7. package/lib/vendor/blamejs/api-snapshot.json +23 -2
  8. package/lib/vendor/blamejs/examples/wiki/lib/build-app.js +5 -1
  9. package/lib/vendor/blamejs/examples/wiki/lib/harvest-vendored-deps.js +6 -1
  10. package/lib/vendor/blamejs/examples/wiki/test/codebase-patterns.test.js +8 -4
  11. package/lib/vendor/blamejs/index.js +2 -0
  12. package/lib/vendor/blamejs/lib/auth/ciba.js +32 -8
  13. package/lib/vendor/blamejs/lib/auth/dpop.js +9 -0
  14. package/lib/vendor/blamejs/lib/auth/fido-mds3.js +25 -12
  15. package/lib/vendor/blamejs/lib/auth/jwt.js +19 -3
  16. package/lib/vendor/blamejs/lib/auth/oauth.js +8 -2
  17. package/lib/vendor/blamejs/lib/auth/saml.js +19 -9
  18. package/lib/vendor/blamejs/lib/crypto-field.js +19 -1
  19. package/lib/vendor/blamejs/lib/csp.js +9 -0
  20. package/lib/vendor/blamejs/lib/db-query.js +33 -2
  21. package/lib/vendor/blamejs/lib/mail-auth.js +24 -1
  22. package/lib/vendor/blamejs/lib/mail-dkim.js +20 -7
  23. package/lib/vendor/blamejs/lib/middleware/compose-pipeline.js +39 -5
  24. package/lib/vendor/blamejs/lib/pipl-cn.js +11 -8
  25. package/lib/vendor/blamejs/lib/request-helpers.js +146 -13
  26. package/lib/vendor/blamejs/lib/safe-json.js +26 -0
  27. package/lib/vendor/blamejs/lib/session.js +35 -117
  28. package/lib/vendor/blamejs/lib/sql.js +22 -0
  29. package/lib/vendor/blamejs/lib/ws-client.js +26 -0
  30. package/lib/vendor/blamejs/lib/x509-chain.js +71 -24
  31. package/lib/vendor/blamejs/package.json +1 -1
  32. package/lib/vendor/blamejs/release-notes/v0.15.15.json +73 -0
  33. package/lib/vendor/blamejs/test/00-primitives.js +54 -0
  34. package/lib/vendor/blamejs/test/layer-0-primitives/auth-jwt-defenses.test.js +22 -0
  35. package/lib/vendor/blamejs/test/layer-0-primitives/ciba-authreqid-binding.test.js +130 -0
  36. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +37 -0
  37. package/lib/vendor/blamejs/test/layer-0-primitives/csp-builder.test.js +21 -0
  38. package/lib/vendor/blamejs/test/layer-0-primitives/db-raw-residency-gate.test.js +22 -0
  39. package/lib/vendor/blamejs/test/layer-0-primitives/fido-mds3.test.js +40 -1
  40. package/lib/vendor/blamejs/test/layer-0-primitives/mail-auth.test.js +29 -0
  41. package/lib/vendor/blamejs/test/layer-0-primitives/mail-dkim.test.js +46 -0
  42. package/lib/vendor/blamejs/test/layer-0-primitives/middleware-compose-pipeline.test.js +74 -0
  43. package/lib/vendor/blamejs/test/layer-0-primitives/pipl-cn.test.js +12 -0
  44. package/lib/vendor/blamejs/test/layer-0-primitives/request-helpers.test.js +46 -0
  45. package/lib/vendor/blamejs/test/layer-0-primitives/saml-subjectconfirmation-notonorafter.test.js +77 -0
  46. package/lib/vendor/blamejs/test/layer-0-primitives/session-extensions.test.js +74 -0
  47. package/lib/vendor/blamejs/test/layer-0-primitives/sql.test.js +19 -0
  48. package/lib/vendor/blamejs/test/layer-0-primitives/ws-client.test.js +32 -0
  49. package/lib/vendor/blamejs/test/layer-0-primitives/x509-chain-ca-enforcement.test.js +11 -0
  50. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.4.x
10
10
 
11
+ - v0.4.76 (2026-06-21) — **Account security hardening — revoking a passkey ends the account's other sessions, and registration no longer creates an account before the security key is enrolled.** Four hardenings on the customer account surface. Revoking a passkey now ends every other live session for the account — including pending magic-link sign-in tokens — while keeping the device that performed the revoke signed in; previously the credential was deleted but a sealed session cookie a lost or compromised authenticator had already established stayed valid for its full 14-day life. Signing out everywhere now also revokes any unredeemed magic-link token, so a link mailed before sign-out can no longer sign the account back in. Passkey registration no longer creates the customer record until the security-key ceremony actually completes: the pending sign-up is carried in the sealed challenge cookie and the account is created only on a verified finish, so an abandoned or scripted registration can no longer reserve an email address or leave empty accounts behind. And passkey sign-in performs the same credential lookup whether or not the address has an account, removing a response-time difference that could be used to test which emails are registered. No configuration changes; upgrade to pick them up. **Security:** *Revoking a passkey ends the account's other sessions and pending sign-in links* — Revoking a passkey is a response to a lost or compromised authenticator, so it now moves the account's server-side session-revocation boundary forward — invalidating every sealed auth cookie issued before the revoke, including one the compromised authenticator established — and revokes any pending customer-portal (magic-link) token. The browser that performed the revoke is re-issued a fresh cookie so it stays signed in; every other device is signed out on its next request. Previously only the credential row was deleted, leaving already-issued session cookies valid for their full 14-day life. · *Signing out everywhere also revokes pending magic-link tokens* — POST /account/logout already moved the session-revocation boundary forward to kill live sealed cookies; it now also revokes any unredeemed magic-link / customer-portal token for the account. An emailed sign-in link issued before the sign-out can no longer be redeemed to sign the account back in. · *Passkey registration creates the account only after the security key is enrolled* — The public passkey register-begin endpoint no longer writes a customer record before the WebAuthn ceremony completes. The pending sign-up (email + display name + challenge) is sealed into the tamper-proof challenge cookie, and the account is created only on a verified register-finish, where the duplicate-email check runs atomically to settle a begin/finish race. An abandoned or scripted register-begin can no longer reserve (squat) an email address or leave behind empty accounts. · *Passkey sign-in does not reveal whether an email is registered via timing* — Passkey login-begin now runs the same credential lookup on both paths — against the real account for a registered address, against a fixed non-matching id otherwise — so a registered address no longer takes measurably longer to respond. The challenge response shape was already identical on both paths; this closes the remaining response-time signal that could enumerate registered emails.
12
+
13
+ - v0.4.75 (2026-06-21) — **Bundled framework refreshed to blamejs 0.15.15 — more authentication checks fail closed and a blocked-traffic memory leak is fixed.** Refreshes the vendored framework the store is built on from blamejs 0.15.14 to 0.15.15, a correctness-and-hardening release. The protections the store already composes now refuse more malformed or unbound credentials, and a request-pipeline leak under sustained blocked traffic is fixed. The most directly felt change for an operator: when the bot guard, rate limiter, or CSRF check halts a request by writing its response without continuing the chain, the pipeline now settles instead of holding the request closure open forever — under a flood of blocked requests that previously grew memory without bound. On the sign-in surface, the passkey, OAuth, and any token verifiers the store routes through inherit stricter checks: an OAuth ID-token nonce is no longer skipped when empty, an authenticator's current certification status is honored rather than the highest it ever held, a JWT issuer presented as an array no longer satisfies a single-issuer expectation, and a malformed JOSE header raises a typed error instead of crashing the verifier. The content-security-policy builder now refuses a source value containing whitespace or a semicolon (so a malformed provider host can't smuggle a second directive), the SQL builder refuses an always-false equality against NULL and validates every element of an IN-list, and the WebSocket client bounds a fragmented message as it arrives rather than only at the final frame. No store configuration changes; upgrade to pick up the refreshed framework. **Changed:** *Bundled framework updated to blamejs 0.15.15* — The vendored framework is refreshed from 0.15.14 to 0.15.15. The store carries zero runtime npm dependencies and builds entirely on this bundled framework, so its correctness and hardening improvements apply to the store on upgrade with no configuration change. **Security:** *A blocked request no longer leaks its closure for the life of the process* — When the bot guard, rate limiter, CSRF check, or any middleware halts a request by writing the response without advancing the chain — the intended way to block — the request pipeline now settles its promise instead of leaving it pending forever. Previously each blocked request retained its request/response closure, so sustained blocked traffic grew memory without bound. The store's request lifecycle composes this pipeline directly, so the fix applies to every blocked request. · *Sign-in credential verifiers fail closed on more malformed inputs* — The authentication primitives the store routes sign-in through now refuse more unbound or malformed credentials: an OAuth ID-token nonce check is no longer skipped when the supplied nonce is empty, an authenticator's current certification status is honored over the highest level it ever held, a JWT issuer presented as an array no longer satisfies a single-issuer expectation, and a JOSE header or payload that decodes to a non-object raises a typed authentication error instead of an uncaught crash. · *The content-security-policy builder refuses a directive-injecting source* — A CSP source is a single non-whitespace token; the builder now rejects a source containing whitespace or a semicolon. Because sources are space-joined and directives semicolon-joined, a malformed value could otherwise have injected a second, live directive. The store's provider hosts (payment, captcha) are already clean single tokens, so this is defense against a future misconfiguration. · *The SQL builder refuses an always-false NULL equality and validates IN-list elements* — An equality against NULL (always false in SQL; the IS NULL form is required) is now refused rather than silently matching nothing, and every element of an IN-list is validated across all database backends. This narrows the room for a query that quietly returns the wrong rows. · *WebSocket fragmented-message reassembly is bounded as it arrives* — A fragmented incoming message is now bounded by its running total on each frame instead of only when the final frame arrives, so a peer can no longer stream unbounded continuation frames to exhaust memory before the size limit is checked, and a frame arriving after close is ignored.
14
+
11
15
  - v0.4.74 (2026-06-21) — **Sign-in links are built from the configured origin, and signing out invalidates every device's session.** Two account-security hardenings. The passwordless sign-in (magic) link was assembled from the request's Host header, so a request that reached the app carrying a forged Host could produce a link pointing at another domain while still carrying the customer's single-use sign-in token. The link is now built from the operator-configured trusted origin — the same source the referral links, back-in-stock links, and the OAuth redirect already use — so the link always points at the real storefront regardless of the request Host. Separately, signing out cleared the session cookie only in the responding browser; because the sealed auth cookie is stateless for its full lifetime, a copy captured before sign-out stayed valid until it naturally expired. Explicit sign-out now moves the account's server-side session-revocation boundary forward, so every live session cookie for the account is invalidated at once. **Fixed:** *Magic-link sign-in URLs are built from the configured origin, not the request Host* — The passwordless sign-in link and the post-checkout account-claim link now derive their origin from the operator's configured trusted origin first, falling back to the request origin only when none is configured (single-host dev). A forged Host header can no longer redirect a credential-bearing sign-in link to another domain. Both email-link sends share one origin resolver, matching the referral, back-in-stock, and OAuth-redirect discipline. · *Signing out invalidates every live session, not just the current browser* — POST /account/logout now moves the customer's server-side session-revocation boundary forward before clearing the cookie, so every sealed auth cookie issued for the account — including a copy captured on another device or before sign-out — fails its next request and signs out. Previously the stateless cookie was only cleared in the responding browser and remained valid elsewhere for its full lifetime.
12
16
 
13
17
  - v0.4.73 (2026-06-21) — **Subject-access exports now carry every operator note, question, quote and rating — not the first page.** A data-subject-access (GDPR Art. 15) export read only the first 100 rows of four sections — operator CRM notes, product questions, RFQ quotes, and post-fulfilment ratings — yet the bundle's completeness manifest still reported each of those sections as fully exported. A customer with a long history (most plausibly a long-tenured account carrying many operator notes) received a bundle that silently dropped the remainder while presenting as complete. The export now drains the operator-notes and product-question sections to the last row by following each reader's own cursor, and pulls the quotes and ratings sections up to the primitive's maximum of 500 rows — beyond any realistic per-customer count — so a subject-access bundle carries the customer's complete record. Both the assembled bundle and the streamed download share the same readers, so both are now complete. **Fixed:** *Export sections drain past the first 100 rows* — The operator-notes and product-question export sections now follow their reader's cursor to exhaustion, so every row reaches the bundle; the quotes and ratings sections read up to the 500-row primitive maximum, which a single customer's RFQ or rating history never exceeds in practice. A long-tenured customer's subject-access export no longer omits rows past the first page while reporting the section complete.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.74",
2
+ "version": "0.4.76",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-imfe0otYErcB8rr2h6KLSGTtStirysptpXETSPY4zLv3bZoIT75Lo1dOvkOav+xL",
package/lib/customers.js CHANGED
@@ -228,6 +228,36 @@ function create(opts) {
228
228
  };
229
229
  },
230
230
 
231
+ // Create an account AND enroll its first passkey as one logical unit.
232
+ // The passwordless register-finish flow defers account creation to a
233
+ // verified WebAuthn ceremony; creating the customer and the credential
234
+ // must not split, or a passkey-insert failure (transient write error, a
235
+ // duplicate credential id) would leave an empty, credential-less account
236
+ // behind — re-creating the very email-squat the deferred flow prevents.
237
+ // D1 has no interactive transaction, so this composes register +
238
+ // addPasskey and COMPENSATES by deleting the just-created row when the
239
+ // enroll fails. The new row has no related data yet (no passkey, oauth,
240
+ // cart, or order), so the delete is clean and frees the email again.
241
+ registerWithPasskey: async function (input) {
242
+ if (!input || typeof input !== "object") {
243
+ throw new TypeError("customers.registerWithPasskey: input object required");
244
+ }
245
+ if (!input.passkey || typeof input.passkey !== "object") {
246
+ throw new TypeError("customers.registerWithPasskey: passkey object required");
247
+ }
248
+ var customer = await api.register({ email: input.email, display_name: input.display_name });
249
+ var passkey;
250
+ try {
251
+ passkey = await api.addPasskey(customer.id, input.passkey);
252
+ } catch (e) {
253
+ try {
254
+ await query("DELETE FROM customers WHERE id = ?1", [customer.id]);
255
+ } catch (_eDel) { /* best-effort compensation — rethrow the real cause below */ }
256
+ throw e;
257
+ }
258
+ return { customer: customer, passkey: passkey };
259
+ },
260
+
231
261
  get: async function (id) {
232
262
  _uuid(id, "customer id");
233
263
  var r = await query("SELECT * FROM customers WHERE id = ?1", [id]);
package/lib/storefront.js CHANGED
@@ -9682,6 +9682,14 @@ var _NOOP_BINDING_STORE = {
9682
9682
  set: function () { return undefined; },
9683
9683
  del: function () { return undefined; },
9684
9684
  };
9685
+ // Fixed decoy customer id for passkey login-begin timing parity. A valid v4
9686
+ // UUID (passes guardUuid strict) whose 122 random bits are fixed to a value
9687
+ // that can never collide with a real randomly-generated customer id, so a
9688
+ // listPasskeys lookup against it always returns empty while costing the same
9689
+ // query a real account's lookup costs — closing the account-existence timing
9690
+ // oracle on /account/passkey/login-begin.
9691
+ var _LOGIN_ENUM_DECOY_ID = "5ec0de00-0000-4000-8000-deadbeef0000";
9692
+
9685
9693
  var _deviceBinding = null;
9686
9694
  function _deviceBindingInstance() {
9687
9695
  if (!_deviceBinding) {
@@ -16215,9 +16223,10 @@ function mount(router, deps) {
16215
16223
  // sign-in. An owner adding another passkey uses the session-gated
16216
16224
  // /account/passkey/add-* flow; a passwordless owner — or one whose
16217
16225
  // first ceremony was interrupted — signs in with the magic-link.
16218
- var existing = await deps.customers.byEmailHash(
16219
- deps.customers.hashEmail(body.email),
16220
- );
16226
+ // Validate + hash the address (a malformed email throws TypeError →
16227
+ // 400 via the outer catch) and refuse any existing account up front.
16228
+ var regEmailHash = deps.customers.hashEmail(body.email);
16229
+ var existing = await deps.customers.byEmailHash(regEmailHash);
16221
16230
  if (existing) {
16222
16231
  res.status(409);
16223
16232
  res.setHeader && res.setHeader("content-type", "application/json");
@@ -16227,31 +16236,31 @@ function mount(router, deps) {
16227
16236
  });
16228
16237
  return res.end ? res.end(dupMsg) : res.send(dupMsg);
16229
16238
  }
16230
- var customer = await deps.customers.register({
16231
- email: body.email,
16232
- display_name: body.display_name,
16233
- });
16239
+ // SECURITY: do NOT persist a customer row here. Creating the account
16240
+ // before the WebAuthn ceremony completes let an abandoned begin — or a
16241
+ // scripted begin flood — squat an email (the 409 above then blocks the
16242
+ // real owner's registration) and mint junk customer rows. The pending
16243
+ // signup rides in the sealed, tamper-proof challenge cookie instead;
16244
+ // the row is created only on a verified register-finish, where
16245
+ // customers.register re-checks the duplicate atomically to close the
16246
+ // begin→finish race. userDisplayName is the cosmetic authenticator
16247
+ // label only — register() is the authoritative display_name validator.
16234
16248
  var startOpts = await b.auth.passkey.startRegistration({
16235
16249
  rpName: rpName,
16236
16250
  rpId: rpId,
16237
- userName: customer.email_hash.slice(0, 16),
16238
- userDisplayName: customer.display_name,
16251
+ userName: regEmailHash.slice(0, 16),
16252
+ userDisplayName: (typeof body.display_name === "string" ? body.display_name : ""),
16239
16253
  attestationType: "none",
16240
16254
  });
16241
- // Seal the ceremony state (challenge + customer_id) into the
16242
- // shop_auth_chal cookie so register-finish verifies against
16243
- // the same challenge without server-side state.
16255
+ // Seal the pending signup (challenge + email + display name) into the
16256
+ // shop_auth_chal cookie so register-finish verifies against the same
16257
+ // challenge and creates the account without server-side state.
16244
16258
  _setChallengeCookie(res, {
16245
- kind: "register",
16246
- customer_id: customer.id,
16247
- challenge: startOpts.challenge,
16248
- created_at: Date.now(),
16249
- // Whether THIS begin created the customer (vs reused an existing
16250
- // row for a known email). register-finish gates referral
16251
- // attribution on it — an existing customer re-enrolling a passkey
16252
- // through a referral link must not be attributed to a referrer
16253
- // (it isn't a new signup), mirroring the OIDC `rv.created` gate.
16254
- is_new: !existing,
16259
+ kind: "register",
16260
+ reg_email: body.email,
16261
+ reg_display_name: body.display_name,
16262
+ challenge: startOpts.challenge,
16263
+ created_at: Date.now(),
16255
16264
  });
16256
16265
  res.status(200);
16257
16266
  res.setHeader && res.setHeader("content-type", "application/json");
@@ -16292,28 +16301,82 @@ function mount(router, deps) {
16292
16301
  if (att.response && Array.isArray(att.response.transports)) {
16293
16302
  transports = att.response.transports.filter(function (t) { return /^[a-z]+$/.test(t); }).join(",");
16294
16303
  }
16295
- await deps.customers.addPasskey(env.customer_id, {
16304
+ var passkeyInput = {
16296
16305
  credential_id: credentialId,
16297
16306
  public_key: publicKey,
16298
16307
  counter: info.counter || 0,
16299
16308
  transports: transports,
16300
- });
16301
- _clearChallengeCookie(res);
16302
- _setAuthCookie(req, res, {
16303
- customer_id: env.customer_id,
16304
- exp: Date.now() + b.constants.TIME.days(14),
16305
- });
16306
- // Attribute this signup to a referrer if the visitor arrived
16307
- // through a /r/<code> link ONLY for a genuinely new account
16308
- // (env.is_new, stamped in register-begin). An existing customer
16309
- // re-enrolling a passkey is not a new signup and must never be
16310
- // attributed (that would let an existing user mint referral
16311
- // credit by following their own/a friend's link), matching the
16312
- // OIDC `rv.created` gate. Best-effort; never blocks the ceremony.
16313
- if (env.is_new === true) {
16314
- await _attributeReferral(req, res, env.customer_id);
16309
+ };
16310
+ var customerId;
16311
+ if (env.reg_email) {
16312
+ // New deferred flow: create the account AND enroll the credential as
16313
+ // one unit — registerWithPasskey rolls the new row back if the
16314
+ // enroll fails, so a transient write error can't leave an empty,
16315
+ // squattable account behind. register() validates the email +
16316
+ // display name and atomically refuses a duplicate, so a racing
16317
+ // second ceremony (or an account created between this begin and
16318
+ // finish) loses here rather than double-creating. Creating the row
16319
+ // only after a verified attestation is what stops an abandoned or
16320
+ // scripted register-begin from squatting an email.
16321
+ var created;
16322
+ try {
16323
+ created = await deps.customers.registerWithPasskey({
16324
+ email: env.reg_email,
16325
+ display_name: env.reg_display_name,
16326
+ passkey: passkeyInput,
16327
+ });
16328
+ } catch (e) {
16329
+ if (e && e.code === "CUSTOMER_DUPLICATE") {
16330
+ _clearChallengeCookie(res);
16331
+ res.status(409);
16332
+ res.setHeader && res.setHeader("content-type", "application/json");
16333
+ var dupFin = JSON.stringify({
16334
+ error: "account_exists",
16335
+ message: "An account with this email already exists. Sign in instead.",
16336
+ });
16337
+ return res.end ? res.end(dupFin) : res.send(dupFin);
16338
+ }
16339
+ if (e instanceof TypeError) {
16340
+ _clearChallengeCookie(res);
16341
+ res.status(400);
16342
+ return res.end ? res.end((e && e.message) || "registration refused") : res.send((e && e.message) || "registration refused");
16343
+ }
16344
+ throw e;
16345
+ }
16346
+ customerId = created.customer.id;
16347
+ _clearChallengeCookie(res);
16348
+ _setAuthCookie(req, res, {
16349
+ customer_id: customerId,
16350
+ exp: Date.now() + b.constants.TIME.days(14),
16351
+ });
16352
+ // A verified deferred register-finish always lands a genuinely new
16353
+ // account, so attribute the signup to a referrer when the visitor
16354
+ // arrived through a /r/<code> link. Best-effort; never blocks.
16355
+ await _attributeReferral(req, res, customerId);
16356
+ } else if (env.customer_id) {
16357
+ // Legacy challenge cookie minted by the previous (pre-defer)
16358
+ // register-begin, still in flight across a rolling deploy: that
16359
+ // begin already created the customer row, so enroll the credential
16360
+ // onto it (the prior finish behavior) and gate referral attribution
16361
+ // on the begin-stamped is_new exactly as before. This branch only
16362
+ // matches cookies issued before this version shipped; it ages out
16363
+ // with the challenge cookie's TTL.
16364
+ await deps.customers.addPasskey(env.customer_id, passkeyInput);
16365
+ customerId = env.customer_id;
16366
+ _clearChallengeCookie(res);
16367
+ _setAuthCookie(req, res, {
16368
+ customer_id: customerId,
16369
+ exp: Date.now() + b.constants.TIME.days(14),
16370
+ });
16371
+ if (env.is_new === true) {
16372
+ await _attributeReferral(req, res, customerId);
16373
+ } else {
16374
+ try { _clearReferralCookie(res); } catch (_e) { /* best-effort */ }
16375
+ }
16315
16376
  } else {
16316
- try { _clearReferralCookie(res); } catch (_e) { /* best-effort */ }
16377
+ _clearChallengeCookie(res);
16378
+ res.status(400);
16379
+ return res.end ? res.end("bad challenge") : res.send("bad challenge");
16317
16380
  }
16318
16381
  res.status(200);
16319
16382
  return res.end ? res.end("ok") : res.send("ok");
@@ -16345,9 +16408,17 @@ function mount(router, deps) {
16345
16408
  // challenge with an empty allowCredentials list — the client
16346
16409
  // can't distinguish "no such address" from "wrong passkey",
16347
16410
  // protecting against email-enumeration.
16411
+ //
16412
+ // Timing parity: a real account ran a second query (listPasskeys)
16413
+ // that a non-existent address skipped, leaving a measurable
16414
+ // response-time delta that re-introduced an account-existence
16415
+ // oracle. Run the same lookup on both paths — against the real id
16416
+ // for an existing account, against a fixed non-matching decoy id
16417
+ // (a valid v4 UUID that can never collide with a random customer
16418
+ // id) otherwise — so the work, and the timing, match.
16348
16419
  var allow = [];
16420
+ var pks = await deps.customers.listPasskeys(customer ? customer.id : _LOGIN_ENUM_DECOY_ID);
16349
16421
  if (customer) {
16350
- var pks = await deps.customers.listPasskeys(customer.id);
16351
16422
  allow = pks.map(function (p) {
16352
16423
  return {
16353
16424
  id: p.credential_id,
@@ -16720,6 +16791,16 @@ function mount(router, deps) {
16720
16791
  if (auth && auth.customer_id && deps.customers &&
16721
16792
  typeof deps.customers.revokeSessionsForCustomer === "function") {
16722
16793
  await deps.customers.revokeSessionsForCustomer(auth.customer_id);
16794
+ // Also revoke any pending customer-portal token (an unredeemed
16795
+ // magic link issued before sign-out). Bumping the cookie boundary
16796
+ // kills live sealed cookies, but a pending portal token is a
16797
+ // separate credential that would otherwise still redeem after a
16798
+ // sign-out-everywhere. Best-effort + drop-silent.
16799
+ try {
16800
+ if (deps.customerPortal && typeof deps.customerPortal.revokeAllForCustomer === "function") {
16801
+ await deps.customerPortal.revokeAllForCustomer(auth.customer_id, "sign-out");
16802
+ }
16803
+ } catch (_ePortal) { /* drop-silent — best-effort pending-token teardown */ }
16723
16804
  }
16724
16805
  } catch (_eRev) { /* drop-silent — the cookie clear below still signs out this browser */ }
16725
16806
  // Wipe the browser's client-side state for the origin on sign-out
@@ -16868,6 +16949,35 @@ function mount(router, deps) {
16868
16949
  if (e instanceof TypeError) return _renderPasskeysPage(req, res, auth, (e && e.message) || "Could not revoke that passkey.", 400);
16869
16950
  throw e;
16870
16951
  }
16952
+ // Revoking a passkey is a security action — the authenticator may be
16953
+ // lost or compromised. Deleting the credential stops it minting a NEW
16954
+ // session, but the sealed auth cookie is stateless for its 14-day TTL,
16955
+ // so a copy captured before the revoke (e.g. a session the compromised
16956
+ // authenticator established) stays live unless we move the server-side
16957
+ // revocation boundary forward now. We then RE-MINT this browser's auth
16958
+ // cookie with an `iat` at the new boundary, so the trusted device
16959
+ // actively performing the revoke stays signed in while every OTHER live
16960
+ // cookie — older `iat`, behind the boundary — dies on its next request
16961
+ // ("sign out other sessions"). Also revoke every pending customer-portal
16962
+ // token (an unredeemed magic link issued before the revoke) so the
16963
+ // compromise can't be re-entered through a still-valid link.
16964
+ // Best-effort + drop-silent: a failed teardown must not leave the
16965
+ // customer staring at a 500 after the credential is already gone.
16966
+ try {
16967
+ var rev = await deps.customers.revokeSessionsForCustomer(auth.customer_id);
16968
+ var boundary = (rev && rev.sessions_valid_from) || Date.now();
16969
+ // Keep the current (trusted) session alive across the boundary bump.
16970
+ _setAuthCookie(req, res, {
16971
+ customer_id: auth.customer_id,
16972
+ iat: boundary,
16973
+ exp: Date.now() + b.constants.TIME.days(14),
16974
+ });
16975
+ } catch (_eRev) { /* drop-silent — credential is gone; boundary bump is defense-in-depth */ }
16976
+ try {
16977
+ if (deps.customerPortal && typeof deps.customerPortal.revokeAllForCustomer === "function") {
16978
+ await deps.customerPortal.revokeAllForCustomer(auth.customer_id, "passkey-revoke");
16979
+ }
16980
+ } catch (_ePortal) { /* drop-silent — best-effort pending-token teardown */ }
16871
16981
  res.status(303); res.setHeader && res.setHeader("location", "/account/passkeys?ok=revoked");
16872
16982
  return res.end ? res.end() : res.send("");
16873
16983
  });