@blamejs/blamejs-shop 0.4.85 → 0.4.86

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/lib/asset-manifest.json +1 -1
  3. package/lib/order.js +4 -2
  4. package/lib/security-middleware.js +1 -0
  5. package/lib/vendor/MANIFEST.json +71 -39
  6. package/lib/vendor/blamejs/CHANGELOG.md +2 -0
  7. package/lib/vendor/blamejs/api-snapshot.json +11 -3
  8. package/lib/vendor/blamejs/lib/atomic-file.js +34 -0
  9. package/lib/vendor/blamejs/lib/auth/fido-mds3.js +10 -0
  10. package/lib/vendor/blamejs/lib/auth/password.js +1 -0
  11. package/lib/vendor/blamejs/lib/auth/saml.js +11 -3
  12. package/lib/vendor/blamejs/lib/daemon.js +4 -1
  13. package/lib/vendor/blamejs/lib/external-db.js +131 -0
  14. package/lib/vendor/blamejs/lib/graphql-federation.js +25 -15
  15. package/lib/vendor/blamejs/lib/log-stream-cloudwatch.js +1 -0
  16. package/lib/vendor/blamejs/lib/log-stream-local.js +14 -1
  17. package/lib/vendor/blamejs/lib/log-stream-otlp.js +1 -0
  18. package/lib/vendor/blamejs/lib/log-stream-webhook.js +1 -0
  19. package/lib/vendor/blamejs/lib/mail-auth.js +69 -14
  20. package/lib/vendor/blamejs/lib/mail-bimi.js +6 -0
  21. package/lib/vendor/blamejs/lib/mail-crypto-smime.js +10 -0
  22. package/lib/vendor/blamejs/lib/mail-dkim.js +68 -15
  23. package/lib/vendor/blamejs/lib/mail.js +39 -0
  24. package/lib/vendor/blamejs/lib/middleware/api-encrypt.js +6 -2
  25. package/lib/vendor/blamejs/lib/network-dns-resolver.js +61 -11
  26. package/lib/vendor/blamejs/lib/network-dns.js +47 -2
  27. package/lib/vendor/blamejs/lib/network-nts.js +16 -0
  28. package/lib/vendor/blamejs/lib/network-proxy.js +55 -2
  29. package/lib/vendor/blamejs/lib/object-store/azure-blob-bucket-ops.js +1 -0
  30. package/lib/vendor/blamejs/lib/object-store/gcs-bucket-ops.js +1 -0
  31. package/lib/vendor/blamejs/lib/object-store/http-request.js +4 -0
  32. package/lib/vendor/blamejs/lib/outbox.js +29 -0
  33. package/lib/vendor/blamejs/lib/queue-sqs.js +1 -0
  34. package/lib/vendor/blamejs/lib/request-helpers.js +25 -6
  35. package/lib/vendor/blamejs/lib/session-device-binding.js +46 -24
  36. package/lib/vendor/blamejs/lib/session.js +85 -28
  37. package/lib/vendor/blamejs/package.json +1 -1
  38. package/lib/vendor/blamejs/release-notes/v0.15.16.json +94 -0
  39. package/lib/vendor/blamejs/test/layer-0-primitives/api-encrypt.test.js +51 -0
  40. package/lib/vendor/blamejs/test/layer-0-primitives/atomic-file-open-append-nofollow.test.js +87 -0
  41. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +48 -0
  42. package/lib/vendor/blamejs/test/layer-0-primitives/external-db-non-atomic-backend.test.js +200 -0
  43. package/lib/vendor/blamejs/test/layer-0-primitives/fido-mds3-cert-bad-validity.test.js +159 -0
  44. package/lib/vendor/blamejs/test/layer-0-primitives/graphql-federation.test.js +49 -2
  45. package/lib/vendor/blamejs/test/layer-0-primitives/mail-auth-dmarc-policy-failclosed.test.js +139 -0
  46. package/lib/vendor/blamejs/test/layer-0-primitives/mail-bimi-cert-validity-unparseable.test.js +137 -0
  47. package/lib/vendor/blamejs/test/layer-0-primitives/mail-crypto-smime-bad-validity.test.js +134 -0
  48. package/lib/vendor/blamejs/test/layer-0-primitives/mail-dkim-numericdate-failclosed.test.js +155 -0
  49. package/lib/vendor/blamejs/test/layer-0-primitives/mail-proxy-framing-bounds.test.js +263 -0
  50. package/lib/vendor/blamejs/test/layer-0-primitives/network-dns-lookup-timeout-default.test.js +116 -0
  51. package/lib/vendor/blamejs/test/layer-0-primitives/network-dns-resolver-timeout.test.js +126 -0
  52. package/lib/vendor/blamejs/test/layer-0-primitives/network-nts-handshake-byte-cap.test.js +127 -0
  53. package/lib/vendor/blamejs/test/layer-0-primitives/saml-subjectconfirmation-notbefore.test.js +238 -0
  54. package/lib/vendor/blamejs/test/layer-0-primitives/session-destroy-all-store-backed.test.js +128 -0
  55. package/lib/vendor/blamejs/test/layer-0-primitives/session-device-binding-ipv6-canonical-and-no-store.test.js +202 -0
  56. package/lib/vendor/blamejs/test/layer-0-primitives/session-device-binding.test.js +18 -9
  57. package/lib/vendor/blamejs/test/layer-0-primitives/webhook-verify-nonce-atomic.test.js +169 -0
  58. package/lib/webhooks.js +38 -9
  59. package/package.json +1 -1
