@blamejs/blamejs-shop 0.4.22 → 0.4.23
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 +2 -0
- package/lib/admin.js +45 -0
- package/lib/asset-manifest.json +1 -1
- package/lib/checkout.js +70 -0
- package/lib/operator-accounts.js +52 -1
- package/lib/operator-audit-log.js +166 -6
- package/lib/payment.js +178 -69
- package/lib/storefront.js +109 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.4.x
|
|
10
10
|
|
|
11
|
+
- v0.4.23 (2026-06-06) — **A stolen sign-in cookie stops working on another device, payment-provider outages fail fast and recover, replayed payment webhooks are refused, and the staff audit log is rewrite-evident.** A security-hardening release. The signed-in session cookie now carries a device fingerprint — a cookie lifted from one browser softly signs out instead of working anywhere for two weeks. Payment-provider calls run behind a circuit breaker, so a provider brown-out fails fast into the existing payment-unavailable page and recovers automatically instead of hanging every checkout. A captured payment-webhook payload can no longer be replayed inside the signature tolerance window. And the staff-activity audit log is anchored with post-quantum checkpoint signatures, with a verification endpoint that detects any rewrite of its history. **Security:** *The sign-in cookie is pinned to the device that signed in* — The signed-in session cookie was tamper-proof but portable — exfiltrated once, it worked from any device until expiry. Signing in now embeds a fingerprint of the signing-in browser inside the sealed cookie, and every authenticated request re-checks it in constant time. A mismatch signs the visitor out gently to the sign-in page rather than failing the request mid-page; network changes don't trip it (the fingerprint deliberately excludes the IP address), and sessions issued before this release continue to work until they expire naturally. · *Payment-provider calls are circuit-broken and retried where safe* — A payment-provider brown-out previously failed every checkout serially, each waiting out the full network timeout. Provider calls now run behind a circuit breaker: repeated failures trip it, subsequent checkouts fail fast into the existing payment-unavailable page, and the circuit closes again when the provider recovers. Idempotent calls — reads and writes carrying an idempotency key — retry through brief blips; non-idempotent calls never retry. The TLS configuration for provider connections is unchanged. · *Replayed payment webhooks are refused* — A captured, validly-signed payment-webhook payload could be replayed within the signature timestamp tolerance and re-drive order transitions. Each event is now recorded on first receipt and a replay of the same event answers as an already-processed no-op — enforced with a single atomic write, so two concurrent deliveries of the same event also collapse to one. Replay records expire with the signature tolerance window. · *The staff audit log detects history rewrites* — Staff-activity audit rows were hash-linked, which catches tampering with a single row but not a consistent rewrite of the whole chain. The chain is now anchored with periodic checkpoint signatures (ML-DSA, the same signing the framework audit chain uses), and `/admin/operators/audit/verify` reports both link integrity and checkpoint verification — a rewritten history fails the check. Stores that disable destructive operations behind a second approver were also evaluated: with one active operator the gate would deadlock the store, so gift-card voids, refunds, and operator disables remain single-approver — role-gated, bounded, reversible, and audited — with the stance and its revisit condition documented in the module.
|
|
12
|
+
|
|
11
13
|
- v0.4.22 (2026-06-06) — **Privacy export covers every table, erasure revokes the login, one-click unsubscribe actually works, and bounces feed the suppression list.** A privacy and email-integrity release. The data-subject export now includes reviews, consent history, wishlist, surveys, and recently-viewed data — with a manifest that makes any absent section visible. Erasing a customer also deletes their passkeys and social-login links and revokes live sessions, so a deleted account can no longer sign in. The one-click unsubscribe that mail clients invoke from their native button now works (it pointed at a route that didn't exist), a new intake endpoint feeds provider bounce and complaint webhooks into the marketing suppression list, support-ticket edge cases are fixed, and the CSV exports share one complete injection-neutralizer. **Added:** *Bounce and complaint webhook intake* — A new `POST /api/webhooks/mail-bounce` endpoint accepts bounce and complaint webhooks from Postmark, SES, or Resend (selectable per request). Spam complaints and permanently-dead addresses land on the marketing suppression list — transactional mail still flows — and campaign metrics gain the bounce events they were built to read. The endpoint is armed only when `MAIL_BOUNCE_SECRET` is set and the provider presents it in the `x-mail-bounce-secret` header; unconfigured, it answers 503 and accepts nothing. **Fixed:** *The data-subject export includes every table that holds the customer* — The export bundle silently omitted reviews, consent history, wishlist items, survey responses, and recently-viewed data. All five are now included, and the bundle carries a completeness manifest listing every section as exported, empty, or absent — an omission is visible rather than silent. · *Erasure revokes the customer's ability to sign in* — Deleting a customer scrubbed their profile but left passkeys, Google/Apple login links, and live sessions intact — the "deleted" account could sign right back in. Erasure now deletes the passkeys and social-login links, revokes live portal sessions, and tombstones the email lookup hash irreversibly, while the anonymized record survives for order-history integrity. · *One-click unsubscribe works from the mail client's native button* — The unsubscribe headers stamped on every campaign pointed at a route that didn't exist, and the unsubscribe endpoint read its token from the form body — which a mail client's native one-click POST never carries. The header now points at the real endpoint and the token rides the URL, so the RFC 8058 one-click flow a mail client fires actually unsubscribes. The browser confirmation page is unchanged. · *Support tickets: reopen on customer reply, atomic tags, honest first-response time* — A customer reply to a resolved ticket silently disappeared from every operator queue — it now reopens the ticket with a fresh activity timestamp. Concurrent tag edits no longer overwrite each other (tag changes are single-statement updates). And an internal-only operator note no longer counts as the first response — only a reply the customer can actually see stamps the first-response time. · *CSV exports share one complete injection neutralizer* — The order-export and segment-members CSV exports each carried their own formula-injection defense, and both missed the tab, carriage-return, newline, and pipe vectors. Both now compose the framework's CSV cell guard, which covers the full vector set — including signed numerics, which the previous neutralizers deliberately exempted.
|
|
12
14
|
|
|
13
15
|
- v0.4.21 (2026-06-06) — **Subscription changes reach the payment processor, smart collections match real products, refunds claw back earned points, and the remaining balance races are closed.** A correctness release across the commerce surfaces. Changing a subscription's quantity now updates the processor before the local record — what the customer sees is what they're billed — and a cadence change on a processor-backed plan is honestly refused rather than silently ignored. Smart collections, which matched zero products in production because their rules read fields the catalog rows never carried, now evaluate against the real product data. Loyalty points earned on a purchase are reversed when the order is refunded or cancelled. And the loyalty, store-credit, gift-registry, and gift-card-ledger write paths that could lose updates or oversell under concurrency now use atomic guards. **Fixed:** *Subscription quantity changes reach the payment processor* — Changing a subscription's quantity updated the local record and showed a success message while the processor kept billing the original amount. The change is now pushed to the processor first — a processor failure leaves the local record untouched and surfaces the error, so the customer-visible state and the billed state can no longer diverge. Changing delivery frequency on a processor-backed subscription is refused with honest guidance (the billing cadence is bound to the plan's price and cannot be re-cadenced in place); self-managed local subscriptions keep their frequency controls. Self-manage controls also now respect the processor's status — a subscription the processor reports as cancelled or expired shows its state instead of live controls. · *Smart collections match real catalog products* — Smart-collection rules read fields like tags, price, and stock from each product row — fields the real catalog listing never carried, so every smart collection matched zero products in production even though admin previews built on richer mock rows looked right. Rule evaluation now joins the real tag, category, vendor, price, and inventory data onto each product page, and the admin preview routes through the same path the storefront uses. Smart-collection pages also stop re-walking the entire catalog on every paginated request — the matched set is briefly cached and a rule edit invalidates it. · *Refunds and cancellations reverse the loyalty points the order earned* — Points awarded when an order was paid survived a refund or cancellation — buying, earning, and refunding farmed points indefinitely. The earn record is now claimed atomically on the order's refund or cancel transition and the awarded points are clawed back from the balance, floored at zero when some were already spent. The reversal is idempotent (a re-delivered payment webhook reverses exactly once) and lifetime points — which drive tier — are deliberately untouched. · *Balance and inventory-adjacent races closed across loyalty, store credit, gift registry, and gift-card ledger* — Loyalty earn and adjust used read-then-write absolute updates that could lose concurrent updates; the gift-registry purchase check could oversell a registry item under two simultaneous purchases; and the gift-card ledger's overdraft check could let two concurrent debits both pass. All of these now use single-statement conditional writes that refuse cleanly when the guard fails. Store-credit expiry sweeps also stop under-expiring when operator-initiated deductions exist — the sweep now keys on its own prior output rather than netting all expiry rows together. · *Search-ranking click-through metrics are bounded and attributed* — The ranking metrics screen could show click-through rates above 100%, and clicks were attributed from the query string alone — trivially spoofable. A click now only counts when the same session recorded a real impression for that query, and displayed rates are bounded at 100%.
|
package/lib/admin.js
CHANGED
|
@@ -12909,6 +12909,51 @@ function mount(router, deps) {
|
|
|
12909
12909
|
},
|
|
12910
12910
|
));
|
|
12911
12911
|
|
|
12912
|
+
// Operator audit-chain integrity check (ops/tooling, bearer JSON).
|
|
12913
|
+
// Walks the hash linkage (verifyChain) AND re-derives every signed
|
|
12914
|
+
// checkpoint (verifyCheckpoints) so an operator can confirm the chain
|
|
12915
|
+
// is both internally consistent AND anchored — the checkpoint layer is
|
|
12916
|
+
// what catches a full-chain rewrite the hash linkage alone can't.
|
|
12917
|
+
// Read-only: any failure to read degrades to an unavailable verdict,
|
|
12918
|
+
// never a 500. Mounts only when the audit log is wired.
|
|
12919
|
+
if (operatorAuditLog && typeof operatorAuditLog.verifyChain === "function") {
|
|
12920
|
+
router.get("/admin/operators/audit/verify", _pageOrApi(true,
|
|
12921
|
+
R(async function (req, res) {
|
|
12922
|
+
var chain = null;
|
|
12923
|
+
var checkpoints = null;
|
|
12924
|
+
try { chain = await operatorAuditLog.verifyChain(); }
|
|
12925
|
+
catch (_e) { chain = { ok: false, reason: "verify-unavailable" }; }
|
|
12926
|
+
if (typeof operatorAuditLog.verifyCheckpoints === "function") {
|
|
12927
|
+
try { checkpoints = await operatorAuditLog.verifyCheckpoints(); }
|
|
12928
|
+
catch (_e) { checkpoints = { ok: false, reason: "verify-unavailable" }; }
|
|
12929
|
+
}
|
|
12930
|
+
_json(res, 200, {
|
|
12931
|
+
chain: chain,
|
|
12932
|
+
checkpoints: checkpoints,
|
|
12933
|
+
signing_available: !!(operatorAuditLog.signingAvailable && operatorAuditLog.signingAvailable()),
|
|
12934
|
+
});
|
|
12935
|
+
return false;
|
|
12936
|
+
}),
|
|
12937
|
+
// No dedicated console screen — the operators page links here for
|
|
12938
|
+
// tooling; a browser hit gets the same JSON verdict.
|
|
12939
|
+
async function (req, res) {
|
|
12940
|
+
var chain = null;
|
|
12941
|
+
var checkpoints = null;
|
|
12942
|
+
try { chain = await operatorAuditLog.verifyChain(); }
|
|
12943
|
+
catch (_e) { chain = { ok: false, reason: "verify-unavailable" }; }
|
|
12944
|
+
if (typeof operatorAuditLog.verifyCheckpoints === "function") {
|
|
12945
|
+
try { checkpoints = await operatorAuditLog.verifyCheckpoints(); }
|
|
12946
|
+
catch (_e) { checkpoints = { ok: false, reason: "verify-unavailable" }; }
|
|
12947
|
+
}
|
|
12948
|
+
_json(res, 200, {
|
|
12949
|
+
chain: chain,
|
|
12950
|
+
checkpoints: checkpoints,
|
|
12951
|
+
signing_available: !!(operatorAuditLog.signingAvailable && operatorAuditLog.signingAvailable()),
|
|
12952
|
+
});
|
|
12953
|
+
},
|
|
12954
|
+
));
|
|
12955
|
+
}
|
|
12956
|
+
|
|
12912
12957
|
// Create an operator. The first operator is created by the ADMIN_API_KEY
|
|
12913
12958
|
// owner; thereafter any owner can. `created_by` is the acting operator's
|
|
12914
12959
|
// id (or the "owner" sentinel for the break-glass key).
|
package/lib/asset-manifest.json
CHANGED
package/lib/checkout.js
CHANGED
|
@@ -246,6 +246,60 @@ function create(deps) {
|
|
|
246
246
|
var backorder = deps.backorder || null;
|
|
247
247
|
var preorder = deps.preorder || null;
|
|
248
248
|
|
|
249
|
+
// Optional inbound-webhook replay defense. A validly-signed Stripe event
|
|
250
|
+
// can be replayed verbatim inside the ±5-minute signature tolerance: the
|
|
251
|
+
// signature still verifies, so signature-checking alone does not stop a
|
|
252
|
+
// replay, and the downstream order-state idempotency is keyed on order
|
|
253
|
+
// state (not event identity) so a refund/cancel replay or a replay that
|
|
254
|
+
// races the first delivery slips past it. When `webhookReplayQuery` (a
|
|
255
|
+
// D1 query fn) is wired, every verified Stripe event id is atomically
|
|
256
|
+
// recorded with an INSERT ... ON CONFLICT DO NOTHING; a replay loses the
|
|
257
|
+
// PRIMARY-KEY race and is treated as an already-processed no-op. The
|
|
258
|
+
// store composes b.nonceStore over a D1-backed atomic backend rather
|
|
259
|
+
// than a hand-rolled has/set (which would race). Absent — the handler is
|
|
260
|
+
// byte-identical to the un-wired flow (order-state idempotency still
|
|
261
|
+
// covers the common re-delivery), so this is additive, never required.
|
|
262
|
+
var webhookReplayQuery = (typeof deps.webhookReplayQuery === "function")
|
|
263
|
+
? deps.webhookReplayQuery : null;
|
|
264
|
+
var STRIPE_REPLAY_TTL_MS = b.constants.TIME.minutes(5); // matches the signature tolerance window
|
|
265
|
+
var _stripeReplayStore = null;
|
|
266
|
+
function _stripeReplay() {
|
|
267
|
+
if (!webhookReplayQuery) return null;
|
|
268
|
+
if (!_stripeReplayStore) {
|
|
269
|
+
// Custom b.nonceStore backend: the atomicity lives in the D1
|
|
270
|
+
// INSERT ... ON CONFLICT DO NOTHING (the PRIMARY KEY race decides
|
|
271
|
+
// first-seen vs replay), so the check + insert can never interleave.
|
|
272
|
+
_stripeReplayStore = b.nonceStore.create({
|
|
273
|
+
backend: {
|
|
274
|
+
checkAndInsert: async function (eventId, expireAt) {
|
|
275
|
+
var nowMs = Date.now();
|
|
276
|
+
var r = await webhookReplayQuery(
|
|
277
|
+
"INSERT INTO stripe_webhook_events (event_id, first_seen_at, expires_at) " +
|
|
278
|
+
"VALUES (?1, ?2, ?3) ON CONFLICT (event_id) DO NOTHING",
|
|
279
|
+
[eventId, nowMs, expireAt],
|
|
280
|
+
);
|
|
281
|
+
// rowCount/changes === 1 → first sighting (recorded); 0 → replay.
|
|
282
|
+
var changes = (r && r.meta && typeof r.meta.changes === "number") ? r.meta.changes
|
|
283
|
+
: (r && typeof r.rowCount === "number") ? r.rowCount
|
|
284
|
+
: (r && typeof r.changes === "number") ? r.changes : 0;
|
|
285
|
+
return changes > 0;
|
|
286
|
+
},
|
|
287
|
+
purgeExpired: async function () {
|
|
288
|
+
var r = await webhookReplayQuery(
|
|
289
|
+
"DELETE FROM stripe_webhook_events WHERE expires_at < ?1",
|
|
290
|
+
[Date.now()],
|
|
291
|
+
);
|
|
292
|
+
if (r && r.meta && typeof r.meta.changes === "number") return r.meta.changes;
|
|
293
|
+
if (r && typeof r.rowCount === "number") return r.rowCount;
|
|
294
|
+
if (r && typeof r.changes === "number") return r.changes;
|
|
295
|
+
return 0;
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
return _stripeReplayStore;
|
|
301
|
+
}
|
|
302
|
+
|
|
249
303
|
// Reprice a list of cart lines through the quantity-discount engine.
|
|
250
304
|
// Returns a shallow copy with `unit_amount_minor` overwritten by the
|
|
251
305
|
// discounted unit for each line's SKU at its quantity. A line whose
|
|
@@ -1261,6 +1315,22 @@ function create(deps) {
|
|
|
1261
1315
|
var event = v.event;
|
|
1262
1316
|
var eventType = event && event.type;
|
|
1263
1317
|
|
|
1318
|
+
// Replay defense — atomically claim this event id the moment the
|
|
1319
|
+
// signature verifies, BEFORE any subscription routing or state
|
|
1320
|
+
// transition. A replayed (already-seen) event id loses the
|
|
1321
|
+
// PRIMARY-KEY race and short-circuits to a processed no-op so no
|
|
1322
|
+
// transition, refund-mirror, or subscription update runs twice. A
|
|
1323
|
+
// store error fails CLOSED inside the nonceStore (returns
|
|
1324
|
+
// not-fresh) — a replay is indistinguishable from a wiped store, so
|
|
1325
|
+
// refusing is the safe default. No-op when the store isn't wired.
|
|
1326
|
+
var replay = _stripeReplay();
|
|
1327
|
+
if (replay && event && typeof event.id === "string" && event.id.length > 0) {
|
|
1328
|
+
var fresh = await replay.checkAndInsert(event.id, Date.now() + STRIPE_REPLAY_TTL_MS);
|
|
1329
|
+
if (!fresh) {
|
|
1330
|
+
return { handled: true, event_type: eventType || null, skipped: "replay", event_id: event.id };
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1264
1334
|
// Subscription events route to the subscriptions primitive
|
|
1265
1335
|
// (if wired). The one-time-order PaymentIntent path below
|
|
1266
1336
|
// stays unchanged.
|
package/lib/operator-accounts.js
CHANGED
|
@@ -87,9 +87,60 @@
|
|
|
87
87
|
*
|
|
88
88
|
* Storage: `migrations-d1/0213_operator_accounts.sql`.
|
|
89
89
|
*
|
|
90
|
+
* Dual-control (two-operator approval) — evaluated, deliberately NOT
|
|
91
|
+
* wired in v1:
|
|
92
|
+
*
|
|
93
|
+
* `b.dualControl` raises a destructive op to "two distinct named
|
|
94
|
+
* operators must approve before it runs." It is the right control for
|
|
95
|
+
* a store run by a TEAM, but it is structurally a two-actor M-of-N
|
|
96
|
+
* gate — `create()` hard-validates `minApprovers >= 2`, and there is
|
|
97
|
+
* no auto-satisfy-on-single-operator path. The dominant deployment of
|
|
98
|
+
* this storefront is a SINGLE owner; on such a store a dual-control
|
|
99
|
+
* gate would DEADLOCK the destructive op forever — there is no second
|
|
100
|
+
* operator to approve, so the op can never consume its grant. Wiring
|
|
101
|
+
* it unconditionally is therefore a worse failure than the
|
|
102
|
+
* single-operator risk it would close. It is also a two-request async
|
|
103
|
+
* workflow (request → a different actor approves → consume), which
|
|
104
|
+
* does not fit the synchronous single-POST shape these admin actions
|
|
105
|
+
* have today.
|
|
106
|
+
*
|
|
107
|
+
* Per destructive op, the v1 stance:
|
|
108
|
+
*
|
|
109
|
+
* - gift-card void (POST /admin/gift-cards/:id/void): recoverable —
|
|
110
|
+
* void is a status flip that PRESERVES the balance (it burns no
|
|
111
|
+
* value), so re-issuing or restoring the card's status makes a
|
|
112
|
+
* mistaken void recoverable, and the giftcard ledger records the
|
|
113
|
+
* acting operator. The cost of a deadlock-on-single-owner
|
|
114
|
+
* outweighs the benefit. Stance: role-gate (`orders.write`) +
|
|
115
|
+
* audit, no dual-control.
|
|
116
|
+
*
|
|
117
|
+
* - refunds (POST /admin/orders/:id/refund, /admin/returns/:id/
|
|
118
|
+
* refund): real money out, but bounded by the order's captured
|
|
119
|
+
* amount (the payment primitive refuses to over-refund a PI), and
|
|
120
|
+
* every refund is idempotency-keyed + audited with the operator
|
|
121
|
+
* id. The damage ceiling is one order's total, not the whole
|
|
122
|
+
* book. Stance: role-gate (`orders.write`) + audit, no
|
|
123
|
+
* dual-control.
|
|
124
|
+
*
|
|
125
|
+
* - operator disable (POST /admin/operators/:id/disable): the
|
|
126
|
+
* highest-blast-radius op (it can revoke a co-owner), but it is
|
|
127
|
+
* REVERSIBLE in one click (`/enable`), gated on `operators.manage`
|
|
128
|
+
* (owner-only), and audited. A malicious self-lockout still leaves
|
|
129
|
+
* the `ADMIN_API_KEY` break-glass owner credential working.
|
|
130
|
+
* Stance: role-gate + audit, no dual-control.
|
|
131
|
+
*
|
|
132
|
+
* Re-open condition: when a store carries TWO OR MORE active `owner`/
|
|
133
|
+
* `manager` operators, dual-control becomes wire-able WITHOUT the
|
|
134
|
+
* deadlock risk — gate the approval flow on `listAccounts({ status:
|
|
135
|
+
* "active" })` length >= 2, auto-satisfy below that, and move the
|
|
136
|
+
* destructive POST to a request/approve/consume sequence keyed off a
|
|
137
|
+
* b.cache grant. That is the natural follow-up the moment a real
|
|
138
|
+
* multi-operator store exists; it is not defensible to ship a control
|
|
139
|
+
* that bricks the single-operator common case to pre-empt it.
|
|
140
|
+
*
|
|
90
141
|
* @primitive operatorAccounts
|
|
91
142
|
* @related operatorRoles, operatorSessions, operatorAuditLog,
|
|
92
|
-
* b.password, b.crypto, b.guardEmail, b.uuid
|
|
143
|
+
* b.password, b.crypto, b.guardEmail, b.uuid, b.dualControl
|
|
93
144
|
*/
|
|
94
145
|
|
|
95
146
|
var EMAIL_NAMESPACE = "operator-email";
|
|
@@ -84,6 +84,11 @@ var SHA3_512_HEX_LEN = 128;
|
|
|
84
84
|
|
|
85
85
|
var ZERO_HASH = "0".repeat(SHA3_512_HEX_LEN);
|
|
86
86
|
|
|
87
|
+
// Canonical prefix for the bytes a checkpoint signs. Stable forever —
|
|
88
|
+
// changing it invalidates every prior checkpoint signature. Mirrors the
|
|
89
|
+
// framework audit chain's "blamejs-audit-checkpoint-v1" format.
|
|
90
|
+
var CHECKPOINT_FORMAT = "blamejs-operator-audit-checkpoint-v1";
|
|
91
|
+
|
|
87
92
|
var ALLOWED_ACTOR_TYPES = Object.freeze(["operator", "system", "app"]);
|
|
88
93
|
var ALLOWED_UA_CLASSES = Object.freeze([
|
|
89
94
|
"browser",
|
|
@@ -581,6 +586,152 @@ function create(opts) {
|
|
|
581
586
|
return { ok: true, rows_verified: rows.length, last_hash: prevHash };
|
|
582
587
|
}
|
|
583
588
|
|
|
589
|
+
// -- checkpoint anchoring ----------------------------------------------
|
|
590
|
+
//
|
|
591
|
+
// The hash chain is tamper-EVIDENT against a single edited/deleted row,
|
|
592
|
+
// but an attacker who can rewrite the whole table can re-hash from a
|
|
593
|
+
// forged genesis and leave the chain internally consistent. Anchoring
|
|
594
|
+
// the tip with a post-quantum signature over the head row_hash — keyed
|
|
595
|
+
// off the framework's b.auditSign keypair, whose private half never
|
|
596
|
+
// touches D1 — closes that hole: a full-chain rewrite can't reproduce a
|
|
597
|
+
// valid checkpoint signature over the rewritten tip.
|
|
598
|
+
//
|
|
599
|
+
// checkpoint() signs the CURRENT head and inserts an anchor row.
|
|
600
|
+
// verifyCheckpoints() re-derives every anchor's signature and confirms
|
|
601
|
+
// the anchored row still carries the signed hash. Both no-op gracefully
|
|
602
|
+
// when b.auditSign isn't initialized (the chain stays hash-linked; the
|
|
603
|
+
// signature layer is simply absent) so a deployment without the
|
|
604
|
+
// audit-signing keypair still records + verifies the linkage.
|
|
605
|
+
|
|
606
|
+
function _auditSignReady() {
|
|
607
|
+
try { b.auditSign.getPublicKeyFingerprint(); return true; }
|
|
608
|
+
catch (_e) { return false; }
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Canonical signed bytes for a checkpoint. STABLE FOREVER — changing
|
|
612
|
+
// this layout invalidates every prior checkpoint signature.
|
|
613
|
+
function _checkpointPayload(atRowId, atOccurredAt, atRowHash, createdAt) {
|
|
614
|
+
return Buffer.from(
|
|
615
|
+
CHECKPOINT_FORMAT + "\n" +
|
|
616
|
+
atRowId + "\n" +
|
|
617
|
+
String(atOccurredAt) + "\n" +
|
|
618
|
+
atRowHash + "\n" +
|
|
619
|
+
String(createdAt),
|
|
620
|
+
"utf8",
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Anchor the current chain tip with a fresh PQC signature. Returns the
|
|
625
|
+
// inserted checkpoint row, or null when the chain is empty (nothing to
|
|
626
|
+
// anchor) or `skipIfUnchanged` and the tip hasn't advanced since the
|
|
627
|
+
// last checkpoint. Throws OperatorAuditCheckpointSigningUnavailable-
|
|
628
|
+
// shaped TypeError only if asked to checkpoint without a signing key —
|
|
629
|
+
// callers that want a soft skip check `signingAvailable()` first.
|
|
630
|
+
async function checkpoint(checkpointOpts) {
|
|
631
|
+
checkpointOpts = checkpointOpts || {};
|
|
632
|
+
if (!_auditSignReady()) {
|
|
633
|
+
throw new TypeError("operatorAuditLog.checkpoint: b.auditSign is not initialized — " +
|
|
634
|
+
"no signing key to anchor the chain with");
|
|
635
|
+
}
|
|
636
|
+
var head = await _currentHead();
|
|
637
|
+
if (head.id === null) return null; // empty chain — nothing to anchor
|
|
638
|
+
|
|
639
|
+
if (checkpointOpts.skipIfUnchanged) {
|
|
640
|
+
var last = await query(
|
|
641
|
+
"SELECT at_row_hash FROM operator_audit_checkpoints " +
|
|
642
|
+
"ORDER BY created_at DESC, id DESC LIMIT 1",
|
|
643
|
+
[],
|
|
644
|
+
);
|
|
645
|
+
if (last.rows.length && last.rows[0].at_row_hash === head.row_hash) {
|
|
646
|
+
return null; // already anchored at this exact tip
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
var createdAt = _now();
|
|
651
|
+
var payload = _checkpointPayload(head.id, head.occurred_at, head.row_hash, createdAt);
|
|
652
|
+
var signature = b.auditSign.sign(payload).toString("base64");
|
|
653
|
+
var pubFp = b.auditSign.getPublicKeyFingerprint();
|
|
654
|
+
var id = b.uuid.v7();
|
|
655
|
+
|
|
656
|
+
await query(
|
|
657
|
+
"INSERT INTO operator_audit_checkpoints " +
|
|
658
|
+
"(id, created_at, at_row_id, at_occurred_at, at_row_hash, signature, public_key_fingerprint) " +
|
|
659
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
|
660
|
+
[id, createdAt, head.id, head.occurred_at, head.row_hash, signature, pubFp],
|
|
661
|
+
);
|
|
662
|
+
|
|
663
|
+
return {
|
|
664
|
+
id: id,
|
|
665
|
+
created_at: createdAt,
|
|
666
|
+
at_row_id: head.id,
|
|
667
|
+
at_occurred_at: head.occurred_at,
|
|
668
|
+
at_row_hash: head.row_hash,
|
|
669
|
+
public_key_fingerprint: pubFp,
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// Walk every checkpoint oldest-first, re-derive its signature, and
|
|
674
|
+
// confirm the anchored row still carries the signed hash. Reports the
|
|
675
|
+
// first divergence — a forged signature, a key-rotation fingerprint
|
|
676
|
+
// mismatch, a vanished anchor row, or a rewritten tip whose hash no
|
|
677
|
+
// longer matches what was signed.
|
|
678
|
+
async function verifyCheckpoints() {
|
|
679
|
+
if (!_auditSignReady()) {
|
|
680
|
+
return { ok: true, checkpoints_verified: 0, reason: "audit-sign-unavailable" };
|
|
681
|
+
}
|
|
682
|
+
var r = await query(
|
|
683
|
+
"SELECT * FROM operator_audit_checkpoints ORDER BY created_at ASC, id ASC",
|
|
684
|
+
[],
|
|
685
|
+
);
|
|
686
|
+
var rows = r.rows;
|
|
687
|
+
if (!rows.length) return { ok: true, checkpoints_verified: 0 };
|
|
688
|
+
|
|
689
|
+
var currentFp = b.auditSign.getPublicKeyFingerprint();
|
|
690
|
+
var currentPub = b.auditSign.getPublicKey();
|
|
691
|
+
|
|
692
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
693
|
+
var c = rows[i];
|
|
694
|
+
// Only the current key verifies (no key-history table). A rotation
|
|
695
|
+
// without re-signing surfaces here rather than silently failing.
|
|
696
|
+
if (c.public_key_fingerprint !== currentFp) {
|
|
697
|
+
return {
|
|
698
|
+
ok: false, checkpoints_verified: i, break_at: i, checkpoint_id: c.id,
|
|
699
|
+
reason: "public key fingerprint mismatch (key rotated without re-signing?)",
|
|
700
|
+
expected: currentFp, actual: c.public_key_fingerprint,
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
var payload = _checkpointPayload(c.at_row_id, Number(c.at_occurred_at), c.at_row_hash, Number(c.created_at));
|
|
704
|
+
var sigBuf;
|
|
705
|
+
try { sigBuf = Buffer.from(c.signature, "base64"); }
|
|
706
|
+
catch (_e) {
|
|
707
|
+
return { ok: false, checkpoints_verified: i, break_at: i, checkpoint_id: c.id,
|
|
708
|
+
reason: "checkpoint signature not decodable" };
|
|
709
|
+
}
|
|
710
|
+
if (!b.auditSign.verify(payload, sigBuf, currentPub)) {
|
|
711
|
+
return { ok: false, checkpoints_verified: i, break_at: i, checkpoint_id: c.id,
|
|
712
|
+
reason: "post-quantum signature failed" };
|
|
713
|
+
}
|
|
714
|
+
// The anchored row must still exist AND still carry the signed hash.
|
|
715
|
+
// A full-chain rewrite changes row_hash → this mismatch is the catch.
|
|
716
|
+
var anchored = await query(
|
|
717
|
+
"SELECT row_hash FROM operator_audit_events WHERE id = ?1 LIMIT 1",
|
|
718
|
+
[c.at_row_id],
|
|
719
|
+
);
|
|
720
|
+
if (!anchored.rows.length) {
|
|
721
|
+
return { ok: false, checkpoints_verified: i, break_at: i, checkpoint_id: c.id,
|
|
722
|
+
reason: "anchored audit row missing (id=" + c.at_row_id + ")" };
|
|
723
|
+
}
|
|
724
|
+
if (anchored.rows[0].row_hash !== c.at_row_hash) {
|
|
725
|
+
return {
|
|
726
|
+
ok: false, checkpoints_verified: i, break_at: i, checkpoint_id: c.id,
|
|
727
|
+
reason: "anchored row_hash mismatch — operator_audit_events was rewritten",
|
|
728
|
+
expected: c.at_row_hash, actual: anchored.rows[0].row_hash,
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
return { ok: true, checkpoints_verified: rows.length };
|
|
733
|
+
}
|
|
734
|
+
|
|
584
735
|
return {
|
|
585
736
|
MAX_ACTOR_ID_LEN: MAX_ACTOR_ID_LEN,
|
|
586
737
|
MAX_ACTION_LEN: MAX_ACTION_LEN,
|
|
@@ -593,12 +744,20 @@ function create(opts) {
|
|
|
593
744
|
ALLOWED_UA_CLASSES: ALLOWED_UA_CLASSES,
|
|
594
745
|
ZERO_HASH: ZERO_HASH,
|
|
595
746
|
|
|
596
|
-
record:
|
|
597
|
-
listByActor:
|
|
598
|
-
listByResource:
|
|
599
|
-
searchAction:
|
|
600
|
-
chainHead:
|
|
601
|
-
verifyChain:
|
|
747
|
+
record: record,
|
|
748
|
+
listByActor: listByActor,
|
|
749
|
+
listByResource: listByResource,
|
|
750
|
+
searchAction: searchAction,
|
|
751
|
+
chainHead: chainHead,
|
|
752
|
+
verifyChain: verifyChain,
|
|
753
|
+
// Checkpoint anchoring — sign the chain tip out-of-band so a
|
|
754
|
+
// full-chain rewrite (which verifyChain alone can't catch) becomes
|
|
755
|
+
// detectable. `signingAvailable()` lets callers soft-skip when the
|
|
756
|
+
// audit-signing key isn't initialized.
|
|
757
|
+
checkpoint: checkpoint,
|
|
758
|
+
verifyCheckpoints: verifyCheckpoints,
|
|
759
|
+
signingAvailable: _auditSignReady,
|
|
760
|
+
CHECKPOINT_FORMAT: CHECKPOINT_FORMAT,
|
|
602
761
|
};
|
|
603
762
|
}
|
|
604
763
|
|
|
@@ -614,4 +773,5 @@ module.exports = {
|
|
|
614
773
|
ALLOWED_ACTOR_TYPES: ALLOWED_ACTOR_TYPES,
|
|
615
774
|
ALLOWED_UA_CLASSES: ALLOWED_UA_CLASSES,
|
|
616
775
|
ZERO_HASH: ZERO_HASH,
|
|
776
|
+
CHECKPOINT_FORMAT: CHECKPOINT_FORMAT,
|
|
617
777
|
};
|
package/lib/payment.js
CHANGED
|
@@ -8,9 +8,15 @@
|
|
|
8
8
|
* verification (HMAC-SHA256 over `<timestamp>.<body>` with
|
|
9
9
|
* `whsec_...` secret, ±5 min tolerance, constant-time compare) and
|
|
10
10
|
* outbound API calls (PaymentIntent create / retrieve / confirm /
|
|
11
|
-
* cancel + Refund)
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* cancel + Refund) on `b.httpClient` (SSRF-gated, response-capped,
|
|
12
|
+
* ALPN HTTP/2). Each adapter instance wraps its dials in a per-upstream
|
|
13
|
+
* `b.circuitBreaker` plus a bounded `b.retry` (the latter only on
|
|
14
|
+
* idempotent dials — GET reads + idempotency-keyed writes — so a
|
|
15
|
+
* transient blip never re-sends a one-shot charge). While the breaker is
|
|
16
|
+
* open the dial fast-fails with `code: "CIRCUIT_OPEN"` BEFORE the
|
|
17
|
+
* request, so checkout renders the recoverable payment-unavailable page
|
|
18
|
+
* rather than stranding an order mid-charge. No `stripe` npm dep — every
|
|
19
|
+
* byte is either node built-in or vendored blamejs primitive.
|
|
14
20
|
*
|
|
15
21
|
* Future Adyen / Mollie / Paddle adapters land as additional
|
|
16
22
|
* factory functions returning the same `{ verifyWebhook,
|
|
@@ -48,6 +54,59 @@ var STRIPE_WEBHOOK_TOLERANCE = 300; // ± 5 minutes (Stripe default)
|
|
|
48
54
|
var STRIPE_HTTP_TIMEOUT_MS = 15000;
|
|
49
55
|
var CURRENCY_RE = /^[a-z]{3}$/; // Stripe wants lowercase ISO 4217
|
|
50
56
|
|
|
57
|
+
// ---- PSP dial resilience (circuit breaker + bounded retry) ----------------
|
|
58
|
+
//
|
|
59
|
+
// b.httpClient does NOT itself circuit-break or retry — it SSRF-gates, caps,
|
|
60
|
+
// and pools, but the failure-threshold breaker + backoff retry are separate
|
|
61
|
+
// primitives a consumer composes around it. Each adapter instance owns ONE
|
|
62
|
+
// breaker per upstream (Stripe / PayPal): per-target is the only correct
|
|
63
|
+
// scope — sharing a breaker across unrelated peers defeats the
|
|
64
|
+
// failure-threshold semantic.
|
|
65
|
+
//
|
|
66
|
+
// Open-circuit posture: while the breaker is open every dial fast-fails with
|
|
67
|
+
// a plain Error carrying `code: "CIRCUIT_OPEN"` (NOT a TypeError), so the
|
|
68
|
+
// storefront's checkout handler renders it through the existing recoverable
|
|
69
|
+
// "checkout didn't go through" page — the same structured payment-unavailable
|
|
70
|
+
// surface a raw upstream 5xx already produces — rather than charging or
|
|
71
|
+
// 400-ing a field. Nothing is captured: the breaker fails BEFORE the dial, so
|
|
72
|
+
// no order is stranded mid-charge.
|
|
73
|
+
//
|
|
74
|
+
// Retry rides ONLY on idempotent dials — GET reads and writes carrying an
|
|
75
|
+
// idempotency key (Stripe Idempotency-Key / PayPal-Request-Id). A
|
|
76
|
+
// keyless POST is sent exactly once: re-driving it could double-charge.
|
|
77
|
+
var BREAKER_FAILURE_THRESHOLD = 5; // consecutive failures before opening
|
|
78
|
+
var BREAKER_COOLDOWN_MS = C.TIME.seconds(30); // open → half-open probe delay
|
|
79
|
+
var BREAKER_SUCCESS_THRESHOLD = 2; // half-open probes to re-close
|
|
80
|
+
var DIAL_RETRY_MAX_ATTEMPTS = 3; // total tries incl. first (idempotent only)
|
|
81
|
+
var DIAL_RETRY_BASE_DELAY_MS = 200;
|
|
82
|
+
var DIAL_RETRY_MAX_DELAY_MS = C.TIME.seconds(2);
|
|
83
|
+
|
|
84
|
+
// One breaker per adapter instance. `idempotent` selects whether the dial
|
|
85
|
+
// also rides the bounded backoff retry — the breaker counts the retry loop's
|
|
86
|
+
// FINAL outcome as a single call (b.retry.withBreaker), so a transient blip
|
|
87
|
+
// that the retry rides out never inflates the failure counter.
|
|
88
|
+
function _makeBreaker(name) {
|
|
89
|
+
return b.circuitBreaker.create({
|
|
90
|
+
name: name,
|
|
91
|
+
failureThreshold: BREAKER_FAILURE_THRESHOLD,
|
|
92
|
+
cooldownMs: BREAKER_COOLDOWN_MS,
|
|
93
|
+
successThreshold: BREAKER_SUCCESS_THRESHOLD,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function _dial(breaker, idempotent, fn) {
|
|
98
|
+
if (!breaker) return fn();
|
|
99
|
+
if (!idempotent) return breaker.wrap(fn);
|
|
100
|
+
return b.retry.withBreaker(fn, {
|
|
101
|
+
breaker: breaker,
|
|
102
|
+
retry: {
|
|
103
|
+
maxAttempts: DIAL_RETRY_MAX_ATTEMPTS,
|
|
104
|
+
baseDelayMs: DIAL_RETRY_BASE_DELAY_MS,
|
|
105
|
+
maxDelayMs: DIAL_RETRY_MAX_DELAY_MS,
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
51
110
|
// Stripe holds idempotency keys for 24h, so the local cache row
|
|
52
111
|
// expires on the same window — operators who run `cleanupExpired()`
|
|
53
112
|
// on a daily schedule keep the table small without ever shortening
|
|
@@ -196,33 +255,47 @@ async function _stripeCall(opts, method, path, params, idempotencyKey) {
|
|
|
196
255
|
headers["idempotency-key"] = idempotencyKey;
|
|
197
256
|
}
|
|
198
257
|
var httpClient = opts.httpClient || b.httpClient;
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
258
|
+
// A GET read is always idempotent; a write is idempotent only when it
|
|
259
|
+
// carries an Idempotency-Key (Stripe dedupes a replay of the SAME key
|
|
260
|
+
// server-side). A keyless POST rides the breaker but NOT the retry, so
|
|
261
|
+
// a transient blip never re-sends it (double-charge guard).
|
|
262
|
+
var idempotent = method === "GET" || !!idempotencyKey;
|
|
263
|
+
// The HTTP request AND the non-2xx → throw both run INSIDE the dialed
|
|
264
|
+
// closure so the breaker counts a 5xx / transport error as a failure
|
|
265
|
+
// (and the idempotent-path retry retries it). A 4xx is the request's
|
|
266
|
+
// fault, not the peer's health — `err.statusCode` makes the retry
|
|
267
|
+
// classifier skip it, and a 4xx still increments the breaker, which is
|
|
268
|
+
// acceptable since a sustained stream of 4xx is itself a degraded state.
|
|
269
|
+
var json = await _dial(opts._breaker, idempotent, async function () {
|
|
270
|
+
var res = await httpClient.request({
|
|
271
|
+
method: method,
|
|
272
|
+
url: url,
|
|
273
|
+
headers: headers,
|
|
274
|
+
body: body || undefined,
|
|
275
|
+
timeoutMs: opts.timeoutMs || STRIPE_HTTP_TIMEOUT_MS,
|
|
276
|
+
agent: _PSP_TLS_AGENT,
|
|
277
|
+
});
|
|
278
|
+
var text = res.body && res.body.toString ? res.body.toString("utf8") : "";
|
|
279
|
+
var parsed = null;
|
|
280
|
+
try { parsed = text.length ? JSON.parse(text) : {}; } catch (_e) { parsed = { _raw: text }; }
|
|
281
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
282
|
+
var err = new Error("stripe: " + method + " " + path + " → HTTP " + res.statusCode +
|
|
283
|
+
(parsed && parsed.error && parsed.error.message ? " — " + parsed.error.message : ""));
|
|
284
|
+
err.code = (parsed && parsed.error && parsed.error.code) || "STRIPE_HTTP_" + res.statusCode;
|
|
285
|
+
err.statusCode = res.statusCode;
|
|
286
|
+
err.stripe = parsed && parsed.error || null;
|
|
287
|
+
err._stripeRawText = text;
|
|
288
|
+
err._stripeStatus = res.statusCode;
|
|
289
|
+
throw err;
|
|
290
|
+
}
|
|
291
|
+
// Carry the raw status + serialised body alongside the parsed JSON
|
|
292
|
+
// so the idempotency layer can persist them verbatim for replay
|
|
293
|
+
// without re-stringifying (preserves byte-for-byte fidelity with
|
|
294
|
+
// what Stripe returned, including field ordering).
|
|
295
|
+
Object.defineProperty(parsed, "_stripeStatus", { value: res.statusCode, enumerable: false });
|
|
296
|
+
Object.defineProperty(parsed, "_stripeRawText", { value: text, enumerable: false });
|
|
297
|
+
return parsed;
|
|
206
298
|
});
|
|
207
|
-
var text = res.body && res.body.toString ? res.body.toString("utf8") : "";
|
|
208
|
-
var json = null;
|
|
209
|
-
try { json = text.length ? JSON.parse(text) : {}; } catch (_e) { json = { _raw: text }; }
|
|
210
|
-
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
211
|
-
var err = new Error("stripe: " + method + " " + path + " → HTTP " + res.statusCode +
|
|
212
|
-
(json && json.error && json.error.message ? " — " + json.error.message : ""));
|
|
213
|
-
err.code = (json && json.error && json.error.code) || "STRIPE_HTTP_" + res.statusCode;
|
|
214
|
-
err.statusCode = res.statusCode;
|
|
215
|
-
err.stripe = json && json.error || null;
|
|
216
|
-
err._stripeRawText = text;
|
|
217
|
-
err._stripeStatus = res.statusCode;
|
|
218
|
-
throw err;
|
|
219
|
-
}
|
|
220
|
-
// Carry the raw status + serialised body alongside the parsed JSON
|
|
221
|
-
// so the idempotency layer can persist them verbatim for replay
|
|
222
|
-
// without re-stringifying (preserves byte-for-byte fidelity with
|
|
223
|
-
// what Stripe returned, including field ordering).
|
|
224
|
-
Object.defineProperty(json, "_stripeStatus", { value: res.statusCode, enumerable: false });
|
|
225
|
-
Object.defineProperty(json, "_stripeRawText", { value: text, enumerable: false });
|
|
226
299
|
return json;
|
|
227
300
|
}
|
|
228
301
|
|
|
@@ -330,6 +403,13 @@ function stripe(opts) {
|
|
|
330
403
|
throw new TypeError("payment: now must be a function returning current epoch ms");
|
|
331
404
|
}
|
|
332
405
|
|
|
406
|
+
// One circuit breaker per adapter instance, guarding every Stripe dial.
|
|
407
|
+
// Stashed on opts so the module-level `_stripeCall` reaches it without a
|
|
408
|
+
// signature change. Skippable in tests via `breaker: false`.
|
|
409
|
+
if (opts._breaker === undefined) {
|
|
410
|
+
opts._breaker = opts.breaker === false ? null : _makeBreaker("psp-stripe");
|
|
411
|
+
}
|
|
412
|
+
|
|
333
413
|
// Idempotency state shared across every mutating call. When `query`
|
|
334
414
|
// is not supplied the primitive runs in legacy mode — every
|
|
335
415
|
// mutating call goes straight to Stripe, no cache writes, no
|
|
@@ -349,6 +429,11 @@ function stripe(opts) {
|
|
|
349
429
|
return {
|
|
350
430
|
name: "stripe",
|
|
351
431
|
|
|
432
|
+
// The per-adapter circuit breaker (or null when disabled). Exposed so
|
|
433
|
+
// an operator dashboard can read `breaker.getState()` ("closed" /
|
|
434
|
+
// "open" / "half") and reset it after a confirmed recovery.
|
|
435
|
+
breaker: opts._breaker,
|
|
436
|
+
|
|
352
437
|
verifyWebhook: function (headers, rawBody, vOpts) {
|
|
353
438
|
return _verifyWebhook(headers, rawBody, opts.webhookSecret, vOpts);
|
|
354
439
|
},
|
|
@@ -648,28 +733,33 @@ async function _paypalToken(opts, state) {
|
|
|
648
733
|
if (state.token && now < state.tokenExpiresAt) return state.token;
|
|
649
734
|
var httpClient = opts.httpClient || b.httpClient;
|
|
650
735
|
var basic = Buffer.from(opts.clientId + ":" + opts.secret).toString("base64");
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
736
|
+
// The client-credentials token exchange is idempotent (re-asking for a
|
|
737
|
+
// token is always safe), so it rides the breaker AND the bounded retry.
|
|
738
|
+
var json = await _dial(opts._breaker, true, async function () {
|
|
739
|
+
var res = await httpClient.request({
|
|
740
|
+
method: "POST",
|
|
741
|
+
url: _paypalApiBase(opts) + "/v1/oauth2/token",
|
|
742
|
+
headers: {
|
|
743
|
+
"authorization": "Basic " + basic,
|
|
744
|
+
"accept": "application/json",
|
|
745
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
746
|
+
"user-agent": "blamejs-shop (zero-dep)",
|
|
747
|
+
},
|
|
748
|
+
body: "grant_type=client_credentials",
|
|
749
|
+
timeoutMs: opts.timeoutMs || PAYPAL_HTTP_TIMEOUT_MS,
|
|
750
|
+
agent: _PSP_TLS_AGENT,
|
|
751
|
+
});
|
|
752
|
+
var text = res.body && res.body.toString ? res.body.toString("utf8") : "";
|
|
753
|
+
var parsed; try { parsed = text.length ? JSON.parse(text) : {}; } catch (_e) { parsed = {}; }
|
|
754
|
+
if (res.statusCode < 200 || res.statusCode >= 300 || !parsed.access_token) {
|
|
755
|
+
var err = new Error("paypal: OAuth2 token exchange failed → HTTP " + res.statusCode +
|
|
756
|
+
(parsed && parsed.error_description ? " — " + parsed.error_description : ""));
|
|
757
|
+
err.code = "PAYPAL_AUTH_" + res.statusCode;
|
|
758
|
+
err.statusCode = res.statusCode;
|
|
759
|
+
throw err;
|
|
760
|
+
}
|
|
761
|
+
return parsed;
|
|
663
762
|
});
|
|
664
|
-
var text = res.body && res.body.toString ? res.body.toString("utf8") : "";
|
|
665
|
-
var json; try { json = text.length ? JSON.parse(text) : {}; } catch (_e) { json = {}; }
|
|
666
|
-
if (res.statusCode < 200 || res.statusCode >= 300 || !json.access_token) {
|
|
667
|
-
var err = new Error("paypal: OAuth2 token exchange failed → HTTP " + res.statusCode +
|
|
668
|
-
(json && json.error_description ? " — " + json.error_description : ""));
|
|
669
|
-
err.code = "PAYPAL_AUTH_" + res.statusCode;
|
|
670
|
-
err.statusCode = res.statusCode;
|
|
671
|
-
throw err;
|
|
672
|
-
}
|
|
673
763
|
state.token = json.access_token;
|
|
674
764
|
var ttlMs = (typeof json.expires_in === "number" ? json.expires_in : 0) * 1000; // allow:raw-time-literal — PayPal expires_in is a runtime seconds value; *1000 → ms
|
|
675
765
|
state.tokenExpiresAt = now + Math.max(0, ttlMs - PAYPAL_TOKEN_SKEW_MS);
|
|
@@ -688,26 +778,34 @@ async function _paypalCall(opts, state, method, path, bodyObj, requestId) {
|
|
|
688
778
|
var body = bodyObj != null ? JSON.stringify(bodyObj) : undefined;
|
|
689
779
|
if (body) headers["content-length"] = Buffer.byteLength(body, "utf8");
|
|
690
780
|
var httpClient = opts.httpClient || b.httpClient;
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
781
|
+
// A GET is idempotent; a write is idempotent only when it carries a
|
|
782
|
+
// PayPal-Request-Id (PayPal dedupes a replay of the SAME id, and the
|
|
783
|
+
// same id rides every retry attempt within one call). A keyless write
|
|
784
|
+
// rides the breaker but not the retry.
|
|
785
|
+
var idempotent = method === "GET" || !!requestId;
|
|
786
|
+
var json = await _dial(opts._breaker, idempotent, async function () {
|
|
787
|
+
var res = await httpClient.request({
|
|
788
|
+
method: method,
|
|
789
|
+
url: _paypalApiBase(opts) + path,
|
|
790
|
+
headers: headers,
|
|
791
|
+
body: body,
|
|
792
|
+
timeoutMs: opts.timeoutMs || PAYPAL_HTTP_TIMEOUT_MS,
|
|
793
|
+
agent: _PSP_TLS_AGENT,
|
|
794
|
+
});
|
|
795
|
+
var text = res.body && res.body.toString ? res.body.toString("utf8") : "";
|
|
796
|
+
var parsed; try { parsed = text.length ? JSON.parse(text) : {}; } catch (_e) { parsed = { _raw: text }; }
|
|
797
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
798
|
+
var detail = parsed && (parsed.message || (parsed.details && parsed.details[0] && parsed.details[0].description)) || "";
|
|
799
|
+
var err = new Error("paypal: " + method + " " + path + " → HTTP " + res.statusCode + (detail ? " — " + detail : ""));
|
|
800
|
+
err.code = (parsed && parsed.name) || "PAYPAL_HTTP_" + res.statusCode;
|
|
801
|
+
err.statusCode = res.statusCode;
|
|
802
|
+
err.paypal = parsed || null;
|
|
803
|
+
throw err;
|
|
804
|
+
}
|
|
805
|
+
Object.defineProperty(parsed, "_paypalStatus", { value: res.statusCode, enumerable: false });
|
|
806
|
+
Object.defineProperty(parsed, "_paypalRawText", { value: text, enumerable: false });
|
|
807
|
+
return parsed;
|
|
698
808
|
});
|
|
699
|
-
var text = res.body && res.body.toString ? res.body.toString("utf8") : "";
|
|
700
|
-
var json; try { json = text.length ? JSON.parse(text) : {}; } catch (_e) { json = { _raw: text }; }
|
|
701
|
-
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
702
|
-
var detail = json && (json.message || (json.details && json.details[0] && json.details[0].description)) || "";
|
|
703
|
-
var err = new Error("paypal: " + method + " " + path + " → HTTP " + res.statusCode + (detail ? " — " + detail : ""));
|
|
704
|
-
err.code = (json && json.name) || "PAYPAL_HTTP_" + res.statusCode;
|
|
705
|
-
err.statusCode = res.statusCode;
|
|
706
|
-
err.paypal = json || null;
|
|
707
|
-
throw err;
|
|
708
|
-
}
|
|
709
|
-
Object.defineProperty(json, "_paypalStatus", { value: res.statusCode, enumerable: false });
|
|
710
|
-
Object.defineProperty(json, "_paypalRawText", { value: text, enumerable: false });
|
|
711
809
|
return json;
|
|
712
810
|
}
|
|
713
811
|
|
|
@@ -721,6 +819,13 @@ function paypal(opts) {
|
|
|
721
819
|
if (opts.now != null && typeof opts.now !== "function") {
|
|
722
820
|
throw new TypeError("payment: now must be a function returning current epoch ms");
|
|
723
821
|
}
|
|
822
|
+
// One circuit breaker per adapter instance, guarding every PayPal dial
|
|
823
|
+
// (the token exchange + every Orders-v2 call). Skippable in tests via
|
|
824
|
+
// `breaker: false`.
|
|
825
|
+
if (opts._breaker === undefined) {
|
|
826
|
+
opts._breaker = opts.breaker === false ? null : _makeBreaker("psp-paypal");
|
|
827
|
+
}
|
|
828
|
+
|
|
724
829
|
var state = {
|
|
725
830
|
query: opts.query || null,
|
|
726
831
|
now: typeof opts.now === "function" ? opts.now : function () { return Date.now(); },
|
|
@@ -744,6 +849,10 @@ function paypal(opts) {
|
|
|
744
849
|
return {
|
|
745
850
|
name: "paypal",
|
|
746
851
|
|
|
852
|
+
// The per-adapter circuit breaker (or null when disabled). Same
|
|
853
|
+
// operator-dashboard surface as the Stripe adapter's.
|
|
854
|
+
breaker: opts._breaker,
|
|
855
|
+
|
|
747
856
|
// Create an Orders-v2 order (intent CAPTURE). The returned `id` is the
|
|
748
857
|
// PayPal order id the buyer approves; `captureOrder` finalizes it.
|
|
749
858
|
createOrder: function (input, idempotencyKey) {
|
package/lib/storefront.js
CHANGED
|
@@ -9085,13 +9085,70 @@ function _setSidCookie(req, res, sid) {
|
|
|
9085
9085
|
});
|
|
9086
9086
|
}
|
|
9087
9087
|
|
|
9088
|
+
// Store-free device-fingerprint binding for the sealed auth cookie. The
|
|
9089
|
+
// sealed envelope is tamper-proof but device-PORTABLE: a cookie lifted
|
|
9090
|
+
// off one device replays for the full 14-day life on any other. We bind
|
|
9091
|
+
// it softly to a SHAKE256 fingerprint of the device shape (User-Agent +
|
|
9092
|
+
// sorted Accept-Language / Accept-Encoding) carried INSIDE the sealed
|
|
9093
|
+
// envelope, recomputed + constant-time-compared on read. No external
|
|
9094
|
+
// store: `binding.fingerprint(req)` is a pure function of the request, so
|
|
9095
|
+
// the fingerprint lives in the cookie itself rather than a session table.
|
|
9096
|
+
//
|
|
9097
|
+
// The IP component is deliberately disabled (`ipPrefixBits {v4:0, v6:0}`):
|
|
9098
|
+
// a mobile or VPN visitor roams networks constantly, and signing them out
|
|
9099
|
+
// on a network hop is a worse outcome than the residual portability a
|
|
9100
|
+
// UA+Accept-only fingerprint leaves. Drift in the device shape is the
|
|
9101
|
+
// signal; a network change is not.
|
|
9102
|
+
//
|
|
9103
|
+
// `b.sessionDeviceBinding.create()` refuses to construct without either a
|
|
9104
|
+
// bindingStore or storeInSession — neither of which the store-free
|
|
9105
|
+
// `fingerprint()` path touches. Pass a b.cache-shaped no-op so the
|
|
9106
|
+
// constructor's opt-shape gate is satisfied; its methods are never called.
|
|
9107
|
+
var _NOOP_BINDING_STORE = {
|
|
9108
|
+
get: function () { return undefined; },
|
|
9109
|
+
set: function () { return undefined; },
|
|
9110
|
+
del: function () { return undefined; },
|
|
9111
|
+
};
|
|
9112
|
+
var _deviceBinding = null;
|
|
9113
|
+
function _deviceBindingInstance() {
|
|
9114
|
+
if (!_deviceBinding) {
|
|
9115
|
+
_deviceBinding = b.sessionDeviceBinding.create({
|
|
9116
|
+
bindingStore: _NOOP_BINDING_STORE,
|
|
9117
|
+
ipPrefixBits: { v4: 0, v6: 0 },
|
|
9118
|
+
});
|
|
9119
|
+
}
|
|
9120
|
+
return _deviceBinding;
|
|
9121
|
+
}
|
|
9122
|
+
|
|
9123
|
+
// The hex device fingerprint for THIS request, or null when it can't be
|
|
9124
|
+
// computed (a request shape the primitive refuses). A null fingerprint is
|
|
9125
|
+
// stored as "no binding" — the read side skips the check rather than
|
|
9126
|
+
// signing the visitor out over a missing signal.
|
|
9127
|
+
function _authDeviceFingerprint(req) {
|
|
9128
|
+
try {
|
|
9129
|
+
var fp = _deviceBindingInstance().fingerprint(req);
|
|
9130
|
+
return fp ? fp.toString("hex") : null;
|
|
9131
|
+
} catch (_e) {
|
|
9132
|
+
return null;
|
|
9133
|
+
}
|
|
9134
|
+
}
|
|
9135
|
+
|
|
9088
9136
|
// Auth + WebAuthn-challenge cookies carry a vault-sealed JSON envelope.
|
|
9089
9137
|
// writeSealed/readSealed handle the seal + the on-wire prefix; the
|
|
9090
9138
|
// caller works in plain objects.
|
|
9091
9139
|
function _setAuthCookie(req, res, env) {
|
|
9092
9140
|
var T = b.constants.TIME;
|
|
9093
9141
|
var secure = _secureForReq(req);
|
|
9094
|
-
|
|
9142
|
+
// Stash the device fingerprint inside the sealed envelope at mint time
|
|
9143
|
+
// so a later read can detect a cookie that has moved to a different
|
|
9144
|
+
// device shape. Additive: an env handed in WITHOUT `fp` (a caller that
|
|
9145
|
+
// doesn't know about binding) just gets it filled here.
|
|
9146
|
+
var sealed = env;
|
|
9147
|
+
if (env && env.fp == null) {
|
|
9148
|
+
var fp = _authDeviceFingerprint(req);
|
|
9149
|
+
if (fp) sealed = Object.assign({}, env, { fp: fp });
|
|
9150
|
+
}
|
|
9151
|
+
_cookieJar().writeSealed(res, _authCookieName(secure), JSON.stringify(sealed), {
|
|
9095
9152
|
expires: new Date(Date.now() + T.days(14)),
|
|
9096
9153
|
secure: secure,
|
|
9097
9154
|
});
|
|
@@ -9570,6 +9627,14 @@ var LOGIN_ERROR_MESSAGES = {
|
|
|
9570
9627
|
link: "That sign-in link is invalid or has expired. Request a fresh one.",
|
|
9571
9628
|
};
|
|
9572
9629
|
|
|
9630
|
+
// Neutral (non-error) notices surfaced on the sign-in screen. The
|
|
9631
|
+
// device-binding soft sign-out lands here: a reassuring "sign in again"
|
|
9632
|
+
// message, never an alarming error, and it discloses nothing about WHY
|
|
9633
|
+
// (no "your session looked suspicious" — the visitor just signs in again).
|
|
9634
|
+
var LOGIN_NOTICE_MESSAGES = {
|
|
9635
|
+
device: "You've been signed out for your security. Please sign in again.",
|
|
9636
|
+
};
|
|
9637
|
+
|
|
9573
9638
|
function renderAccountLogin(opts) {
|
|
9574
9639
|
opts = opts || {};
|
|
9575
9640
|
var oauthButtons = "";
|
|
@@ -9588,6 +9653,12 @@ function renderAccountLogin(opts) {
|
|
|
9588
9653
|
var errHtml = (opts.error && LOGIN_ERROR_MESSAGES[opts.error])
|
|
9589
9654
|
? "<p class=\"auth-form__message auth-form__message--err\">" + b.template.escapeHtml(LOGIN_ERROR_MESSAGES[opts.error]) + "</p>"
|
|
9590
9655
|
: "";
|
|
9656
|
+
// Neutral notice (e.g. the device-binding soft sign-out) renders in the
|
|
9657
|
+
// same slot with non-error styling. Error wins if both are somehow set.
|
|
9658
|
+
if (!errHtml && opts.notice && LOGIN_NOTICE_MESSAGES[opts.notice]) {
|
|
9659
|
+
errHtml = "<p class=\"auth-form__message\" role=\"status\">" +
|
|
9660
|
+
b.template.escapeHtml(LOGIN_NOTICE_MESSAGES[opts.notice]) + "</p>";
|
|
9661
|
+
}
|
|
9591
9662
|
// Render the email-link path INLINE (a working no-JS form), not as a link
|
|
9592
9663
|
// to a separate page, so both passwordless paths live on one screen.
|
|
9593
9664
|
var magicHtml = opts.magic_link_enabled ? LOGIN_MAGIC_INLINE : "";
|
|
@@ -11910,9 +11981,28 @@ function mount(router, deps) {
|
|
|
11910
11981
|
// block) and the account routes inside it, so there's one auth-cookie
|
|
11911
11982
|
// reader rather than a copy per call site. A missing / malformed /
|
|
11912
11983
|
// expired cookie returns null — never throws.
|
|
11984
|
+
//
|
|
11985
|
+
// Device-binding (soft): an envelope carrying a stashed `fp` is checked
|
|
11986
|
+
// against THIS request's recomputed fingerprint with a constant-time
|
|
11987
|
+
// compare. On drift the visitor reads as signed-out (return null) and a
|
|
11988
|
+
// one-shot `req._authDeviceDrift` flag is set so the page-level gates
|
|
11989
|
+
// clear the now-stale cookie and bounce to a neutral sign-in — never a
|
|
11990
|
+
// hard 401 mid-page. A pre-binding envelope (no `fp`, minted before this
|
|
11991
|
+
// shipped) passes through unchanged until its natural expiry, so a
|
|
11992
|
+
// deploy never mass-signs-out live sessions.
|
|
11913
11993
|
function _currentCustomerEnv(req) {
|
|
11914
11994
|
var env = _readAuthEnv(req);
|
|
11915
11995
|
if (!env || !env.customer_id || !env.exp || env.exp < Date.now()) return null;
|
|
11996
|
+
if (typeof env.fp === "string" && env.fp.length > 0) {
|
|
11997
|
+
var current = _authDeviceFingerprint(req);
|
|
11998
|
+
// A request whose fingerprint can't be recomputed (current === null)
|
|
11999
|
+
// is NOT treated as drift — absence of signal is not evidence of a
|
|
12000
|
+
// moved cookie. Only a present-but-mismatching fingerprint signs out.
|
|
12001
|
+
if (current !== null && !b.crypto.timingSafeEqual(env.fp, current)) {
|
|
12002
|
+
if (req) req._authDeviceDrift = true;
|
|
12003
|
+
return null;
|
|
12004
|
+
}
|
|
12005
|
+
}
|
|
11916
12006
|
return env;
|
|
11917
12007
|
}
|
|
11918
12008
|
|
|
@@ -14808,6 +14898,9 @@ function mount(router, deps) {
|
|
|
14808
14898
|
res.status(303); res.setHeader && res.setHeader("location", "/account");
|
|
14809
14899
|
return res.end ? res.end() : res.send("");
|
|
14810
14900
|
}
|
|
14901
|
+
// A drifted cookie that reached the sign-in screen directly still gets
|
|
14902
|
+
// cleared so the next request carries no stale envelope.
|
|
14903
|
+
if (req && req._authDeviceDrift) _clearAuthCookie(req, res);
|
|
14811
14904
|
var cartCount = await _cartCountForReq(req);
|
|
14812
14905
|
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
14813
14906
|
// Login captcha is opt-in (CAPTCHA_GATE_LOGIN). The widget + the scoped
|
|
@@ -14827,6 +14920,7 @@ function mount(router, deps) {
|
|
|
14827
14920
|
apple_enabled: !!deps.oauthApple,
|
|
14828
14921
|
magic_link_enabled: !!(deps.customerPortal && deps.customerPortalEmail),
|
|
14829
14922
|
error: url && url.searchParams.get("error"),
|
|
14923
|
+
notice: url && url.searchParams.get("signed_out"),
|
|
14830
14924
|
captcha_kind: captchaLoginOn ? captchaKind : null,
|
|
14831
14925
|
captcha_public_key: captchaLoginOn ? captchaPubKey : null,
|
|
14832
14926
|
}));
|
|
@@ -15247,7 +15341,12 @@ function mount(router, deps) {
|
|
|
15247
15341
|
throw e;
|
|
15248
15342
|
}
|
|
15249
15343
|
if (!auth) {
|
|
15250
|
-
|
|
15344
|
+
// On device-binding drift, clear the stale cookie + surface a
|
|
15345
|
+
// neutral sign-in notice (matches _accountAuth's soft sign-out).
|
|
15346
|
+
if (req && req._authDeviceDrift) _clearAuthCookie(req, res);
|
|
15347
|
+
res.status(303);
|
|
15348
|
+
res.setHeader && res.setHeader("location",
|
|
15349
|
+
(req && req._authDeviceDrift) ? "/account/login?signed_out=device" : "/account/login");
|
|
15251
15350
|
return res.end ? res.end() : res.send("");
|
|
15252
15351
|
}
|
|
15253
15352
|
var customer = await deps.customers.get(auth.customer_id);
|
|
@@ -15505,7 +15604,14 @@ function mount(router, deps) {
|
|
|
15505
15604
|
throw e;
|
|
15506
15605
|
}
|
|
15507
15606
|
if (!auth) {
|
|
15508
|
-
|
|
15607
|
+
// Device-binding drift: clear the now-stale cookie and bounce to a
|
|
15608
|
+
// neutral sign-in notice (never a hard 401 mid-page). Any other
|
|
15609
|
+
// not-signed-in case (no cookie, expired) bounces to the plain
|
|
15610
|
+
// login with no notice.
|
|
15611
|
+
if (req && req._authDeviceDrift) _clearAuthCookie(req, res);
|
|
15612
|
+
res.status(303);
|
|
15613
|
+
res.setHeader && res.setHeader("location",
|
|
15614
|
+
(req && req._authDeviceDrift) ? "/account/login?signed_out=device" : "/account/login");
|
|
15509
15615
|
res.end ? res.end() : res.send("");
|
|
15510
15616
|
return null;
|
|
15511
15617
|
}
|
package/package.json
CHANGED