@blamejs/core 0.15.40 → 0.15.41
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/network-byte-quota.js +74 -14
- package/lib/static.js +44 -20
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
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.41 (2026-06-28) — **Counters kept on a shared cache — byte quotas and the static server's bandwidth/concurrency caps — now accumulate with an atomic compare-and-set, so concurrent requests can no longer lose each other's charges and slip under the limit.** Several caps maintained a counter on a cache with a non-atomic read-modify-write: read the current value, add to it in memory, write it back. On a cache shared across nodes, two concurrent requests both read the same value, each added only its own contribution, and one write clobbered the other — a lost update that under-counted the cap and let a peer slip under it. b.network.byteQuota (and the b.middleware.dailyByteQuota that composes it) and the static server's per-actor/global bandwidth and per-actor concurrency caps all did this. They now accumulate through the cache's atomic update() (compare-and-set, with retry on the cluster backend under contention), so every concurrent charge is counted. A cache backing these caps must support atomic update(); b.cache provides it, and both primitives refuse a get/set-only cache at construction. The single-node in-memory paths were already safe (they accumulate synchronously). **Fixed:** *Byte quota on a shared cache counts concurrent requests atomically (no lost updates)* — b.network.byteQuota's cache backend accumulated bytes with a get → add → set that is not atomic, so concurrent requests from one peer on a multi-node deployment lost each other's byte charges and the rolling daily byte budget was under-enforced. Accounting now uses the cache's atomic compare-and-set update(), counting every concurrent request, and retries the cluster CAS under a contention burst rather than dropping a charge. A cache wired to a byte quota must support update() — b.cache does; a plain get/set cache is refused at create() (byte-quota/cache-no-atomic-update), and a backend that can't actually commit an atomic update surfaces loud on first use instead of silently disabling the quota. b.middleware.dailyByteQuota, which composes byteQuota, inherits the fix. · *Static server bandwidth + concurrency caps count concurrent requests atomically* — b.staticServe's per-actor and global bandwidth caps and its per-actor concurrency cap kept their counters on the cache with a non-atomic get → add → set, so concurrent downloads from one actor on a multi-replica deployment lost each other's charges and the caps were under-enforced (a bandwidth/concurrency limit bypass). The counters now accumulate through the cache's atomic update() with the same contention retry, and the quota cache is required to support update() at create(). **Detectors:** *Build guard: a cache-backed counter must use atomic update(), not get → set* — A codebase guard now fails the build if a primitive accumulates a counter on a cache with a get → mutate → set read-modify-write instead of the atomic update(), so the lost-update class fixed here cannot reappear at a new cap. Caches used for lookups or cache-aside (the value is replaced wholesale, not incremented), or whose writes are serialized in-process, are allowlisted with the reason they cannot lose an increment.
|
|
12
|
+
|
|
11
13
|
- 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
14
|
|
|
13
15
|
- 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.
|
|
@@ -51,6 +51,10 @@ var ByteQuotaError = defineClass("ByteQuotaError", { alwaysPermanent: true });
|
|
|
51
51
|
|
|
52
52
|
var BINS_PER_DAY = 24; // 24 hours in a day
|
|
53
53
|
var BIN_MS = C.TIME.hours(1);
|
|
54
|
+
// Outer retries when the cache's internal CAS exhausts under a concurrency
|
|
55
|
+
// burst on one key — absorbs the burst so a charge is not dropped (each retry
|
|
56
|
+
// re-reads the latest counter). Total CAS attempts ≈ this × the cache's own 5.
|
|
57
|
+
var ACCOUNT_MAX_RETRIES = 6;
|
|
54
58
|
|
|
55
59
|
function _hourBin(nowMs) { return Math.floor(nowMs / BIN_MS); }
|
|
56
60
|
function _newEntry() { return { bins: new Array(BINS_PER_DAY).fill(0), startHour: 0 }; }
|
|
@@ -108,22 +112,44 @@ function _memoryBackend() {
|
|
|
108
112
|
|
|
109
113
|
function _cacheBackend(cache) {
|
|
110
114
|
function _key(k) { return "byteQuota:" + k; }
|
|
111
|
-
|
|
112
|
-
var raw = await cache.get(_key(key));
|
|
115
|
+
function _coerce(raw) {
|
|
113
116
|
return raw && typeof raw === "object" && Array.isArray(raw.bins) ? raw : _newEntry();
|
|
114
117
|
}
|
|
115
118
|
return {
|
|
116
119
|
async total(key, nowMs) {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
+
// Read-only: slide + sum without writing back. A writeback here would
|
|
121
|
+
// race a concurrent account() — it could read the pre-increment entry
|
|
122
|
+
// and clobber the increment (lost update). The slide is recomputed on
|
|
123
|
+
// every read/account, so persisting it is a pure optimization not worth
|
|
124
|
+
// the clobber risk.
|
|
125
|
+
var slid = _slideAndSum(_coerce(await cache.get(_key(key))), _hourBin(nowMs));
|
|
120
126
|
return slid.total;
|
|
121
127
|
},
|
|
122
128
|
async account(key, bytes, nowMs) {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
129
|
+
// Atomic read-modify-write. A plain get → mutate → set loses concurrent
|
|
130
|
+
// charges on the shared cache (two callers read the same counter and one
|
|
131
|
+
// set clobbers the other). cache.update runs the slide + add under a
|
|
132
|
+
// compare-and-set (with retry on the cluster backend), so every
|
|
133
|
+
// concurrent record is counted toward the quota.
|
|
134
|
+
var nowHour = _hourBin(nowMs);
|
|
135
|
+
// The cluster CAS retries internally and throws UPDATE_CONTENTION once it
|
|
136
|
+
// gives up. A concurrency burst on one key must not drop a charge (that
|
|
137
|
+
// lets a peer undercount the quota), so retry the whole RMW; each attempt
|
|
138
|
+
// re-reads the latest value (a full transaction whose latency naturally
|
|
139
|
+
// spreads the contenders) and eventually wins the CAS.
|
|
140
|
+
for (var attempt = 0; ; attempt++) {
|
|
141
|
+
try {
|
|
142
|
+
await cache.update(_key(key), function (current) {
|
|
143
|
+
var slid = _slideAndSum(_coerce(current), nowHour);
|
|
144
|
+
slid.entry.bins[BINS_PER_DAY - 1] += bytes;
|
|
145
|
+
return { value: slid.entry };
|
|
146
|
+
}, { ttlMs: BIN_MS * BINS_PER_DAY });
|
|
147
|
+
return;
|
|
148
|
+
} catch (e) {
|
|
149
|
+
if (e && e.code === "UPDATE_CONTENTION" && attempt < ACCOUNT_MAX_RETRIES) continue;
|
|
150
|
+
throw e;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
127
153
|
},
|
|
128
154
|
async reset(key) {
|
|
129
155
|
if (typeof cache.delete === "function") await cache.delete(_key(key));
|
|
@@ -172,9 +198,27 @@ function create(opts) {
|
|
|
172
198
|
_requirePositiveBytes("bytesPerDay", opts.bytesPerDay);
|
|
173
199
|
var bytesPerDay = opts.bytesPerDay;
|
|
174
200
|
var now = typeof opts.now === "function" ? opts.now : function () { return Date.now(); };
|
|
175
|
-
var backend
|
|
176
|
-
|
|
177
|
-
|
|
201
|
+
var backend;
|
|
202
|
+
if (opts.cache && typeof opts.cache.get === "function") {
|
|
203
|
+
// A byte quota is a shared counter — the cache backend MUST support atomic
|
|
204
|
+
// read-modify-write. A plain get/set cache loses concurrent byte charges
|
|
205
|
+
// (lost update) on the multi-node path, silently under-counting the quota.
|
|
206
|
+
// This is an early-catch for a cache that lacks an update method entirely;
|
|
207
|
+
// a b.cache whose backend doesn't implement update (e.g. a get/set-only
|
|
208
|
+
// custom backend) exposes the method but throws UNSUPPORTED at call time —
|
|
209
|
+
// that case is surfaced loud on the first record() (see below).
|
|
210
|
+
if (typeof opts.cache.update !== "function") {
|
|
211
|
+
throw new ByteQuotaError(
|
|
212
|
+
"byte-quota/cache-no-atomic-update",
|
|
213
|
+
"network.byteQuota: a cache backing a byte quota must support atomic update() — " +
|
|
214
|
+
"a plain get/set cache loses concurrent byte charges on the shared path; " +
|
|
215
|
+
"use b.cache.create(...), which provides it"
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
backend = _cacheBackend(opts.cache);
|
|
219
|
+
} else {
|
|
220
|
+
backend = _memoryBackend();
|
|
221
|
+
}
|
|
178
222
|
|
|
179
223
|
var _emitAudit = audit().namespaced("network.byte_quota", opts.audit);
|
|
180
224
|
|
|
@@ -235,9 +279,25 @@ function create(opts) {
|
|
|
235
279
|
var nowMs = now();
|
|
236
280
|
try { await backend.account(key, bytes, nowMs); }
|
|
237
281
|
catch (e) {
|
|
282
|
+
// A cache backend that doesn't actually implement atomic update() throws
|
|
283
|
+
// UNSUPPORTED at call time (the public update method exists on every
|
|
284
|
+
// b.cache, so the get/set-only case can't be caught at construction).
|
|
285
|
+
// Under the drop-silent policy below this would silently disable the
|
|
286
|
+
// quota — surface it LOUD so the operator fixes the backend rather than
|
|
287
|
+
// running an unenforced quota.
|
|
288
|
+
if (e && e.code === "UNSUPPORTED") {
|
|
289
|
+
throw new ByteQuotaError(
|
|
290
|
+
"byte-quota/cache-no-atomic-update",
|
|
291
|
+
"network.byteQuota: the configured cache backend does not support atomic update() — " +
|
|
292
|
+
"a byte quota cannot enforce on a get/set-only backend; use a cache whose backend " +
|
|
293
|
+
"implements update (the memory or cluster backend)"
|
|
294
|
+
);
|
|
295
|
+
}
|
|
238
296
|
_emitAudit("backend_error", "failure", { phase: "record", key: key, bytes: bytes, error: (e && e.message) || String(e) });
|
|
239
|
-
// Drop-silent after audit
|
|
240
|
-
//
|
|
297
|
+
// Drop-silent after audit for a transient/unreachable backend — the
|
|
298
|
+
// operation already succeeded; a throw would punish the handler that
|
|
299
|
+
// already accepted bytes. A contention burst is retried in account()
|
|
300
|
+
// before it can reach here.
|
|
241
301
|
return;
|
|
242
302
|
}
|
|
243
303
|
_emitMetric("recorded", bytes, {});
|
package/lib/static.js
CHANGED
|
@@ -655,13 +655,23 @@ function _validateCreateOpts(opts) {
|
|
|
655
655
|
"'user-content'; got " + JSON.stringify(opts.mountType));
|
|
656
656
|
}
|
|
657
657
|
// Quotas require a cache for cluster-shared coordination.
|
|
658
|
-
if (
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
658
|
+
if (opts.maxBytesPerActorPerWindowMs > 0 ||
|
|
659
|
+
opts.maxBytesAllActorsPerWindowMs > 0 ||
|
|
660
|
+
opts.maxConcurrentDownloadsPerActor > 0) {
|
|
661
|
+
if (!opts.cache) {
|
|
662
|
+
throw _err("BAD_OPT",
|
|
663
|
+
"staticServe.create: bandwidth / concurrency quotas require opts.cache " +
|
|
664
|
+
"(pass cache: b.cache.create({ backend: 'cluster' }) so multi-replica deploys honor caps globally)");
|
|
665
|
+
}
|
|
666
|
+
// The bandwidth / concurrency counters accumulate atomically via
|
|
667
|
+
// cache.update; a cache that lacks an update method entirely cannot
|
|
668
|
+
// coordinate the caps. (A b.cache whose backend can't do atomic RMW exposes
|
|
669
|
+
// update but throws UNSUPPORTED at call time — that surfaces on first use.)
|
|
670
|
+
if (typeof opts.cache.update !== "function") {
|
|
671
|
+
throw _err("BAD_OPT",
|
|
672
|
+
"staticServe.create: the quota cache must support atomic update() — a plain " +
|
|
673
|
+
"get/set cache loses concurrent bandwidth/concurrency charges; use b.cache.create(...)");
|
|
674
|
+
}
|
|
665
675
|
}
|
|
666
676
|
}
|
|
667
677
|
|
|
@@ -688,17 +698,36 @@ async function _checkBandwidthQuota(cache, actorKey, perActorCap, globalCap, win
|
|
|
688
698
|
return { ok: true, windowStart: windowStart, now: now };
|
|
689
699
|
}
|
|
690
700
|
|
|
701
|
+
// Atomic counter adjust on the cache. A plain cache.get → mutate → cache.set is
|
|
702
|
+
// a non-atomic read-modify-write: two concurrent requests read the same value
|
|
703
|
+
// and one set clobbers the other (lost update), under-counting on the shared
|
|
704
|
+
// (cluster) cache so the bandwidth / concurrency caps can be bypassed. cache.update
|
|
705
|
+
// runs the adjust under a compare-and-set; the cluster CAS retries internally and
|
|
706
|
+
// throws UPDATE_CONTENTION once it gives up, so retry the whole RMW under a burst
|
|
707
|
+
// (each attempt re-reads the latest value) rather than dropping the adjustment.
|
|
708
|
+
var STATIC_COUNTER_MAX_RETRIES = 6;
|
|
709
|
+
async function _atomicCounter(cache, key, mutate, ttlMs) {
|
|
710
|
+
for (var attempt = 0; ; attempt++) {
|
|
711
|
+
try {
|
|
712
|
+
await cache.update(key, function (current) {
|
|
713
|
+
var c = (typeof current === "number" && isFinite(current)) ? current : 0;
|
|
714
|
+
return { value: mutate(c) };
|
|
715
|
+
}, { ttlMs: ttlMs });
|
|
716
|
+
return;
|
|
717
|
+
} catch (e) {
|
|
718
|
+
if (e && e.code === "UPDATE_CONTENTION" && attempt < STATIC_COUNTER_MAX_RETRIES) continue;
|
|
719
|
+
throw e;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
691
724
|
async function _consumeBandwidth(cache, actorKey, perActorCap, globalCap, windowMs, bytes) {
|
|
692
725
|
if (!cache) return;
|
|
693
726
|
if (perActorCap > 0 && actorKey) {
|
|
694
|
-
|
|
695
|
-
var aUsed = (await cache.get(aKey)) || 0;
|
|
696
|
-
await cache.set(aKey, aUsed + bytes, { ttlMs: windowMs });
|
|
727
|
+
await _atomicCounter(cache, "static:bw:actor:" + actorKey, function (c) { return c + bytes; }, windowMs);
|
|
697
728
|
}
|
|
698
729
|
if (globalCap > 0) {
|
|
699
|
-
|
|
700
|
-
var gUsed = (await cache.get(gKey)) || 0;
|
|
701
|
-
await cache.set(gKey, gUsed + bytes, { ttlMs: windowMs });
|
|
730
|
+
await _atomicCounter(cache, "static:bw:global", function (c) { return c + bytes; }, windowMs);
|
|
702
731
|
}
|
|
703
732
|
}
|
|
704
733
|
|
|
@@ -712,17 +741,12 @@ async function _checkConcurrencyCap(cache, actorKey, cap) {
|
|
|
712
741
|
|
|
713
742
|
async function _incConcurrency(cache, actorKey) {
|
|
714
743
|
if (!cache || !actorKey) return;
|
|
715
|
-
|
|
716
|
-
var current = (await cache.get(key)) || 0;
|
|
717
|
-
await cache.set(key, current + 1, { ttlMs: C.TIME.minutes(10) });
|
|
744
|
+
await _atomicCounter(cache, "static:conc:" + actorKey, function (c) { return c + 1; }, C.TIME.minutes(10));
|
|
718
745
|
}
|
|
719
746
|
|
|
720
747
|
async function _decConcurrency(cache, actorKey) {
|
|
721
748
|
if (!cache || !actorKey) return;
|
|
722
|
-
|
|
723
|
-
var current = (await cache.get(key)) || 0;
|
|
724
|
-
var next = current > 0 ? current - 1 : 0;
|
|
725
|
-
await cache.set(key, next, { ttlMs: C.TIME.minutes(10) });
|
|
749
|
+
await _atomicCounter(cache, "static:conc:" + actorKey, function (c) { return c > 0 ? c - 1 : 0; }, C.TIME.minutes(10));
|
|
726
750
|
}
|
|
727
751
|
|
|
728
752
|
function _actorKeyFromContext(ctx) {
|
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:b6890c56-a06c-431c-b27d-d91ce739441d",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-06-
|
|
8
|
+
"timestamp": "2026-06-28T08:26:04.321Z",
|
|
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.41",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.15.
|
|
25
|
+
"version": "0.15.41",
|
|
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.41",
|
|
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.41",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|