@blamejs/core 0.15.60 → 0.15.62
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/mail-arc-sign.js +1 -1
- package/lib/mail-auth.js +35 -14
- package/lib/mail-dkim.js +1 -0
- package/lib/queue-local.js +26 -11
- package/lib/queue-redis.js +77 -17
- package/lib/queue.js +3 -3
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.15.x
|
|
10
10
|
|
|
11
|
+
- v0.15.62 (2026-06-29) — **ARC evaluation now reads each hop's instance with the same strict parser the signature checks use, so a crafted ARC-Authentication-Results header can no longer forge the upstream auth-results surfaced to downstream policy.** b.mail.arc.evaluate returns finalAr — the most recent hop's ARC-Authentication-Results, the receiver's view of the upstream authentication results — which operators may key downstream decisions on. The instance tag (i=) on each ARC header was parsed by three different regexes: the indexing pass that drives the AMS/AS signature checks required i= with no surrounding space and at most three digits, while the finalAr extraction and the AMS header-retention test accepted a looser form (a space around =, unbounded digits). When a sealer signs without covering ARC-Authentication-Results in its AMS h= (permitted by RFC 8617 and supported by the verifier), an attacker holding no key could append a second ARC-Authentication-Results written so the strict pass ignored it while the loose pass consumed it — forging finalAr on a chain that still verified as pass. All ARC instance reads now route through one strict parser, so the evaluation surfaces the same hop the signatures validated. The release also repairs the b.mail.arc.sign excludeAarFromAms option (it was read but rejected by option validation, so the documented opt-out was unreachable) and routes the ARC-Seal signature's b= stripping through the shared tag-aware helper. **Fixed:** *ARC finalAr is read from the strictly-indexed hop, not a looser rescan* — b.mail.arc.evaluate extracted finalAr (and validated the per-hop AMS header retention) with a regex that accepted ARC instance tags the signature-indexing pass rejected — a space around i= or more than three digits. A sealer that omits ARC-Authentication-Results from its AMS h= leaves the AAR outside signature coverage; an attacker could then inject a second ARC-Authentication-Results whose instance the strict crypto pass skipped but the finalAr pass accepted, presenting attacker-chosen upstream auth-results on a chain that still reported pass. Every ARC instance read now goes through a single strict parser, so finalAr is always the AAR the chain's signatures actually covered. · *b.mail.arc.sign accepts excludeAarFromAms again* — The excludeAarFromAms option was read when building the AMS h= list but was missing from the function's option allow-list, so passing it raised an unknown-option error — the documented opt-out could not be used. It is now accepted. · *ARC-Seal b= stripping uses the shared tag-aware helper* — The ARC-Seal verification stripped the signature's b= value with a regex that could mis-zero a value containing b= inside another tag; it now uses the same tag-aware stripper as DKIM, so canonicalization matches the signer in every case. **Detectors:** *ARC instance parsing must use the shared strict reader* — A check fails the build if any ARC instance (i=) parsing regex is added outside the single shared reader, preventing a future divergence between the signature-indexing pass and the finalAr / header-retention passes.
|
|
12
|
+
|
|
13
|
+
- v0.15.61 (2026-06-29) — **The local and Redis job queues fence completion, failure, and lease extension on the lease the caller actually holds, so a worker finishing after its lease expired can no longer disturb a job another worker has since taken over.** On the local and Redis queue backends, complete(), fail(), and extendLease() identified a job only by its id. When a worker's lease expired, the sweep returned the job to the ready set and another worker leased and began running it; if the original worker then finished late, its complete() could mark the new worker's in-progress job done (and double-fire a cron repeat or re-release flow children), and its fail() could re-queue or dead-letter a job the new worker was still executing. Each lease now carries the job's attempts value (incremented once per lease), and complete(), fail(), and extendLease() act only when that value still matches — so a call from a worker that no longer holds the lease changes nothing. The generic consumer threads this automatically; the SQS backend already bound these actions to the message's receipt handle and is unchanged. **Fixed:** *Local and Redis queues bind complete/fail/extendLease to the held lease* — A long-running handler whose lease expired and was swept could have its job re-leased to a second worker; when the first worker finished, complete() marked the second worker's in-progress job done — double-firing a cron-recurring job's next enqueue and re-releasing its flow dependents — while fail() re-queued or dead-lettered the job the second worker was still running (re-executing or discarding in-flight work). The backends now fence each of these calls on the leased attempts value, which is bumped once per lease; only the worker that holds the current lease can complete, fail, or extend it. A stale call returns without mutating the queue. This brings the local and Redis backends to parity with the SQS backend, which already bound these actions to the message receipt handle.
|
|
14
|
+
|
|
11
15
|
- v0.15.60 (2026-06-29) — **`requireStepUp` binds the elevation grant to the authenticated principal, refusing a grant minted for a different user (cross-user step-up replay).** The b.middleware.requireStepUp gate accepts an operator-issued step-up elevation grant from the X-Step-Up-Grant header and verifies it with b.auth.stepUp.grant.verify. An elevation grant carries the subject it was minted for (payload.sub), but the middleware verified only the grant's signature, expiry, and scope — never that the grant's subject matched the request's authenticated principal. A grant minted for one user (and then leaked through a shared cache, a log line, a referrer, or a shared device) therefore satisfied the step-up requirement for ANY other authenticated user who presented it, elevating their session to the granted assurance level without ever completing a step-up ceremony. requireStepUp now passes the resolved principal as the grant's required subject, so the grant satisfies step-up only for the user it was issued to. The principal is resolved from whichever shape the authenticator populated — a session's req.user.id / req.user.userId, or the JWT subject (req.user.claims.sub / req.user.sub) set by bearerAuth with an external verifier — so a grant legitimately minted for a JWT subject still binds. A request with no resolvable principal cannot bind the grant and falls through to the claims-based challenge. **Security:** *Step-up elevation grants are bound to the authenticated principal* — requireStepUp's grant path called b.auth.stepUp.grant.verify with only the grant scope, not the subject, so any holder of a valid, unexpired, scope-matching elevation grant passed the step-up gate regardless of which user the request was authenticated as — a leaked or shared grant elevated a different user's session (cross-user step-up replay). The grant already binds a subject at mint time and the verifier supports a subject check; the middleware now supplies the request's principal as the required subject, refusing a grant whose subject does not match. The principal is read from whichever field the authenticator set — a session's id/userId or the JWT subject (claims.sub / sub) from bearerAuth's external verifier — so a grant minted for any of those binds correctly. A request with no authenticated principal cannot bind a grant and is handled by the normal claims-based step-up challenge. The grant verifier's signature/expiry/scope/jti-revocation checks are unchanged.
|
|
12
16
|
|
|
13
17
|
- v0.15.59 (2026-06-29) — **OCSP response validation binds the response to the certificate's issuer (issuerNameHash + issuerKeyHash), not the serial number alone, refusing a wrong-issuer "good".** An OCSP SingleResponse identifies the certificate it covers by a CertID of (hashAlgorithm, issuerNameHash, issuerKeyHash, serialNumber) — RFC 6960 §4.1.1. b.network.tls.ocsp.evaluate matched a response to the certificate under validation by the serial number alone and never compared the CertID's issuer hashes. Because a serial number is unique only within one issuer, a responder key that serves more than one issuer identity — a delegated OCSP responder, or a CA key spanning issuers — could have a signed "good" response for serial-S under issuer-Y accepted as proof for the serial-S certificate under issuer-X. The evaluator now recomputes the expected issuerNameHash and issuerKeyHash from the issuer certificate and refuses a response whose CertID issuer hashes do not match. b.network.tls.ocsp.fetch supplies the issuer certificate automatically (its issuerPem is the leaf's issuer); ocsp.requireGood and direct ocsp.evaluate callers pass the issuer cert explicitly as the new issuerCertDer (requireGood's issuerPem may be a delegated OCSP responder rather than the issuer, so it is not used as the issuer), and a response with no issuer certificate available stays bound on serial plus signature as before. **Security:** *OCSP evaluate binds the response CertID to the issuer, not the serial alone (RFC 6960 §4.1.1)* — b.network.tls.ocsp.evaluate selected the matching SingleResponse by normalized serial number only, ignoring the CertID's issuerNameHash and issuerKeyHash. Since serials are unique only per issuer, a "good" response signed by a key that also serves a different issuer (a delegated id-kp-OCSPSigning responder, or a shared CA key) could be replayed as proof for a same-serial certificate under another issuer. evaluate now recomputes the expected issuerNameHash = Hash(issuer DN) and issuerKeyHash = Hash(issuer public key) under the CertID's own hash algorithm and refuses the response if either differs ("wrong-issuer response"). b.network.tls.ocsp.fetch forwards the issuer certificate automatically (its issuerPem is the leaf's issuer); ocsp.requireGood and a direct ocsp.evaluate caller enable the binding by passing issuerCertDer (the issuer cert DER) — requireGood's issuerPem may be a delegated OCSP responder, so the issuer cert is taken explicitly rather than derived from it — and a call without an issuer certificate retains the prior serial-plus-signature binding.
|
package/lib/mail-arc-sign.js
CHANGED
|
@@ -177,7 +177,7 @@ function sign(opts) {
|
|
|
177
177
|
validateOpts(opts, [
|
|
178
178
|
"rfc822", "instance", "authservId", "domain", "selector",
|
|
179
179
|
"privateKey", "algorithm", "cv", "authResults",
|
|
180
|
-
"headersToSign", "timestamp", "audit",
|
|
180
|
+
"headersToSign", "timestamp", "audit", "excludeAarFromAms",
|
|
181
181
|
], "mail.arc.sign");
|
|
182
182
|
|
|
183
183
|
validateOpts.requireNonEmptyString(opts.rfc822, "sign: rfc822",
|
package/lib/mail-auth.js
CHANGED
|
@@ -1456,6 +1456,25 @@ function _parseHeaderLines(headerSection) {
|
|
|
1456
1456
|
// limit how far an attacker can push junk headers.
|
|
1457
1457
|
var ARC_MAX_HOPS = 50; // RFC 8617 §5.1.2 chain ceiling
|
|
1458
1458
|
|
|
1459
|
+
// Parse the ARC instance tag (i=) from an ARC header value (RFC 8617
|
|
1460
|
+
// §4.2.1). Strict by construction: the tag must follow a value boundary
|
|
1461
|
+
// (start / ";" / "," / whitespace), use no space around "=", and be 1-3
|
|
1462
|
+
// digits (instances are bounded to [1,50]). EVERY ARC-header instance read
|
|
1463
|
+
// — the indexing pass that drives the AMS/AS crypto checks, the AMS h=
|
|
1464
|
+
// retention test, the per-hop d= extraction, and the finalAr surfacing —
|
|
1465
|
+
// routes through this one parser so they can never disagree about which hop
|
|
1466
|
+
// a header belongs to. A looser parser in one place (e.g. allowing "i = 1"
|
|
1467
|
+
// with a space, or unbounded digits) lets an attacker inject an
|
|
1468
|
+
// ARC-Authentication-Results that the strict crypto pass ignores while a
|
|
1469
|
+
// permissive pass consumes it, forging the upstream auth-results surfaced as
|
|
1470
|
+
// finalAr on a chain that still verifies pass — with no signing key.
|
|
1471
|
+
function _arcInstanceOf(value) {
|
|
1472
|
+
var m = String(value).match(/(?:^|[;,\s])i=(\d{1,3})\b/);
|
|
1473
|
+
if (!m) return null;
|
|
1474
|
+
var inst = parseInt(m[1], 10);
|
|
1475
|
+
return (isFinite(inst) && inst >= 1) ? inst : null;
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1459
1478
|
async function arcVerify(rfc822, opts) {
|
|
1460
1479
|
if (typeof rfc822 !== "string" || rfc822.length === 0) {
|
|
1461
1480
|
throw new MailAuthError("mail-auth/arc-bad-input",
|
|
@@ -1491,13 +1510,11 @@ async function arcVerify(rfc822, opts) {
|
|
|
1491
1510
|
var value = khv.value.trim();
|
|
1492
1511
|
if (name !== "arc-seal" && name !== "arc-message-signature" &&
|
|
1493
1512
|
name !== "arc-authentication-results") continue;
|
|
1494
|
-
// ARC hop instance per RFC 8617 §4.2.1 —
|
|
1495
|
-
//
|
|
1496
|
-
//
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
var inst = iMatch ? parseInt(iMatch[1], 10) : null;
|
|
1500
|
-
if (inst === null || !isFinite(inst) || inst < 1) continue;
|
|
1513
|
+
// ARC hop instance per RFC 8617 §4.2.1 — parsed by the shared strict
|
|
1514
|
+
// reader so the index this drives (and the AMS/AS crypto checks keyed on
|
|
1515
|
+
// it) matches every other instance read in the verifier/evaluator.
|
|
1516
|
+
var inst = _arcInstanceOf(value);
|
|
1517
|
+
if (inst === null) continue;
|
|
1501
1518
|
if (inst > maxInstanceSeen) maxInstanceSeen = inst;
|
|
1502
1519
|
var slotKey = inst + ":" + name;
|
|
1503
1520
|
if (seenSlot[slotKey]) { duplicate = true; continue; }
|
|
@@ -1790,7 +1807,9 @@ async function _verifyArc(rfc822, hop, allHops, kind, dnsLookup, dkim) {
|
|
|
1790
1807
|
}
|
|
1791
1808
|
// Current AS with b= emptied. RFC 8617 §5.1.2: canonicalization
|
|
1792
1809
|
// includes the AS header with `b=` value stripped + no trailing CRLF.
|
|
1793
|
-
|
|
1810
|
+
// Use the shared tag-aware stripper (the `\bb=...` regex it replaced
|
|
1811
|
+
// mis-zeroed a value containing `b=` inside another tag, e.g. `d=ab=x`).
|
|
1812
|
+
var asUnsigned = dkim._stripBTagValue(sigValue);
|
|
1794
1813
|
canonicalized += _canonRelaxedHeader("ARC-Seal", asUnsigned).replace(/\r\n$/, "");
|
|
1795
1814
|
|
|
1796
1815
|
// Verify the AS signature.
|
|
@@ -1824,8 +1843,8 @@ async function _verifyAmsViaDkim(rfc822, hop, sigValue, tags, dkim, dnsLookup) {
|
|
|
1824
1843
|
// canonicalizes it via h=). Pre-v0.8.17 stripped every AAR
|
|
1825
1844
|
// unconditionally, breaking verification on chains that
|
|
1826
1845
|
// included AAR in h= (Microsoft + Google interop).
|
|
1827
|
-
var
|
|
1828
|
-
if (
|
|
1846
|
+
var aarInst = _arcInstanceOf(khv.value);
|
|
1847
|
+
if (aarInst === null || aarInst !== hop.instance) continue;
|
|
1829
1848
|
}
|
|
1830
1849
|
rebuilt.push(line);
|
|
1831
1850
|
}
|
|
@@ -1980,12 +1999,14 @@ async function arcEvaluate(rfc822, opts) {
|
|
|
1980
1999
|
var name = khv.key;
|
|
1981
2000
|
var value = khv.value.trim();
|
|
1982
2001
|
if (name === "arc-seal") {
|
|
1983
|
-
var
|
|
2002
|
+
var sealInst = _arcInstanceOf(value);
|
|
1984
2003
|
var dMatch = value.match(/(?:^|[;,\s])d=([^\s;]+)/); // allow:regex-no-length-cap — header bounded by RFC 5322 998
|
|
1985
|
-
if (
|
|
2004
|
+
if (sealInst !== null && dMatch) hopDomains[sealInst] = dMatch[1].toLowerCase();
|
|
1986
2005
|
} else if (name === "arc-authentication-results") {
|
|
1987
|
-
|
|
1988
|
-
|
|
2006
|
+
// Same strict instance reader as the indexing pass — finalAr must be the
|
|
2007
|
+
// AAR the crypto pass actually indexed, never one a looser parser admits.
|
|
2008
|
+
var arInst = _arcInstanceOf(value);
|
|
2009
|
+
if (arInst !== null) hopAr[arInst] = value;
|
|
1989
2010
|
}
|
|
1990
2011
|
}
|
|
1991
2012
|
|
package/lib/mail-dkim.js
CHANGED
|
@@ -1323,6 +1323,7 @@ module.exports = {
|
|
|
1323
1323
|
_canonHeaderRelaxedForTest: _canonHeaderRelaxed,
|
|
1324
1324
|
_canonBodyRelaxedForTest: _canonBodyRelaxed,
|
|
1325
1325
|
_canonBodySimpleForTest: _canonBodySimple,
|
|
1326
|
+
_stripBTagValue: _stripBTagValue, // RFC 6376 §3.5 — tag-aware b= zeroing; shared by the ARC seal verifier (internal cross-module helper)
|
|
1326
1327
|
_stripBTagValueForTest: _stripBTagValue,
|
|
1327
1328
|
// The header-block parser that produces the { name, value } pairs fed to the
|
|
1328
1329
|
// canonicalizers. Exposed so a golden-vector test can pin its byte-exact
|
package/lib/queue-local.js
CHANGED
|
@@ -436,23 +436,27 @@ function create(config) {
|
|
|
436
436
|
// Handler context exposes this as `ctx.extendLease(ms)`. The job must
|
|
437
437
|
// still be in 'inflight' status (i.e. not yet swept by sweepExpired);
|
|
438
438
|
// otherwise the call no-ops and returns false.
|
|
439
|
-
async function extendLease(jobId, additionalMs) {
|
|
439
|
+
async function extendLease(jobId, additionalMs, opts) {
|
|
440
440
|
cluster.requireLeader();
|
|
441
441
|
if (typeof additionalMs !== "number" || additionalMs <= 0) {
|
|
442
442
|
throw _err("INVALID_LEASE_EXTENSION",
|
|
443
443
|
"extendLease: additionalMs must be a positive number", true);
|
|
444
444
|
}
|
|
445
445
|
var newExpiry = Date.now() + additionalMs;
|
|
446
|
-
var
|
|
446
|
+
var extUpd = _update()
|
|
447
447
|
.set("leaseExpiresAt", newExpiry)
|
|
448
448
|
.where("_id", jobId)
|
|
449
|
-
.where("status", "inflight")
|
|
450
|
-
|
|
449
|
+
.where("status", "inflight");
|
|
450
|
+
// Lease fencing: only the current lease holder (matching attempts) may
|
|
451
|
+
// extend. attempts is unchanged by an extend, so a worker can extend
|
|
452
|
+
// repeatedly (mirrors the redis EXTEND_LUA fence).
|
|
453
|
+
if (opts && opts.attempt != null) extUpd = extUpd.where("attempts", opts.attempt);
|
|
454
|
+
var built = extUpd.toSql();
|
|
451
455
|
var result = await store.execute(built.sql, built.params);
|
|
452
456
|
return (result.rowCount || 0) > 0;
|
|
453
457
|
}
|
|
454
458
|
|
|
455
|
-
async function complete(jobId) {
|
|
459
|
+
async function complete(jobId, opts) {
|
|
456
460
|
cluster.requireLeader();
|
|
457
461
|
var nowMs = Date.now();
|
|
458
462
|
// Read the row first so we can act on repeat / flow metadata after
|
|
@@ -467,13 +471,20 @@ function create(config) {
|
|
|
467
471
|
var rowRes = await store.execute(rowBuilt.sql, rowBuilt.params);
|
|
468
472
|
var row = (rowRes && rowRes.rows && rowRes.rows[0]) || null;
|
|
469
473
|
|
|
470
|
-
|
|
474
|
+
// Lease fencing: when the caller supplies the `attempts` value it
|
|
475
|
+
// leased at, gate the status flip on it. `attempts` is bumped once per
|
|
476
|
+
// lease, so a stale completer whose lease expired and whose job was
|
|
477
|
+
// re-leased to another worker (a higher attempts) matches 0 rows and
|
|
478
|
+
// does not mark the new worker's in-progress job done. Matches the
|
|
479
|
+
// redis backend's COMPLETE_LUA fence and SQS's receiptHandle binding.
|
|
480
|
+
var doneUpd = _update()
|
|
471
481
|
.set("status", "done")
|
|
472
482
|
.set("finishedAt", nowMs)
|
|
473
483
|
.set("leaseExpiresAt", null)
|
|
474
484
|
.where("_id", jobId)
|
|
475
|
-
.where("status", "inflight")
|
|
476
|
-
|
|
485
|
+
.where("status", "inflight");
|
|
486
|
+
if (opts && opts.attempt != null) doneUpd = doneUpd.where("attempts", opts.attempt);
|
|
487
|
+
var doneBuilt = doneUpd.toSql();
|
|
477
488
|
var doneRes = await store.execute(doneBuilt.sql, doneBuilt.params);
|
|
478
489
|
// Only run the post-completion side effects if THIS call actually flipped
|
|
479
490
|
// the row inflight→done. If the status guard matched 0 rows the job was
|
|
@@ -579,7 +590,7 @@ function create(config) {
|
|
|
579
590
|
// prior 'pending'/'failed' SQL literals now bind, which keeps the raw
|
|
580
591
|
// fragment literal-free).
|
|
581
592
|
var attemptsLt = _qc("attempts") + " < " + _qc("maxAttempts");
|
|
582
|
-
var
|
|
593
|
+
var failUpd = _update()
|
|
583
594
|
.setRaw("status", "CASE WHEN " + attemptsLt + " THEN ? ELSE ? END", ["pending", "failed"])
|
|
584
595
|
.set("lastError", sealedErr)
|
|
585
596
|
.set("leaseExpiresAt", null)
|
|
@@ -587,8 +598,12 @@ function create(config) {
|
|
|
587
598
|
[nowMs + retryDelayMs])
|
|
588
599
|
.setRaw("finishedAt", "CASE WHEN " + attemptsLt + " THEN NULL ELSE ? END", [nowMs])
|
|
589
600
|
.where("_id", jobId)
|
|
590
|
-
.where("status", "inflight")
|
|
591
|
-
|
|
601
|
+
.where("status", "inflight"); // lease-ownership guard — a stale fail() must not clobber a job re-leased to another worker after this one's lease expired (mirrors complete()/extendLease)
|
|
602
|
+
// Lease fencing: when the caller leased at a specific attempts value,
|
|
603
|
+
// gate on it too, so a stale fail() whose job was re-leased (higher
|
|
604
|
+
// attempts) matches 0 rows (mirrors the redis FAIL_LUA fence).
|
|
605
|
+
if (opts.attempt != null) failUpd = failUpd.where("attempts", opts.attempt);
|
|
606
|
+
var failBuilt = failUpd.toSql();
|
|
592
607
|
var failRes = await store.execute(failBuilt.sql, failBuilt.params);
|
|
593
608
|
return (failRes.rowCount || 0) > 0;
|
|
594
609
|
}
|
package/lib/queue-redis.js
CHANGED
|
@@ -144,15 +144,31 @@ var SWEEP_LUA = [
|
|
|
144
144
|
// COMPLETE_LUA — atomically remove from inflight zset, flip status to
|
|
145
145
|
// done, set finishedAt. Returns 1 if the job was inflight, 0 otherwise.
|
|
146
146
|
//
|
|
147
|
+
// Lease fencing: ARGV[3] is the caller's leased `attempts` value (the
|
|
148
|
+
// inflight `attempts` at the time it leased), or "" to skip the check.
|
|
149
|
+
// `attempts` is HINCRBY'd once per lease, so it uniquely identifies a
|
|
150
|
+
// lease generation. If the stored attempts no longer matches, the caller
|
|
151
|
+
// no longer owns the lease — its lease expired, was swept, and the job was
|
|
152
|
+
// re-leased to another worker (whose lease incremented attempts) — so a
|
|
153
|
+
// late completion from the stale holder returns 0 without marking the
|
|
154
|
+
// in-progress job done. (The ZREM-removed gate alone can't catch this: the
|
|
155
|
+
// inflight member is just the jobId, present again under the new lease.)
|
|
156
|
+
//
|
|
147
157
|
// KEYS[1] = inflight zset
|
|
148
158
|
// KEYS[2] = job hash key
|
|
149
159
|
// ARGV[1] = jobId (member to ZREM)
|
|
150
160
|
// ARGV[2] = nowMs
|
|
161
|
+
// ARGV[3] = expected leased attempts ("" = no fence)
|
|
151
162
|
var COMPLETE_LUA = [
|
|
152
163
|
'local inflightKey = KEYS[1]',
|
|
153
164
|
'local jobKey = KEYS[2]',
|
|
154
165
|
'local jobId = ARGV[1]',
|
|
155
166
|
'local nowMs = tonumber(ARGV[2])',
|
|
167
|
+
'local fenceAttempt = ARGV[3]',
|
|
168
|
+
'if fenceAttempt ~= "" then',
|
|
169
|
+
' local cur = redis.call("HGET", jobKey, "attempts")',
|
|
170
|
+
' if cur == false or tostring(cur) ~= fenceAttempt then return 0 end',
|
|
171
|
+
'end',
|
|
156
172
|
'local removed = redis.call("ZREM", inflightKey, jobId)',
|
|
157
173
|
'if removed == 1 then',
|
|
158
174
|
' redis.call("HSET", jobKey, "status", "done", "finishedAt", nowMs, "leaseExpiresAt", "")',
|
|
@@ -161,9 +177,11 @@ var COMPLETE_LUA = [
|
|
|
161
177
|
].join("\n");
|
|
162
178
|
|
|
163
179
|
// FAIL_LUA — decide retry vs DLQ based on the row's current attempts
|
|
164
|
-
// vs maxAttempts (read from HASH for race-freedom).
|
|
165
|
-
//
|
|
166
|
-
//
|
|
180
|
+
// vs maxAttempts (read from HASH for race-freedom). Gated on the job
|
|
181
|
+
// still being inflight (ZREM removed == 1): a stale fail() on a job that
|
|
182
|
+
// is no longer inflight returns -1 and mutates nothing. Retry: ZADD ready
|
|
183
|
+
// at score=nextAvailableAt, status=pending, return 0. DLQ: ZADD dlq at
|
|
184
|
+
// score=nowMs, status=failed, return 1.
|
|
167
185
|
//
|
|
168
186
|
// KEYS[1] = inflight zset
|
|
169
187
|
// KEYS[2] = ready zset
|
|
@@ -173,6 +191,7 @@ var COMPLETE_LUA = [
|
|
|
173
191
|
// ARGV[2] = nowMs
|
|
174
192
|
// ARGV[3] = sealedErr (string; "" if no error)
|
|
175
193
|
// ARGV[4] = nextAvailableAt
|
|
194
|
+
// ARGV[5] = expected leased attempts ("" = no fence) — see COMPLETE_LUA
|
|
176
195
|
var FAIL_LUA = [
|
|
177
196
|
'local inflightKey = KEYS[1]',
|
|
178
197
|
'local readyKey = KEYS[2]',
|
|
@@ -182,9 +201,23 @@ var FAIL_LUA = [
|
|
|
182
201
|
'local nowMs = tonumber(ARGV[2])',
|
|
183
202
|
'local sealedErr = ARGV[3]',
|
|
184
203
|
'local nextAvailableAt = tonumber(ARGV[4])',
|
|
204
|
+
'local fenceAttempt = ARGV[5]',
|
|
185
205
|
'local attempts = tonumber(redis.call("HGET", jobKey, "attempts")) or 0',
|
|
186
206
|
'local maxAttempts = tonumber(redis.call("HGET", jobKey, "maxAttempts")) or 5',
|
|
187
|
-
|
|
207
|
+
// Lease fencing: a stale fail() from a worker whose lease expired and whose
|
|
208
|
+
// job was re-leased to another worker (which incremented attempts) must not
|
|
209
|
+
// re-queue or DLQ the job the new worker is still running. If the stored
|
|
210
|
+
// attempts no longer matches the caller's leased attempts, return -1 and
|
|
211
|
+
// mutate nothing — same fence as COMPLETE_LUA.
|
|
212
|
+
'if fenceAttempt ~= "" and tostring(attempts) ~= fenceAttempt then return -1 end',
|
|
213
|
+
// Lease-ownership guard: only act if THIS call removed the job from inflight.
|
|
214
|
+
// A stale fail() — the worker's lease expired, sweepExpired re-queued the job,
|
|
215
|
+
// and another worker completed it — must not re-queue a job that is no longer
|
|
216
|
+
// inflight (it would resurrect a completed job for re-execution). Mirrors
|
|
217
|
+
// queue-local fail()'s `WHERE status='inflight'` guard. Returns -1 so the
|
|
218
|
+
// caller can report "did not act" rather than a retry/dlq outcome.
|
|
219
|
+
'local removed = redis.call("ZREM", inflightKey, jobId)',
|
|
220
|
+
'if removed ~= 1 then return -1 end',
|
|
188
221
|
'if sealedErr ~= "" then redis.call("HSET", jobKey, "lastError", sealedErr) end',
|
|
189
222
|
'redis.call("HSET", jobKey, "leaseExpiresAt", "")',
|
|
190
223
|
'if attempts < maxAttempts then',
|
|
@@ -198,19 +231,30 @@ var FAIL_LUA = [
|
|
|
198
231
|
'end',
|
|
199
232
|
].join("\n");
|
|
200
233
|
|
|
201
|
-
// EXTEND_LUA — push leaseExpiresAt forward iff the job is still inflight
|
|
234
|
+
// EXTEND_LUA — push leaseExpiresAt forward iff the job is still inflight
|
|
235
|
+
// AND the caller still owns the lease. ARGV[3] is the caller's leased
|
|
236
|
+
// attempts ("" = no fence); a stale extend from a worker whose job was
|
|
237
|
+
// re-leased (attempts moved on) returns 0 without touching the new
|
|
238
|
+
// holder's lease — same fence as COMPLETE_LUA. Note `attempts` is not
|
|
239
|
+
// changed by an extend, so a worker can extend its own lease repeatedly.
|
|
202
240
|
//
|
|
203
241
|
// KEYS[1] = inflight zset
|
|
204
242
|
// KEYS[2] = job hash key
|
|
205
243
|
// ARGV[1] = jobId
|
|
206
244
|
// ARGV[2] = newExpiry
|
|
245
|
+
// ARGV[3] = expected leased attempts ("" = no fence)
|
|
207
246
|
var EXTEND_LUA = [
|
|
208
247
|
'local inflightKey = KEYS[1]',
|
|
209
248
|
'local jobKey = KEYS[2]',
|
|
210
249
|
'local jobId = ARGV[1]',
|
|
211
250
|
'local newExpiry = tonumber(ARGV[2])',
|
|
251
|
+
'local fenceAttempt = ARGV[3]',
|
|
212
252
|
'local score = redis.call("ZSCORE", inflightKey, jobId)',
|
|
213
253
|
'if score == false then return 0 end',
|
|
254
|
+
'if fenceAttempt ~= "" then',
|
|
255
|
+
' local cur = redis.call("HGET", jobKey, "attempts")',
|
|
256
|
+
' if cur == false or tostring(cur) ~= fenceAttempt then return 0 end',
|
|
257
|
+
'end',
|
|
214
258
|
'redis.call("ZADD", inflightKey, newExpiry, jobId)',
|
|
215
259
|
'redis.call("HSET", jobKey, "leaseExpiresAt", newExpiry)',
|
|
216
260
|
'return 1',
|
|
@@ -432,12 +476,13 @@ function create(opts) {
|
|
|
432
476
|
return leased;
|
|
433
477
|
}
|
|
434
478
|
|
|
435
|
-
async function extendLease(jobId, additionalMs) {
|
|
479
|
+
async function extendLease(jobId, additionalMs, opts) {
|
|
436
480
|
await _ensureConnected();
|
|
437
481
|
if (typeof additionalMs !== "number" || additionalMs <= 0) {
|
|
438
482
|
throw _err("INVALID_LEASE_EXTENSION",
|
|
439
483
|
"extendLease: additionalMs must be a positive number", true);
|
|
440
484
|
}
|
|
485
|
+
var fence = (opts && opts.attempt != null) ? String(opts.attempt) : "";
|
|
441
486
|
var newExpiry = Date.now() + additionalMs;
|
|
442
487
|
// We don't know which queue the job belongs to without a HGET, so
|
|
443
488
|
// fetch queueName first (avoids storing inflight by queue, which
|
|
@@ -448,14 +493,15 @@ function create(opts) {
|
|
|
448
493
|
var rv = await client.runScript(
|
|
449
494
|
EXTEND_LUA, 2,
|
|
450
495
|
_inflightKey(queueName), _jobKey(jobId),
|
|
451
|
-
jobId, String(newExpiry)
|
|
496
|
+
jobId, String(newExpiry), fence
|
|
452
497
|
);
|
|
453
498
|
return rv === 1;
|
|
454
499
|
}
|
|
455
500
|
|
|
456
|
-
async function complete(jobId) {
|
|
501
|
+
async function complete(jobId, opts) {
|
|
457
502
|
await _ensureConnected();
|
|
458
503
|
var nowMs = Date.now();
|
|
504
|
+
var fence = (opts && opts.attempt != null) ? String(opts.attempt) : "";
|
|
459
505
|
// Read row first to act on cron-repeat metadata. Same shape as
|
|
460
506
|
// queue-local: SELECT row → flip status → if repeatCron, enqueue
|
|
461
507
|
// next firing.
|
|
@@ -464,11 +510,18 @@ function create(opts) {
|
|
|
464
510
|
if (!raw) return false;
|
|
465
511
|
var queueName = raw.queueName || "unknown";
|
|
466
512
|
|
|
467
|
-
await client.runScript(
|
|
513
|
+
var removed = await client.runScript(
|
|
468
514
|
COMPLETE_LUA, 2,
|
|
469
515
|
_inflightKey(queueName), _jobKey(jobId),
|
|
470
|
-
jobId, String(nowMs)
|
|
516
|
+
jobId, String(nowMs), fence
|
|
471
517
|
);
|
|
518
|
+
// Only the completer that won the inflight->done transition (removed === 1)
|
|
519
|
+
// runs the post-completion side effects. If the ZREM matched nothing the
|
|
520
|
+
// job was already completed, or its lease expired and sweepExpired re-queued
|
|
521
|
+
// it (possibly re-leased to another worker) — a stale completer must NOT
|
|
522
|
+
// re-enqueue the cron repeat (duplicate firing) or release flow children
|
|
523
|
+
// twice. Mirrors queue-local's `WHERE status='inflight'` rowcount guard.
|
|
524
|
+
if (Number(removed) !== 1) return false;
|
|
472
525
|
|
|
473
526
|
if (raw.repeatCron) {
|
|
474
527
|
try {
|
|
@@ -589,13 +642,17 @@ function create(opts) {
|
|
|
589
642
|
async function fail(jobId, errorMessage, retryDelayMs) {
|
|
590
643
|
await _ensureConnected();
|
|
591
644
|
var nowMs = Date.now();
|
|
592
|
-
// b.queue.consume passes the object form `{ retryDelayMs }`
|
|
593
|
-
// the queue-local backend); accept it as well as a bare-number
|
|
594
|
-
// arg. Without this the object failed the `typeof === "number"` test
|
|
645
|
+
// b.queue.consume passes the object form `{ retryDelayMs, attempt }`
|
|
646
|
+
// (matching the queue-local backend); accept it as well as a bare-number
|
|
647
|
+
// third arg. Without this the object failed the `typeof === "number"` test
|
|
595
648
|
// below and the delay was forced to 0, so the documented exponential
|
|
596
649
|
// backoff was silently discarded and a failing job re-leased
|
|
597
|
-
// immediately on the redis backend (retry storm).
|
|
650
|
+
// immediately on the redis backend (retry storm). `attempt` is the
|
|
651
|
+
// caller's leased attempts value, used to fence a stale fail() (see
|
|
652
|
+
// FAIL_LUA).
|
|
653
|
+
var fence = "";
|
|
598
654
|
if (retryDelayMs && typeof retryDelayMs === "object") {
|
|
655
|
+
if (retryDelayMs.attempt != null) fence = String(retryDelayMs.attempt);
|
|
599
656
|
retryDelayMs = retryDelayMs.retryDelayMs;
|
|
600
657
|
}
|
|
601
658
|
if (typeof retryDelayMs !== "number" || !isFinite(retryDelayMs) || retryDelayMs < 0) {
|
|
@@ -609,12 +666,15 @@ function create(opts) {
|
|
|
609
666
|
|
|
610
667
|
var sealedErr = errorMessage ? vault().seal(String(errorMessage)) : "";
|
|
611
668
|
|
|
612
|
-
await client.runScript(
|
|
669
|
+
var rv = await client.runScript(
|
|
613
670
|
FAIL_LUA, 4,
|
|
614
671
|
_inflightKey(queueName), _readyKey(queueName), _dlqKey(queueName), _jobKey(jobId),
|
|
615
|
-
jobId, String(nowMs), sealedErr, String(nextAvailableAt)
|
|
672
|
+
jobId, String(nowMs), sealedErr, String(nextAvailableAt), fence
|
|
616
673
|
);
|
|
617
|
-
|
|
674
|
+
// -1 = stale fail() on a job no longer inflight (already completed or
|
|
675
|
+
// re-leased) — it did not retry or DLQ. 0 = retried, 1 = landed in dlq.
|
|
676
|
+
// Mirrors queue-local fail()'s `rowCount > 0`.
|
|
677
|
+
return Number(rv) !== -1;
|
|
618
678
|
}
|
|
619
679
|
|
|
620
680
|
async function sweepExpired() {
|
package/lib/queue.js
CHANGED
|
@@ -479,7 +479,7 @@ function consume(queueName, handler, opts) {
|
|
|
479
479
|
"queue backend '" + b.name + "' does not support extendLease",
|
|
480
480
|
true);
|
|
481
481
|
}
|
|
482
|
-
return b.extendLease(job.jobId, additionalMs).then(function (ok) {
|
|
482
|
+
return b.extendLease(job.jobId, additionalMs, { attempt: job.attempts }).then(function (ok) {
|
|
483
483
|
if (ok) {
|
|
484
484
|
_emit("system.queue.lease.extended", {
|
|
485
485
|
metadata: { queue: queueName, backend: b.name, jobId: job.jobId, additionalMs: additionalMs },
|
|
@@ -514,7 +514,7 @@ function consume(queueName, handler, opts) {
|
|
|
514
514
|
return Promise.resolve()
|
|
515
515
|
.then(function () { return handler(job, ctx); })
|
|
516
516
|
.then(function () {
|
|
517
|
-
return b.complete(job.jobId).then(function () {
|
|
517
|
+
return b.complete(job.jobId, { attempt: job.attempts }).then(function () {
|
|
518
518
|
_emit("system.queue.consume.success", {
|
|
519
519
|
metadata: { queue: queueName, backend: b.name, jobId: job.jobId, attempt: job.attempts, traceId: job.traceId },
|
|
520
520
|
});
|
|
@@ -523,7 +523,7 @@ function consume(queueName, handler, opts) {
|
|
|
523
523
|
}, function (err) {
|
|
524
524
|
var msg = (err && err.message) || String(err);
|
|
525
525
|
var willRetry = job.attempts < job.maxAttempts;
|
|
526
|
-
return b.fail(job.jobId, msg, { retryDelayMs: _backoffDelay(job.attempts) })
|
|
526
|
+
return b.fail(job.jobId, msg, { retryDelayMs: _backoffDelay(job.attempts), attempt: job.attempts })
|
|
527
527
|
.then(function () {
|
|
528
528
|
observability.event("queue.fail", 1, { queueName: queueName, willRetry: willRetry });
|
|
529
529
|
_emit("system.queue.consume.failure", {
|
package/package.json
CHANGED
package/sbom.cdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:5422e8bb-3202-439f-86f2-ed91f656a2ba",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-06-
|
|
8
|
+
"timestamp": "2026-06-29T19:28:45.676Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/core@0.15.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.15.62",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.15.
|
|
25
|
+
"version": "0.15.62",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "The Node framework that owns its stack.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/core@0.15.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.15.62",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/core@0.15.
|
|
57
|
+
"ref": "@blamejs/core@0.15.62",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|