@blamejs/core 0.15.39 → 0.15.40

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 CHANGED
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.15.x
10
10
 
11
+ - v0.15.40 (2026-06-27) — **The durable webhook dispatcher's retry poller now claims due deliveries with FOR UPDATE SKIP LOCKED on Postgres and MySQL, so two pollers running at once (multiple app nodes) can no longer both grab the same delivery and send the webhook twice in one cycle.** The webhook dispatcher's retry poller claimed due deliveries by flipping them pending->in-flight and then re-selecting the in-flight rows by id. On Postgres or MySQL under the default READ COMMITTED isolation, two pollers running concurrently could both re-select the same row: the loser's UPDATE matched zero rows (the winner had already flipped it), but its reselect-by-id still re-read the row the winner had just claimed, so both pollers attempted the same HTTP delivery in one cycle. The claim now row-locks the due rows with SELECT ... FOR UPDATE SKIP LOCKED on the row-locking backends, so concurrent pollers see disjoint sets and each delivery is claimed by exactly one poller; sqlite (a single writer) keeps the existing mark-then-reselect, which it serializes safely. This matches the claim used by the framework's transactional outbox and cluster queue. Receivers that already dedup on the X-Webhook-Delivery-Id header were protected from a duplicate POST; this closes the at-most-once-per-cycle gap at the dispatcher itself. **Fixed:** *Webhook retry pollers no longer double-deliver under concurrency on Postgres / MySQL* — b.webhook.dispatcher's processRetries() claimed due deliveries with a mark-then-reselect that had no row lock, so on Postgres / MySQL at READ COMMITTED two concurrent pollers (for example, the dispatcher running on more than one app node) could both hand back the same delivery and POST it twice in a single retry cycle. The claim now uses SELECT ... FOR UPDATE SKIP LOCKED on those backends so each due row is locked by exactly one poller and concurrent pollers claim disjoint sets; the rows a poller selects are exactly the rows it owns. sqlite keeps the mark-then-reselect path, which its single writer serializes. Operators running the dispatcher on a single node, or whose receivers dedup on X-Webhook-Delivery-Id, were not exposed to a duplicate delivery; no configuration change is required to pick up the fix. **Detectors:** *Build guard: a competing-consumer claim must use FOR UPDATE SKIP LOCKED* — A codebase guard now fails the build if a poller that claims due rows across workers — SELECT status='pending' inside a transaction, then flip the rows to in-flight — omits FOR UPDATE SKIP LOCKED on the row-locking backends. Without the row lock, two pollers under READ COMMITTED both claim the same row; the guard keeps any future poller from re-introducing the shape, with the transactional outbox and cluster queue as the reference claims.
12
+
11
13
  - v0.15.39 (2026-06-27) — **Nine more places that matched an operator-supplied regex against request data — User-Agent, Origin, request path, form fields, SMTP HELO, release-asset names — now screen the pattern for catastrophic backtracking (ReDoS) before use, and a new b.guardRegex.assertSafe helper makes that screening one call.** The previous release screened feature-flag and MCP regex patterns for ReDoS but did not sweep every place the framework matches an operator-supplied regex against attacker-controlled input. Nine more were found and fixed: the bot guard (User-Agent), CORS (Origin), the HTTP span middleware and the shared request skip-matcher used by CSRF / fetch-metadata / rate-limit / access-lock / age-gate and the request logger (request path), static serving (hashed-asset path pattern), form field validation (submitted field value), SMTP HELO generic-rDNS patterns (HELO name), and the self-updater's asset/signature patterns (names from a remote release feed). Each accepted an operator RegExp with only a type check and ran it on every matching request, so an accidentally catastrophic pattern such as (a+)+$ or ((a)+)+$ could pin a CPU on a crafted input — a length cap does not bound backtracking. Every one now screens the pattern through b.guardRegex at configuration time. A new b.guardRegex.assertSafe(input, label?, ErrorClass?, code?) primitive performs that screen in one call (accepting a RegExp or a pattern string), which operators can also use on their own patterns. **Added:** *b.guardRegex.assertSafe — screen a RegExp or pattern string for ReDoS in one call* — b.guardRegex.assertSafe(input, label?, ErrorClass?, code?, opts?) screens an already-compiled RegExp (its source) or a raw pattern string for the catastrophic-backtracking classes — nested, alternation-with, and lookaround quantifiers — throwing the supplied framework-error class (or the underlying GuardRegexError) on a hostile pattern and returning the input on success. It allows large or open-ended bounded repeats (`{8,}`, `{n,m}`): a single counted repeat matches in linear time and legitimate patterns (including the framework's own defaults) use them. It is the config-time guard used by the request-lifecycle fixes above, and operators can apply it to their own patterns before matching them against untrusted input. **Security:** *Operator regex patterns matched against request data are screened for ReDoS framework-wide* — An operator-supplied RegExp matched against attacker-controllable input is a denial-of-service surface if it has a catastrophic-backtracking shape: the input triggers exponential work in the regex engine. Nine sites accepted such patterns with only an `instanceof RegExp` type check and executed them per request — bot-guard against the User-Agent, CORS against the Origin header, the HTTP span middleware and the shared skip-path matcher (CSRF / fetch-metadata / rate-limit / access-lock / age-gate / request-log) against the request path, static serving against the request path, form validation against the submitted field value, SMTP HELO checks against the HELO name, and the self-updater against asset names from a remote release feed. Each now routes the pattern through b.guardRegex at configuration time, so a catastrophic shape is refused up front instead of being weaponized by a crafted request. A length bound on the input is not a defense: a nested-quantifier pattern backtracks catastrophically at a few dozen characters. **Detectors:** *Build guard: an operator regex matched against request input must be ReDoS-screened* — A codebase guard now fails the build if a primitive accepts an operator-supplied RegExp and executes it against request input without screening the pattern through b.guardRegex.assertSafe — so the catastrophic-backtracking class fixed in this release cannot be reintroduced at a new site (the trusted-input cases — local filesystem paths, operator config keys, operator-owned schemas — are explicitly allowlisted). · *Build guard: process.moduleLoadList filters must match the 'NativeModule X' naming* — A guard now fails the build if a test filters process.moduleLoadList by the 'node:X' name only. Node 20+ records a loaded builtin as 'NativeModule X', so a 'node:'-only filter in an edge-runtime no-eager-load test would rot green and miss a reintroduced top-level networking require.