@@ -0,0 +1,202 @@
1
+ "use strict";
2
+ /**
3
+ * b.sessionDeviceBinding — two regressions:
4
+ *
5
+ * (A) IPv6 prefix masking masked the client IP by textual ':'-group slicing
6
+ * with no canonical normalization, so a `::`-shorthand address and its
7
+ * fully-expanded equivalent (2001:db8::1 vs 2001:db8:0:0:0:0:0:1), or a
8
+ * leading-zero-folded group, hashed to DIFFERENT device fingerprints.
9
+ * A roaming user whose proxy reports the address in a different textual
10
+ * form on the next request was logged out on a FALSE drift.
11
+ *
12
+ * (B) #330 — create() refused to construct without a store, so the stateless,
13
+ * store-free fingerprint() (the soft device-binding building block for
14
+ * self-validating tokens) was unreachable through create(). The static
15
+ * b.sessionDeviceBinding.fingerprint(req, opts) and a no-store instance
16
+ * whose fingerprint() works (while bind/verify throw a clear "no store")
17
+ * both make it reachable.
18
+ *
19
+ * Run standalone:
20
+ * node test/layer-0-primitives/session-device-binding-ipv6-canonical-and-no-store.test.js
21
+ * Or via smoke:
22
+ * node test/smoke.js
23
+ */
24
+
25
+ var helpers = require("../helpers");
26
+ var b = helpers.b;
27
+ var check = helpers.check;
28
+
29
+ function _memoryStore() {
30
+ var data = new Map();
31
+ return {
32
+ data: data,
33
+ get: function (k) { return Promise.resolve(data.get(k)); },
34
+ set: function (k, v) { data.set(k, v); return Promise.resolve(); },
35
+ del: function (k) { data.delete(k); return Promise.resolve(); },
36
+ };
37
+ }
38
+
39
+ function _req(remoteAddress) {
40
+ return {
41
+ url: "/x",
42
+ method: "GET",
43
+ headers: {
44
+ "user-agent": "Mozilla/5.0 (Macintosh; Intel)",
45
+ "accept-language": "en-US,en;q=0.9",
46
+ "accept-encoding": "gzip, br",
47
+ },
48
+ socket: { remoteAddress: remoteAddress },
49
+ };
50
+ }
51
+
52
+ // Two textually-different but semantically-IDENTICAL IPv6 forms of the same
53
+ // address. A canonical masker must collapse them to one fingerprint bucket.
54
+ var IPV6_SHORTHAND = "2001:db8::1";
55
+ var IPV6_EXPANDED = "2001:db8:0:0:0:0:0:1";
56
+ // Leading-zero fold: 2001:0db8 vs 2001:db8 are the same group value.
57
+ var IPV6_LEADING_ZEROS = "2001:0db8::1";
58
+
59
+ // (A) Static fingerprint — equivalent IPv6 forms must hash identically.
60
+ function testStaticFingerprintCanonicalIpv6() {
61
+ var fpShort = b.sessionDeviceBinding.fingerprint(_req(IPV6_SHORTHAND));
62
+ var fpExpanded = b.sessionDeviceBinding.fingerprint(_req(IPV6_EXPANDED));
63
+ var fpLeading = b.sessionDeviceBinding.fingerprint(_req(IPV6_LEADING_ZEROS));
64
+ check("(A) static fp: :: shorthand == fully expanded",
65
+ fpShort.equals(fpExpanded));
66
+ check("(A) static fp: :: shorthand == leading-zero form",
67
+ fpShort.equals(fpLeading));
68
+ // Sanity: a genuinely different /64 still diverges (the mask still binds).
69
+ var fpOther = b.sessionDeviceBinding.fingerprint(_req("2001:db9::1"));
70
+ check("(A) static fp: different /64 still diverges", !fpShort.equals(fpOther));
71
+ }
72
+
73
+ // (A) Real consumer path — bind() with one textual form, verify() with the
74
+ // equivalent form, on the shipped instance API. Must NOT drift.
75
+ async function testVerifyCanonicalIpv6AcrossForms() {
76
+ var binding = b.sessionDeviceBinding.create({ bindingStore: _memoryStore() });
77
+ var token = "tok_v6";
78
+ await binding.bind(token, _req(IPV6_SHORTHAND));
79
+
80
+ var verdict = await binding.verify(token, _req(IPV6_EXPANDED));
81
+ check("(A) verify ok: bind(::) then verify(expanded)", verdict.ok === true);
82
+
83
+ var verdict2 = await binding.verify(token, _req(IPV6_LEADING_ZEROS));
84
+ check("(A) verify ok: bind(::) then verify(leading-zeros)", verdict2.ok === true);
85
+
86
+ // A different /64 IPv6 still drifts (the prefix check is alive, not disabled).
87
+ var drift = await binding.verify(token, _req("2001:db9::1"));
88
+ check("(A) verify drift: bind(2001:db8::) then verify(2001:db9::)",
89
+ drift.ok === false && drift.reason === "drift");
90
+ }
91
+
92
+ // (A) The documented `ipPrefixBits: { v4: 0, v6: 0 }` skip-the-IP escape hatch
93
+ // must still skip masking entirely (so a mobile client that switches networks
94
+ // is not logged out).
95
+ async function testIpPrefixBitsZeroSkipsIp() {
96
+ var binding = b.sessionDeviceBinding.create({
97
+ bindingStore: _memoryStore(),
98
+ ipPrefixBits: { v4: 0, v6: 0 },
99
+ });
100
+ var token = "tok_skip";
101
+ await binding.bind(token, _req("2001:db8::1"));
102
+ var across = await binding.verify(token, _req("2001:dead:beef::99"));
103
+ check("(A) ipPrefixBits 0/0: unrelated IPv6 still ok (IP skipped)",
104
+ across.ok === true);
105
+ var binding4 = b.sessionDeviceBinding.create({
106
+ bindingStore: _memoryStore(),
107
+ ipPrefixBits: { v4: 0, v6: 0 },
108
+ });
109
+ await binding4.bind("t4", _req("198.51.100.1"));
110
+ var across4 = await binding4.verify("t4", _req("203.0.113.200"));
111
+ check("(A) ipPrefixBits 0/0: unrelated IPv4 still ok (IP skipped)",
112
+ across4.ok === true);
113
+ }
114
+
115
+ // (B) #330 — a no-store create() returns an instance whose fingerprint() is
116
+ // usable (pure, no store), while bind/verify/unbind throw a clear "no store
117
+ // configured" error. No fabricated no-op store.
118
+ async function testNoStoreInstanceFingerprintReachable() {
119
+ var binding = b.sessionDeviceBinding.create({}); // no store, no storeInSession
120
+ check("(B) no-store create() returns an object", binding && typeof binding === "object");
121
+
122
+ var fp = binding.fingerprint(_req("2001:db8::1"));
123
+ check("(B) no-store instance fingerprint() returns a 32-byte Buffer",
124
+ Buffer.isBuffer(fp) && fp.length === 32);
125
+
126
+ // It must match the static stateless digest for the same shape (one algorithm).
127
+ var staticFp = b.sessionDeviceBinding.fingerprint(_req("2001:db8::1"));
128
+ check("(B) no-store instance fingerprint() == static fingerprint()",
129
+ fp.equals(staticFp));
130
+
131
+ var bindThrew = false;
132
+ try { await binding.bind("t", _req("2001:db8::1")); }
133
+ catch (e) { bindThrew = e && e.code === "session-device-binding/no-store"; }
134
+ check("(B) no-store bind() throws no-store error", bindThrew);
135
+
136
+ var verifyThrew = false;
137
+ try { await binding.verify("t", _req("2001:db8::1")); }
138
+ catch (e) { verifyThrew = e && e.code === "session-device-binding/no-store"; }
139
+ check("(B) no-store verify() throws no-store error", verifyThrew);
140
+
141
+ var unbindThrew = false;
142
+ try { await binding.unbind("t"); }
143
+ catch (e) { unbindThrew = e && e.code === "session-device-binding/no-store"; }
144
+ check("(B) no-store unbind() throws no-store error", unbindThrew);
145
+ }
146
+
147
+ // (B) #330 — the static stateless entry point works with no create() at all.
148
+ function testStaticFingerprintNoStore() {
149
+ check("(B) b.sessionDeviceBinding.fingerprint is a function",
150
+ typeof b.sessionDeviceBinding.fingerprint === "function");
151
+ var fp = b.sessionDeviceBinding.fingerprint(_req("203.0.113.7"));
152
+ check("(B) static fingerprint() returns a 32-byte Buffer with no store",
153
+ Buffer.isBuffer(fp) && fp.length === 32);
154
+ }
155
+
156
+ // Codex P2 (#362): routing the IP mask through requestHelpers.ipPrefix must
157
+ // PRESERVE the configured prefix width, not silently force the helper's bare
158
+ // /24 + /64 default. The device-binding IPv6 default is /48; a client roaming
159
+ // within its /48 allocation but across a different /64 must NOT drift. (RED
160
+ // when ipPrefix ignores the width and buckets at /64.)
161
+ async function testConfiguredV6PrefixWidthPreserved() {
162
+ // Default instance (v6 = /48): same /48, DIFFERENT /64 -> same fingerprint.
163
+ var inst = b.sessionDeviceBinding.create({ bindingStore: _memoryStore() });
164
+ var fpA = inst.fingerprint(_req("2001:db8:0:1::5"));
165
+ var fpB = inst.fingerprint(_req("2001:db8:0:2::9")); // same /48, different /64
166
+ check("(P2) default v6=/48: same /48 different /64 -> same fp (not /64)", fpA.equals(fpB));
167
+ var fpC = inst.fingerprint(_req("2001:db8:1::5")); // different /48
168
+ check("(P2) default v6=/48: different /48 still diverges", !fpA.equals(fpC));
169
+
170
+ // Configured wider bucket (v6 = /32): same /32, different /48 -> same fp.
171
+ var wide = b.sessionDeviceBinding.create({ bindingStore: _memoryStore(), ipPrefixBits: { v6: 32 } });
172
+ var wA = wide.fingerprint(_req("2001:db8:1::1"));
173
+ var wB = wide.fingerprint(_req("2001:db8:99::1")); // same /32, different /48
174
+ check("(P2) configured v6=/32: same /32 different /48 -> same fp (width honored)", wA.equals(wB));
175
+
176
+ // Configured tighter bucket (v6 = /64): same /48 different /64 -> DIVERGES.
177
+ var tight = b.sessionDeviceBinding.create({ bindingStore: _memoryStore(), ipPrefixBits: { v6: 64 } });
178
+ var tA = tight.fingerprint(_req("2001:db8:0:1::5"));
179
+ var tB = tight.fingerprint(_req("2001:db8:0:2::9")); // same /48, different /64
180
+ check("(P2) configured v6=/64: same /48 different /64 -> diverges (width honored)", !tA.equals(tB));
181
+ }
182
+
183
+ async function run() {
184
+ testStaticFingerprintCanonicalIpv6();
185
+ await testVerifyCanonicalIpv6AcrossForms();
186
+ await testIpPrefixBitsZeroSkipsIp();
187
+ await testConfiguredV6PrefixWidthPreserved();
188
+ await testNoStoreInstanceFingerprintReachable();
189
+ testStaticFingerprintNoStore();
190
+ }
191
+
192
+ if (require.main === module) {
193
+ run().then(function () {
194
+ console.log("OK session-device-binding-ipv6-canonical-and-no-store — "
195
+ + helpers.getChecks() + " checks");
196
+ }).catch(function (e) {
197
+ console.error("FAIL:", e && e.stack || e);
198
+ process.exit(1);
199
+ });
200
+ }
201
+
202
+ module.exports = { run: run };
@@ -59,16 +59,25 @@ function testSurface() {
59
59
  typeof b.sessionDeviceBinding.SessionDeviceBindingError === "function");
