@blamejs/blamejs-shop 0.4.75 → 0.4.77
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/lib/asset-manifest.json +1 -1
- package/lib/auto-replenish.js +14 -3
- package/lib/carrier-accounts.js +21 -3
- package/lib/checkout.js +10 -2
- package/lib/customers.js +30 -0
- package/lib/storefront.js +150 -40
- package/lib/subscription-gifts.js +43 -16
- 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.77 (2026-06-21) — **Exactly-once hardening for payment capture, auto-replenishment, gift redemption, and carrier-credential rotation.** Four correctness fixes that make money- and inventory-moving state transitions happen exactly once under concurrency or delayed settlement. A PayPal order is no longer marked paid when the capture itself is still pending — settlement is now read from the capture status, not the order envelope, so a delayed or under-review payment leaves the order pending until funds actually settle. Overlapping auto-replenishment ticks can no longer both fire the same policy: the cadence advance is now an atomic claim, so a vendor purchase order is composed and submitted once per window instead of being double-cut. Redeeming one gift-subscription link twice — two clicks racing within milliseconds — now mints exactly one subscription: the redemption claims the gift before creating the subscription, and rolls the claim back if the mint fails. And two carrier-credential rotations firing at once can no longer both commit; the rotation now compare-and-swaps on the observed key, so the loser is refused instead of clobbering the winner's fresh key and stranding the previous-key grace window. No configuration changes; upgrade to pick them up. **Fixed:** *A PayPal order is marked paid only when the capture itself completes* — Capturing a PayPal order now gates the paid transition on the per-capture status rather than the order envelope. The order-level status can read COMPLETED while the capture is still PENDING (a delayed-settlement or under-review payment), which previously marked the order paid before the money settled. Any non-completed capture now leaves the order pending and reports the capture as unhandled so the checkout surfaces the unsettled state. · *Overlapping auto-replenishment ticks no longer double-submit a purchase order* — The replenishment scheduler advances a policy's run cadence with a single conditional claim that is the serialization point: two ticks firing at once contend on the policy row, only one matches the due-window predicate, and the vendor purchase order is composed and submitted exactly once per window. Previously the cadence was an in-memory check followed by an unconditional stamp, so two overlapping ticks both passed the check and cut duplicate POs. · *Redeeming a gift-subscription link twice creates exactly one subscription* — Gift redemption now claims the gift's pending→redeemed transition before creating the recipient's subscription, and only the winner of that atomic claim mints. A leaked or double-clicked redemption link presented twice within milliseconds therefore creates one subscription, not two; if the subscription mint then fails, the claim is rolled back so the gift can be redeemed again rather than being stranded as redeemed-with-no-subscription. · *Concurrent carrier-credential rotations no longer over-issue or strand credentials* — Rotating a shipping-carrier account's API credentials now compare-and-swaps on the observed key and account state, so two rotations firing at once can't both commit. The loser is refused with a rotation-conflict error instead of overwriting the winner's freshly minted key and leaving the previous-key grace window pointing at a key no deployed worker holds.
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
|
|
11
15
|
- 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.
|
|
12
16
|
|
|
13
17
|
- 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.
|
package/lib/asset-manifest.json
CHANGED
package/lib/auto-replenish.js
CHANGED
|
@@ -757,9 +757,20 @@ function create(opts) {
|
|
|
757
757
|
for (var i = 0; i < rows.length; i += 1) {
|
|
758
758
|
var policy = _shapePolicy(rows[i]);
|
|
759
759
|
var interval = SCHEDULE_INTERVAL_MS[policy.schedule];
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
760
|
+
// Atomic cadence claim — the serialization point. Advancing
|
|
761
|
+
// last_run_at in a single conditional UPDATE (rather than an
|
|
762
|
+
// in-memory comparison followed by an unconditional stamp) means two
|
|
763
|
+
// overlapping ticks contend on the same row: only one UPDATE matches
|
|
764
|
+
// the "due" predicate, the loser changes zero rows and skips the
|
|
765
|
+
// policy, so the vendor PO is composed and submitted exactly once per
|
|
766
|
+
// cadence window. A null last_run_at fires immediately.
|
|
767
|
+
var claim = await query(
|
|
768
|
+
"UPDATE auto_replenish_policies SET last_run_at = ?1, updated_at = ?1 " +
|
|
769
|
+
"WHERE slug = ?2 AND archived_at IS NULL " +
|
|
770
|
+
"AND (last_run_at IS NULL OR last_run_at <= ?3)",
|
|
771
|
+
[now, policy.slug, now - interval],
|
|
772
|
+
);
|
|
773
|
+
if (Number(claim.rowCount || 0) !== 1) continue; // lost the claim / not yet due
|
|
763
774
|
var runRow = await _runPolicyOnce(policy, now);
|
|
764
775
|
summaries.push(_shapeRun(runRow));
|
|
765
776
|
}
|
package/lib/carrier-accounts.js
CHANGED
|
@@ -577,13 +577,31 @@ function create(opts) {
|
|
|
577
577
|
var prevHash = current.api_key_hash;
|
|
578
578
|
var now = _now();
|
|
579
579
|
|
|
580
|
-
|
|
580
|
+
// Atomic compare-and-swap on the OBSERVED key + state. The JS guard
|
|
581
|
+
// above is a fast-fail; this predicate is the authoritative gate, so two
|
|
582
|
+
// concurrent rotations can't both commit. The loser observed the same
|
|
583
|
+
// api_key_hash but its UPDATE no longer matches (the winner already moved
|
|
584
|
+
// the key), changes zero rows, and refuses — preventing an over-issue
|
|
585
|
+
// where the second rotation clobbers the winner's fresh key and strands
|
|
586
|
+
// api_key_previous_hash pointing at a key no deployed worker holds.
|
|
587
|
+
var claim = await query(
|
|
581
588
|
"UPDATE carrier_accounts SET api_key_hash = ?1, " +
|
|
582
589
|
"api_key_previous_hash = ?2, api_secret_hash = ?3, " +
|
|
583
590
|
"status = 'rotating', rotated_at = ?4, updated_at = ?5 " +
|
|
584
|
-
"WHERE id = ?6",
|
|
585
|
-
[hashKey, prevHash, hashSec, now, now, id],
|
|
591
|
+
"WHERE id = ?6 AND status IN ('active','rotating') AND api_key_hash = ?7",
|
|
592
|
+
[hashKey, prevHash, hashSec, now, now, id, prevHash],
|
|
586
593
|
);
|
|
594
|
+
if (Number(claim.rowCount || 0) !== 1) {
|
|
595
|
+
var lost = await _getRaw(id);
|
|
596
|
+
var conflict = new Error(
|
|
597
|
+
(lost && lost.status === "disabled")
|
|
598
|
+
? "carrier-accounts.rotateCredentials: refused — account is disabled"
|
|
599
|
+
: "carrier-accounts.rotateCredentials: refused — a concurrent rotation won the claim"
|
|
600
|
+
);
|
|
601
|
+
conflict.code = (lost && lost.status === "disabled")
|
|
602
|
+
? "CARRIER_ACCOUNT_DISABLED" : "CARRIER_ACCOUNT_ROTATION_CONFLICT";
|
|
603
|
+
throw conflict;
|
|
604
|
+
}
|
|
587
605
|
|
|
588
606
|
var projected = _project(await _getRaw(id));
|
|
589
607
|
projected.plaintext = {
|
package/lib/checkout.js
CHANGED
|
@@ -1976,8 +1976,16 @@ function create(deps) {
|
|
|
1976
1976
|
var cap = await paypal.captureOrder(paypalOrderId);
|
|
1977
1977
|
var captureId = null;
|
|
1978
1978
|
try { captureId = cap.purchase_units[0].payments.captures[0].id; } catch (_e) { captureId = null; }
|
|
1979
|
-
|
|
1980
|
-
|
|
1979
|
+
// Settlement is a property of the CAPTURE, not the order envelope. The
|
|
1980
|
+
// order-level `cap.status` can read COMPLETED while the capture itself is
|
|
1981
|
+
// still PENDING (e.g. an under-review / delayed-settlement payment), so
|
|
1982
|
+
// gating on the order status would mark the order paid before funds
|
|
1983
|
+
// settle. Gate solely on the per-capture status; any non-COMPLETED
|
|
1984
|
+
// capture (PENDING / DECLINED / FAILED) leaves the order `pending` and
|
|
1985
|
+
// returns handled:false so the route can surface the unsettled state.
|
|
1986
|
+
var captureStatus = null;
|
|
1987
|
+
try { captureStatus = cap.purchase_units[0].payments.captures[0].status; } catch (_e3) { captureStatus = null; }
|
|
1988
|
+
var completed = captureStatus === "COMPLETED";
|
|
1981
1989
|
if (completed && o.status === "pending") {
|
|
1982
1990
|
// Persist the capture id on the order row — refunds dial
|
|
1983
1991
|
// /v2/payments/captures/<capture-id>/refund, NOT the PayPal order id
|
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
|
-
|
|
16219
|
-
|
|
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
|
-
|
|
16231
|
-
|
|
16232
|
-
|
|
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:
|
|
16238
|
-
userDisplayName:
|
|
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
|
|
16242
|
-
// shop_auth_chal cookie so register-finish verifies against
|
|
16243
|
-
// the
|
|
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:
|
|
16246
|
-
|
|
16247
|
-
|
|
16248
|
-
|
|
16249
|
-
|
|
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
|
-
|
|
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
|
-
|
|
16302
|
-
|
|
16303
|
-
|
|
16304
|
-
|
|
16305
|
-
|
|
16306
|
-
|
|
16307
|
-
|
|
16308
|
-
|
|
16309
|
-
|
|
16310
|
-
|
|
16311
|
-
|
|
16312
|
-
|
|
16313
|
-
|
|
16314
|
-
|
|
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
|
-
|
|
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
|
});
|
|
@@ -455,23 +455,20 @@ function create(opts) {
|
|
|
455
455
|
gifted_by: existing.giver_customer_id,
|
|
456
456
|
},
|
|
457
457
|
};
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
//
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
"WHERE id = ?3 AND status IN ('pending','delivered')",
|
|
470
|
-
[now, subscriptionId, existing.id],
|
|
458
|
+
// FSM transition FIRST — the atomic claim must gate the irreversible
|
|
459
|
+
// subscription mint, not trail it. Claim pending|delivered -> redeemed in
|
|
460
|
+
// one conditional UPDATE; only the winner (rowCount===1) goes on to mint.
|
|
461
|
+
// A concurrent redeem of the same token loses the claim, mints nothing,
|
|
462
|
+
// and refuses cleanly — so a leaked link presented twice can never
|
|
463
|
+
// double-create subscriptions. The subscription id is backfilled after the
|
|
464
|
+
// mint succeeds.
|
|
465
|
+
var claim = await query(
|
|
466
|
+
"UPDATE subscription_gifts SET status = 'redeemed', redeemed_at = ?1, redeemed_subscription_id = NULL " +
|
|
467
|
+
"WHERE id = ?2 AND status IN ('pending','delivered')",
|
|
468
|
+
[now, existing.id],
|
|
471
469
|
);
|
|
472
|
-
if (Number(
|
|
473
|
-
// Lost the race — re-read so we surface the actual post-race
|
|
474
|
-
// status to the caller.
|
|
470
|
+
if (Number(claim.rowCount || 0) !== 1) {
|
|
471
|
+
// Lost the race — re-read so we surface the actual post-race status.
|
|
475
472
|
var after = await _getRaw(existing.id);
|
|
476
473
|
var raceErr = new Error(
|
|
477
474
|
"subscriptionGifts.redeemGift: gift status changed to '" +
|
|
@@ -480,6 +477,36 @@ function create(opts) {
|
|
|
480
477
|
raceErr.code = "SUBSCRIPTION_GIFT_ALREADY_REDEEMED";
|
|
481
478
|
throw raceErr;
|
|
482
479
|
}
|
|
480
|
+
|
|
481
|
+
// Winner-only mint. If create() fails (or returns a bad shape), revert the
|
|
482
|
+
// claim so the gift can be redeemed again — nothing was minted, and the
|
|
483
|
+
// gift must not be stranded in 'redeemed' with no subscription.
|
|
484
|
+
function _revertClaim() {
|
|
485
|
+
return query(
|
|
486
|
+
"UPDATE subscription_gifts SET status = ?1, redeemed_at = NULL, redeemed_subscription_id = NULL " +
|
|
487
|
+
"WHERE id = ?2 AND status = 'redeemed' AND redeemed_subscription_id IS NULL",
|
|
488
|
+
[status, existing.id],
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
var created;
|
|
492
|
+
try {
|
|
493
|
+
created = await subscriptionsHandle.create(createInput);
|
|
494
|
+
} catch (e) {
|
|
495
|
+
await _revertClaim();
|
|
496
|
+
throw e;
|
|
497
|
+
}
|
|
498
|
+
if (!created || typeof created !== "object" || typeof created.id !== "string" || !created.id.length) {
|
|
499
|
+
await _revertClaim();
|
|
500
|
+
throw new Error("subscriptionGifts.redeemGift: subscriptions.create must return an object with a string id");
|
|
501
|
+
}
|
|
502
|
+
var subscriptionId = created.id;
|
|
503
|
+
|
|
504
|
+
// Backfill the minted subscription id onto the already-claimed row.
|
|
505
|
+
await query(
|
|
506
|
+
"UPDATE subscription_gifts SET redeemed_subscription_id = ?1 " +
|
|
507
|
+
"WHERE id = ?2 AND status = 'redeemed' AND redeemed_subscription_id IS NULL",
|
|
508
|
+
[subscriptionId, existing.id],
|
|
509
|
+
);
|
|
483
510
|
return {
|
|
484
511
|
gift: _hydrateGift(await _getRaw(existing.id)),
|
|
485
512
|
subscription_id: subscriptionId,
|
package/package.json
CHANGED