12
14
 
13
15
  - v0.15.38 (2026-06-27) — **Regex patterns supplied in feature-flag targeting rules and MCP tool input schemas are now screened for catastrophic-backtracking (ReDoS) shapes before compilation, so a pattern matched against request data can't pin a CPU.** Two places compiled a caller-supplied regex pattern and `.test()`'d it against request-controlled input with only a length bound as the stated defense: a feature-flag targeting condition (`op: "regex"`) matched against runtime attribute values, and an MCP tool's input-schema `pattern` matched against tool-call arguments. A length bound is not a ReDoS defense — a catastrophic-backtracking pattern such as `(a+)+$` is six characters and pins a CPU on a crafted input. Both patterns now pass through `b.guardRegex` (strict profile) before compilation, which refuses nested-quantifier, alternation-with-quantifier, and quantifier-inside-lookaround shapes. A ReDoS-shaped flag pattern is refused when the rules are validated; a ReDoS-shaped MCP schema pattern fails tool-input validation. Patterns built from the framework's own static tables, operator-owned JSON Schema patterns, the Sieve glob translator (which cannot express nested quantifiers), and the I-Regexp translator (linear by dialect) are unchanged. **Security:** *Feature-flag regex targeting conditions are screened for ReDoS before compilation* — A flag targeting rule with `op: "regex"` compiled the operator-supplied pattern and `.test()`'d it against runtime attribute values, guarded only by a 200-character length cap. Length does not bound catastrophic backtracking, so a pattern like `(a+)+$` combined with an attacker-controlled attribute value could pin a CPU during flag evaluation. The pattern is now screened through b.guardRegex (strict) when the rules are validated, and a catastrophic-backtracking shape is refused with a clear error. · *MCP tool input-schema patterns are screened for ReDoS before matching request input* — b.mcp.validateToolInput compiled a tool author's input-schema `pattern` and matched it against tool-call argument values; the 4096-character input cap does not bound backtracking (a `(a+)+$` pattern blows up at roughly forty input characters). The schema pattern is now screened through b.guardRegex (strict) before compilation, so a ReDoS-shaped pattern in a registered tool's schema fails input validation instead of letting hostile arguments hang the validator. · *b.guardRegex now catches wrapped nested-quantifier patterns* — The nested-quantifier detector matched a quantified group whose body contained a quantifier, but its inner match was paren-blind, so wrapping the inner quantifier in an extra group — `((a)+)+`, `(([a-z]+)*)*`, `((a+))+` — slipped past it while remaining catastrophic. A structural scan now tracks group nesting and refuses an unbounded-quantified group whose body itself contains an unbounded quantifier at any depth, so the wrapped forms are rejected alongside the bare `(a+)+`. Bounded repeats (`{n}`, `{n,m}`) are unaffected. This strengthens every b.guardRegex caller, including the flag-targeting and MCP screening above.
@@ -574,21 +574,41 @@ function dispatcher(opts) {
574
574
  await externalDb.query(stmt.sql, stmt.params);
575
575
  }
576
576
 
577
+ // FOR UPDATE SKIP LOCKED is Postgres / MySQL-only; sqlite is a single writer
578
+ // with no row lock. Decide on the NORMALIZED dialect — the same resolution the
579
+ // SQL builders use (_sqlDialect maps the `postgresql` alias to `postgres`) — so
580
+ // the lock decision can never disagree with the rendered SQL: a `postgresql`
581
+ // caller emits Postgres SQL AND row-locks, never Postgres SQL with the sqlite
582
+ // mark-then-reselect fallback (which would reopen the double-claim race).
583
+ function _supportsForUpdateSkipLocked() {
584
+ var d = _sqlDialect(externalDb);
585
+ return d === "postgres" || d === "mysql";
586
+ }
587
+
577
588
  // Claim due-pending deliveries by flipping them to 'in-flight' inside a
578
- // transaction (mark-then-reselect; gated on status='pending' so two pollers
579
- // can't both claim the same row), then attempt each claimed delivery.
589
+ // transaction, then attempt each claimed delivery. On Postgres / MySQL the
590
+ // SELECT row-locks the due rows via FOR UPDATE SKIP LOCKED, so concurrent
591
+ // retry pollers see DISJOINT sets — the rows this poller selected are exactly
592
+ // the rows it owns. sqlite (single writer, no row lock) falls back to a
593
+ // guarded mark-then-reselect: the UPDATE gated on status='pending' transitions
594
+ // each row once, and we re-read the in-flight rows we flipped. Without the
595
+ // SKIP LOCKED branch, two pollers under READ COMMITTED both reselect the same
596
+ // in-flight row (the loser's UPDATE matches zero rows, but the reselect-by-id
597
+ // re-reads the row the winner flipped) and double-deliver it in one cycle.
580
598
  async function processRetries() {
581
599
  await _reapStaleInflight();
582
600
  var dialect = _sqlDialect(externalDb);
601
+ var supportsSkipLocked = _supportsForUpdateSkipLocked();
583
602
  var claimed = await externalDb.transaction(async function (xdb) {
584
603
  var nowDate = _nowDate();
585
- var sel = sql.select(deliveriesTable, { dialect: dialect })
604
+ var selBuilder = sql.select(deliveriesTable, { dialect: dialect })
586
605
  .columns(["delivery_id"])
587
606
  .whereRaw("status = 'pending'", [], { allowLiterals: true })
588
607
  .whereRaw("next_attempt_at <= ?", [nowDate])
589
608
  .orderBy("next_attempt_at")
590
- .limit(batchSize)
591
- .toExternalSql(dialect);
609
+ .limit(batchSize);
610
+ if (supportsSkipLocked) selBuilder.forUpdate({ skipLocked: true });
611
+ var sel = selBuilder.toExternalSql(dialect);
592
612
  var rows = await xdb.query(sel.sql, sel.params);
593
613
  var ids = ((rows && rows.rows) || []).map(function (r) { return r.delivery_id; });
594
614
  if (ids.length === 0) return [];
@@ -598,6 +618,12 @@ function dispatcher(opts) {
598
618
  .whereInArray("delivery_id", ids)
599
619
  .toExternalSql(dialect);
600
620
  await xdb.query(mark.sql, mark.params);
621
+ // Postgres / MySQL: the FOR UPDATE SKIP LOCKED SELECT already gave us an
622
+ // exclusively-locked, disjoint set, so the selected ids ARE our claim.
623
+ if (supportsSkipLocked) return ids;
624
+ // sqlite / other: no row lock, so re-read which rows WE flipped. The
625
+ // single writer serializes the gated UPDATE, so the in-flight rows in our
626
+ // id set are ours.
601
627
  var after = sql.select(deliveriesTable, { dialect: dialect })
602
628
  .columns(["delivery_id"])
603
629
  .whereRaw("status = 'in-flight'", [], { allowLiterals: true })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.15.39",
3
+ "version": "0.15.40",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
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:861fafcf-aa1c-4e00-b8a0-ed9942340f9c",
5
+ "serialNumber": "urn:uuid:4538b9a5-f7e6-4723-be61-b03f474f0c0e",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-06-28T04:47:31.828Z",
8
+ "timestamp": "2026-06-28T06:47:43.444Z",
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.39",
22
+ "bom-ref": "@blamejs/core@0.15.40",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.15.39",
25
+ "version": "0.15.40",
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.39",
29
+ "purl": "pkg:npm/%40blamejs/core@0.15.40",
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.39",
57
+ "ref": "@blamejs/core@0.15.40",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]