60
60
  }
61
61
 
62
- function testCreateRejectsBadOpts() {
62
+ async function testCreateRejectsBadOpts() {
63
63
  var threw;
64
64
 
65
- threw = false;
66
- try { b.sessionDeviceBinding.create(); } catch (_e) { threw = true; }
67
- check("create() rejects empty opts", threw);
68
-
69
- threw = false;
70
- try { b.sessionDeviceBinding.create({}); } catch (_e) { threw = true; }
71
- check("create() requires bindingStore or storeInSession", threw);
65
+ // #330 — a no-store create() (no opts, or {} with neither bindingStore nor
66
+ // storeInSession) now returns an INSTANCE whose stateless fingerprint() works
67
+ // (the soft device-binding building block for self-validating tokens), while
68
+ // the persisted bind()/verify()/unbind() lifecycle throws a clear
69
+ // "no store configured" — instead of refusing to construct at all.
70
+ var noStore = b.sessionDeviceBinding.create();
71
+ check("create() with no opts returns a no-store instance", noStore && typeof noStore.fingerprint === "function");
72
+ var noStoreFp = noStore.fingerprint(_mockReq());
73
+ check("no-store instance fingerprint(req) returns a digest", Buffer.isBuffer(noStoreFp) && noStoreFp.length > 0);
74
+ var noStore2 = b.sessionDeviceBinding.create({});
75
+ check("create({}) returns a no-store instance", noStore2 && typeof noStore2.fingerprint === "function");
76
+ // bind() is async, so the no-store guard surfaces as a rejection.
77
+ var bindErr = null;
78
+ try { await noStore2.bind("tok_x", _mockReq()); } catch (e) { bindErr = e; }
79
+ check("no-store bind() fails closed with session-device-binding/no-store",
80
+ bindErr && bindErr.code === "session-device-binding/no-store");
72
81
 
73
82
  threw = false;
74
83
  try {
@@ -242,7 +251,7 @@ function testNamespaceFingerprint() {
242
251
  async function run() {
243
252
  testSurface();
244
253
  testNamespaceFingerprint();
245
- testCreateRejectsBadOpts();
254
+ await testCreateRejectsBadOpts();
246
255
  await testBindAndVerifyHappyPath();
247
256
  await testVerifyDriftRefuses();
248
257
  await testVerifyMissingBindRefuses();
@@ -0,0 +1,169 @@
1
+ "use strict";
2
+ /**
3
+ * b.webhook.verify — replay nonceStore is atomic, not check-then-set (#328).
4
+ *
5
+ * The Stripe-shape inbound verifier must consult its replay store via the
6
+ * atomic { checkAndInsert(nonce, expireAt) → bool } contract — the SAME
7
+ * contract b.nonceStore.create exposes and b.webhook.verifier already uses.
8
+ * A non-atomic check-then-set ({ has, set }) races two concurrent
9
+ * redeliveries of the same event: both observe "unseen", both proceed
10
+ * (CWE-367 TOCTOU). These tests drive the real b.webhook.verify consumer
11
+ * path with the framework's own b.nonceStore.create so the store drops in
12
+ * with no adapter, and assert that of two byte-identical concurrent
13
+ * deliveries exactly one is accepted.
14
+ *
15
+ * RED proof (old buggy non-atomic await-has → if-seen-throw → await-set):
16
+ * two concurrent verifies of the same signature BOTH observe "unseen" and
17
+ * BOTH resolve ok — "concurrent: exactly one accepted" would fail (2 ok,
18
+ * 0 replay). The atomic checkAndInsert collapses that to 1 ok / 1 replay.
19
+ *
20
+ * Run standalone: `node test/layer-0-primitives/webhook-verify-nonce-atomic.test.js`
21
+ * Or via smoke: `node test/smoke.js`
22
+ */
23
+
24
+ var helpers = require("../helpers");
25
+ var b = helpers.b;
26
+ var check = helpers.check;
27
+
28
+ var STRIPE_ALG = "hmac-sha256-stripe";
29
+
30
+ // Build a valid Stripe-Signature header for (body, secret, ts) through the
31
+ // public round-trip companion — no dependence on another test file's helper.
32
+ function _header(body, secret, ts) {
33
+ return b.webhook.sign({ alg: STRIPE_ALG, secret: secret, body: body, timestamp: ts });
34
+ }
35
+
36
+ // The framework's own replay store satisfies the verifier's contract with
37
+ // no adapter: a first delivery is accepted, a second (sequential) replay of
38
+ // the same signature is refused with the stable webhook/replay code.
39
+ async function testFrameworkStoreDropsIn() {
40
+ var body = '{"event":"checkout.completed","id":"evt_drop_in"}';
41
+ var secret = "whsec_drop_in_secret";
42
+ var ts = Math.floor(Date.now() / 1000);
43
+ var header = _header(body, secret, ts);
44
+ var ns = b.nonceStore.create({ backend: "memory" });
45
+
46
+ var r1 = await b.webhook.verify({
47
+ alg: STRIPE_ALG, secret: secret, header: header, body: body, nonceStore: ns,
48
+ });
49
+ check("framework store: first delivery accepted", r1.ok === true);
50
+ check("framework store: scheme v1", r1.scheme === "v1");
51
+ check("framework store: timestamp echoed", r1.timestamp === ts);
52
+
53
+ var replayed = false;
54
+ try {
55
+ await b.webhook.verify({
56
+ alg: STRIPE_ALG, secret: secret, header: header, body: body, nonceStore: ns,
57
+ });
58
+ } catch (e) {
59
+ replayed = true;
60
+ check("framework store: replay → webhook/replay", e.code === "webhook/replay");
61
+ }
62
+ check("framework store: sequential replay refused", replayed);
63
+
64
+ if (ns.close) await ns.close();
65
+ }
66
+
67
+ // The core of #328: of two concurrent verifies of the SAME signature with a
68
+ // SHARED framework store, exactly one is accepted and the other is refused as
69
+ // a replay. A non-atomic check-then-set would let both through (the TOCTOU
70
+ // the atomic checkAndInsert closes).
71
+ async function testConcurrentExactlyOneAccepted() {
72
+ var body = '{"event":"invoice.paid","id":"evt_concurrent"}';
73
+ var secret = "whsec_concurrent_secret";
74
+ var ts = Math.floor(Date.now() / 1000);
75
+ var header = _header(body, secret, ts);
76
+ var ns = b.nonceStore.create({ backend: "memory" });
77
+
78
+ var settled = await Promise.allSettled([
79
+ b.webhook.verify({ alg: STRIPE_ALG, secret: secret, header: header, body: body, nonceStore: ns }),
80
+ b.webhook.verify({ alg: STRIPE_ALG, secret: secret, header: header, body: body, nonceStore: ns }),
81
+ ]);
82
+
83
+ var accepted = settled.filter(function (s) {
84
+ return s.status === "fulfilled" && s.value && s.value.ok === true;
85
+ }).length;
86
+ var replays = settled.filter(function (s) {
87
+ return s.status === "rejected" && s.reason && s.reason.code === "webhook/replay";
88
+ }).length;
89
+
90
+ check("concurrent: exactly one accepted", accepted === 1);
91
+ check("concurrent: exactly one replay", replays === 1);
92
+
93
+ if (ns.close) await ns.close();
94
+ }
95
+
96
+ // A higher-concurrency fan-out hardens the assertion: N byte-identical
97
+ // deliveries land at once and STILL exactly one is accepted — the atomic
98
+ // gate admits a single winner regardless of contention depth.
99
+ async function testConcurrentFanoutSingleWinner() {
100
+ var body = '{"event":"charge.refunded","id":"evt_fanout"}';
101
+ var secret = "whsec_fanout_secret";
102
+ var ts = Math.floor(Date.now() / 1000);
103
+ var header = _header(body, secret, ts);
104
+ var ns = b.nonceStore.create({ backend: "memory" });
105
+
106
+ var N = 8;
107
+ var calls = [];
108
+ for (var i = 0; i < N; i += 1) {
109
+ calls.push(b.webhook.verify({
110
+ alg: STRIPE_ALG, secret: secret, header: header, body: body, nonceStore: ns,
111
+ }));
112
+ }
113
+ var settled = await Promise.allSettled(calls);
114
+ var accepted = settled.filter(function (s) {
115
+ return s.status === "fulfilled" && s.value && s.value.ok === true;
116
+ }).length;
117
+ var replays = settled.filter(function (s) {
118
+ return s.status === "rejected" && s.reason && s.reason.code === "webhook/replay";
119
+ }).length;
120
+
121
+ check("fanout: exactly one accepted of " + N, accepted === 1);
122
+ check("fanout: remaining N-1 refused as replay", replays === N - 1);
123
+
124
+ if (ns.close) await ns.close();
125
+ }
126
+
127
+ // The contract migration: a store exposing only the legacy racy { has, set }
128
+ // shape (no checkAndInsert) is refused — the verifier fails closed on a
129
+ // store that cannot give it an atomic gate, rather than silently falling
130
+ // back to a check-then-set race.
131
+ async function testLegacyHasSetShapeRefused() {
132
+ var body = '{"event":"x","id":"evt_legacy"}';
133
+ var secret = "whsec_legacy_shape";
134
+ var ts = Math.floor(Date.now() / 1000);
135
+ var header = _header(body, secret, ts);
136
+ var legacyStore = {
137
+ has: function () { return Promise.resolve(false); },
138
+ set: function () { return Promise.resolve(); },
139
+ };
140
+
141
+ var refused = false;
142
+ try {
143
+ await b.webhook.verify({
144
+ alg: STRIPE_ALG, secret: secret, header: header, body: body, nonceStore: legacyStore,
145
+ });
146
+ } catch (e) {
147
+ refused = true;
148
+ check("legacy { has, set }: webhook/bad-nonce-store code", e.code === "webhook/bad-nonce-store");
149
+ }
150
+ check("legacy { has, set } store refused (no checkAndInsert)", refused);
151
+ }
152
+
153
+ async function run() {
154
+ check("b.webhook.verify is a function", typeof b.webhook.verify === "function");
155
+ check("b.nonceStore.create is a function", typeof b.nonceStore.create === "function");
156
+ await testFrameworkStoreDropsIn();
157
+ await testConcurrentExactlyOneAccepted();
158
+ await testConcurrentFanoutSingleWinner();
159
+ await testLegacyHasSetShapeRefused();
160
+ }
161
+
162
+ module.exports = { run: run };
163
+
164
+ if (require.main === module) {
165
+ run().then(
166
+ function () { console.log("OK — " + helpers.getChecks() + " checks passed"); },
167
+ function (e) { console.error("FAIL:", e.message); process.exit(1); }
168
+ );
169
+ }
package/lib/webhooks.js CHANGED
@@ -96,6 +96,14 @@ var SECRET_BYTES = 32;
96
96
  var BACKOFF_SCHEDULE_S = Object.freeze([60, 300, 1800, 14400, 86400]); // allow:raw-time-literal — seconds values; C.TIME returns ms
97
97
  var MAX_ATTEMPTS = 5;
98
98
 
99
+ // processRetries claims each due delivery by pushing its next_retry_at out by
100
+ // this lease before attempting it, so a second tick that overlaps a slow pass
101
+ // skips the in-flight row instead of double-attempting it. _attempt overwrites
102
+ // next_retry_at with the real backoff (or stamps delivered_at), so the lease
103
+ // only persists if the process dies mid-attempt — in which case the row is
104
+ // retried after the lease expires. Comfortably longer than a bounded pass.
105
+ var RETRY_CLAIM_LEASE_MS = C.TIME.minutes(5);
106
+
99
107
  // Window used by the per-endpoint rate-limit sliding count. One
100
108
  // minute is short enough that a wedged receiver doesn't permanently
101
109
  // brick its own delivery feed (the window naturally slides forward),
@@ -539,15 +547,17 @@ function create(opts) {
539
547
  return await _deliver(endpoint, row.event_type, row.payload_json);
540
548
  },
541
549
 
542
- // Polled scheduler entry point — picks deliveries whose
543
- // next_retry_at has come due and re-attempts each. Operators
544
- // wire this to a cron / Durable Object alarm / queue worker.
545
- // Returns the list of deliveries the call attempted.
550
+ // Polled scheduler entry point — picks deliveries whose next_retry_at has
551
+ // come due and re-attempts each. The worker's scheduled() handler drives
552
+ // this once a minute via the container's /_/webhook-retry-tick route.
553
+ // Returns the list of deliveries the call actually attempted (the ones it
554
+ // won the claim on).
546
555
  //
547
- // No claim/lock here the framework's single-writer assumption
548
- // for the deliveries table holds (the worker is the only writer).
549
- // A multi-writer deployment composes external locking before
550
- // calling processRetries.
556
+ // Each row is claimed with an atomic conditional UPDATE before it is
557
+ // attempted, so two overlapping ticks (a slow pass still running when the
558
+ // next minute fires) can never double-attempt the same delivery the
559
+ // claim moves next_retry_at out of the due window, and only the tick whose
560
+ // UPDATE matched (rowCount === 1) calls _attempt.
551
561
  processRetries: async function (procOpts) {
552
562
  var now = (procOpts && typeof procOpts.now === "number") ? procOpts.now : nowFn();
553
563
  var max = (procOpts && Number.isInteger(procOpts.max) && procOpts.max > 0) ? procOpts.max : 100;
@@ -561,8 +571,27 @@ function create(opts) {
561
571
  var attempted = [];
562
572
  for (var i = 0; i < r.rows.length; i += 1) {
563
573
  var row = r.rows[i];
574
+ // Skip a delivery whose endpoint has been deleted or PAUSED. Normal
575
+ // fan-out only sends to active endpoints, so the automatic cron must
576
+ // not keep POSTing to one an operator explicitly disabled. Checked
577
+ // BEFORE the claim, so a paused endpoint's pending retries keep their
578
+ // schedule position (next_retry_at untouched) and resume when it is
579
+ // re-enabled, rather than the lease churning forward every tick. A
580
+ // manual admin retry remains an explicit operator override.
564
581
  var endpoint = await _getEndpoint(row.endpoint_id);
565
- if (!endpoint) continue;
582
+ if (!endpoint || !(endpoint.active === 1 || endpoint.active === true)) continue;
583
+ // Atomic claim: push next_retry_at out by the lease so a concurrent
584
+ // tick's identical WHERE no longer matches this row. Only the winner
585
+ // (rowCount === 1) attempts; _attempt then overwrites next_retry_at
586
+ // with the real backoff or stamps delivered_at.
587
+ var claim = await query(
588
+ "UPDATE webhook_deliveries SET next_retry_at = ?1 " +
589
+ "WHERE id = ?2 AND next_retry_at IS NOT NULL AND next_retry_at <= ?3 AND delivered_at IS NULL",
590
+ [now + RETRY_CLAIM_LEASE_MS, row.id, now],
591
+ );
592
+ var claimed = claim && (claim.rowCount != null ? claim.rowCount
593
+ : (claim.meta && claim.meta.changes != null ? claim.meta.changes : claim.changes));
594
+ if (Number(claimed || 0) !== 1) continue; // lost the claim to a concurrent tick
566
595
  var d = await _attempt(row.id, endpoint, row.event_type, row.payload_json);
567
596
  attempted.push(d);
568
597
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.85",
3
+ "version": "0.4.86",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {