@blamejs/core 0.12.19 → 0.12.21

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,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.12.x
10
10
 
11
+ - v0.12.21 (2026-05-24) — **`bundleAdapterStorage.rewrapBundle(bundleId, opts)` — key rotation without restore + rewrite of inner archive bytes.** `storage.rewrapBundle(bundleId, opts?)` rotates a bundle's wrap envelope under a new recipient keypair or passphrase WITHOUT touching the inner tar / tar.gz bytes. Operators rotating a compromised keypair, migrating to a new HSM, or refreshing passphrases on a HIPAA-posture repository previously had to `readBundle` → write to a stage dir → `writeBundle` under the new key — three byte-walks of the bundle payload, two filesystem touches, transient plaintext on disk. rewrapBundle does it as unwrap + rewrap in memory: zero disk plaintext, one round-trip through the wrap layer, the gzipped tar archive bytes inside the envelope are never inflated. Cross-kind rotation (recipient ↔ passphrase) is refused — that's a separate migration the operator configures with explicit cryptoStrategy switch. **Added:** *`storage.rewrapBundle(bundleId, opts?)` — in-place envelope rotation* — Unwraps the bundle under the old key (storage's configured recipient/passphrase OR `opts.oldRecipient` / `opts.oldPassphrase`), re-wraps under the new key (`opts.newRecipient` / `opts.newPassphrase`), writes the rewrapped bytes back to the same storage key. Returns `{ bundleId, oldEnvelopeKind, newEnvelopeKind, bytesRewritten }`. Plaintext bundles refused with `backup/no-envelope-to-rewrap`; cross-kind rotation refused with `backup/no-new-recipient` / `backup/no-new-passphrase`. The inner archive bytes (the gz-compressed tar payload) are never decompressed or re-encoded — rewrap is a wrap-layer-only operation. **Security:** *Zero plaintext on disk during rotation* — The inner tar bytes flow only through memory — old-envelope unwrap → new-envelope wrap → adapter writeFile. Operators previously rotating via readBundle + writeBundle wrote plaintext archive bytes to a temporary stage directory; rewrapBundle removes that exposure window entirely. Matches the operator-side discipline that backup payloads should never land on disk in plaintext form during steady-state operations.
12
+
13
+ - v0.12.20 (2026-05-24) — **`bundleAdapterStorage.verifyAllBundles(opts)` — bounded-parallel batch integrity walk with stopOnFirstFailure short-circuit.** Batch wrapper over the v0.12.19 verifyBundle primitive. `storage.verifyAllBundles(opts?)` iterates `listBundles()` + walks each bundle with a bounded-parallel pool. Returns `{ total, ok, failed, results }` where `results` carries every per-bundle verifyBundle output (including bundleId, format, envelopeKind, entryCount, errors). `opts.concurrency` defaults to 4 (gentle on the storage backend); `opts.stopOnFirstFailure` short-circuits the walk when an unhealthy bundle is found (default off — operators want the full health report). `opts.recipient` / `opts.passphrase` forwarded to every per-bundle verify call. Operators wiring a periodic cron job over a backup repository now have a single primitive to call instead of hand-rolling the listBundles loop. **Added:** *`storage.verifyAllBundles(opts?)` — batch integrity walk* — Iterates `listBundles()` + calls `verifyBundle(bundleId, opts)` on each. Bounded-parallel fan-out (default 4 workers; `opts.concurrency` raises or lowers); each worker pulls from a shared queue + the warm-up keeps `concurrency` workers in flight until the queue drains. Returns the aggregate `{ total, ok, failed, results }` with `results` sorted by bundleId so the report is stable across runs regardless of completion order. `opts.stopOnFirstFailure` short-circuits the walk when the first unhealthy bundle is found — useful for fast-fail CI gates that don't need to enumerate every failure.
14
+
11
15
  - v0.12.19 (2026-05-24) — **`bundleAdapterStorage.verifyBundle(bundleId)` — bundle integrity check without restore + `b.safeArchive.inspect` tar.gz support.** `storage.verifyBundle(bundleId, opts?)` walks a bundle without restoring it: confirms the payload exists, the envelope (if any) decrypts under the supplied key, and the inner tar / tar.gz walker enumerates every entry without writing to disk. Returns `{ ok, format, envelopeKind, entryCount, errors }` — operators wanting periodic health-check of a backup repository call verifyBundle across `listBundles()` and aggregate `ok === false` results. Composes the bundle's known format directly through the unwrap → gunzip → tar pipeline (skips safeArchive's auto-sniff because the bundle's format is already known from bundleInfo). `b.safeArchive.inspect` separately gains `format: "tar.gz"` dispatch — operators with a known tar.gz payload now get entry enumeration without extracting. **Added:** *`storage.verifyBundle(bundleId, opts?)` — integrity check without restore* — Composes bundleInfo for format/envelope detection + the unwrap → gunzip → tar walker chain directly. opts.recipient / opts.passphrase override the storage's configured keys (useful for verifying a bundle under a different key set than the storage was opened with — e.g. verifying that a key rotation candidate can still read a bundle). Wrong key / corrupted payload returns `ok: false` with a typed error code in the errors array rather than throwing. Directory format reports ok=true based on manifest existence + readability (the inspect walker doesn't apply). · *`b.safeArchive.inspect` accepts `format: "tar.gz"`* — Mirrors the v0.12.9 extract surface: operators handed a `.tar.gz` payload can now enumerate entries without extracting. Composes `b.archive.read.gz` + `.asTar()` internally so the same bomb caps apply (1 GiB output / 100× ratio default) to inspect calls.
12
16
 
13
17
  - v0.12.18 (2026-05-24) — **`bundleAdapterStorage.listBundles({ withStats })` + `bundleInfo.createdAt` — opt-in mtime + size from `statKey`.** Two additions on `b.backup.bundleAdapterStorage`. `listBundles({ withStats: true })` fans out `statKey` per bundle and populates `createdAt` (ISO string from mtimeMs) + `size` (bytes) on every entry. Without the opt the default fast path stays a single listKeys call — operators rendering a bundle picker UI choose between O(1) listings and O(N) stat-enriched listings explicitly. `bundleInfo(bundleId)` gains the same `createdAt` field for parity. fsAdapter + objectStoreAdapter both expose `statKey`; legacy adapters without the capability leave the fields null. **Added:** *`storage.listBundles({ withStats: true })` — opt-in per-bundle stat fan-out* — When the adapter exposes `statKey`, populates `createdAt` (ISO string from mtimeMs) + `size` (bytes) per entry. Stat fan-out is O(N) round-trips so the opt is OFF by default — operators wanting cheap one-shot listings stay on `listBundles()`. Format precedence (tar.gz > tar > directory) carries through to which payload key gets stat'd. · *`storage.bundleInfo(bundleId)` returns `createdAt`* — The bundle introspection primitive now returns `{ bundleId, format, envelopeKind, sizeBytes, createdAt }`. `createdAt` is the ISO string derived from `statKey.mtimeMs` (when the adapter exposes it) — null otherwise. Matches the listBundles+withStats shape so operators can use bundleInfo for single-bundle drill-downs without re-mapping field names.
@@ -1532,6 +1532,200 @@ function bundleAdapterStorage(opts) {
1532
1532
  };
1533
1533
  }
1534
1534
  },
1535
+ // rewrapBundle(bundleId, opts) — v0.12.21 key rotation
1536
+ // without restore + rewrite. Unwraps the bundle under the old
1537
+ // key + re-wraps under the new key. Inner tar / tar.gz bytes
1538
+ // are never decompressed or rewritten. Operators rotating
1539
+ // recipient keypairs avoid the full restore-to-disk → rewrite
1540
+ // cycle. Returns `{ bundleId, oldEnvelopeKind, newEnvelopeKind,
1541
+ // bytesRewritten }`. Refuses cross-kind rotation (recipient ↔
1542
+ // passphrase) — that's a separate migration the operator
1543
+ // configures explicitly.
1544
+ async rewrapBundle(bundleId, rwOpts) {
1545
+ rwOpts = rwOpts || {};
1546
+ _ensureBundleId(bundleId);
1547
+ var info = await this.bundleInfo(bundleId);
1548
+ if (info.format !== "tar" && info.format !== "tar.gz") {
1549
+ throw new BackupError("backup/format-not-wrappable",
1550
+ "rewrapBundle: '" + bundleId + "' has format=" + JSON.stringify(info.format) +
1551
+ " — only tar / tar.gz bundles carry wrap envelopes");
1552
+ }
1553
+ var rwKeySuffix = info.format === "tar.gz" ? TAR_GZ_KEY_SUFFIX : TAR_KEY_SUFFIX;
1554
+ var sealed = await adapter.readFile(bundleId + rwKeySuffix);
1555
+ var envelopeKind = info.envelopeKind;
1556
+ // Codex P2 on v0.12.21 PR #172 — when the adapter has no
1557
+ // readPartial capability, bundleInfo returns envelopeKind:
1558
+ // "unknown" rather than risk a full payload load. For
1559
+ // rewrap, we already have to load the payload (to unwrap),
1560
+ // so fall back to a sniffEnvelope on the loaded sealed
1561
+ // bytes — fixes the regression where adapters satisfying
1562
+ // the minimum contract couldn't use rewrapBundle.
1563
+ if (envelopeKind === "unknown") {
1564
+ envelopeKind = archiveLazy().sniffEnvelope(sealed);
1565
+ }
1566
+ if (envelopeKind !== "recipient" && envelopeKind !== "passphrase") {
1567
+ throw new BackupError("backup/no-envelope-to-rewrap",
1568
+ "rewrapBundle: '" + bundleId + "' carries envelopeKind=" +
1569
+ JSON.stringify(envelopeKind) + " — nothing to rewrap");
1570
+ }
1571
+ // Override the info.envelopeKind we use below so the
1572
+ // dispatch + return shape reflect the actual envelope we
1573
+ // detected (matters when bundleInfo returned "unknown").
1574
+ info = Object.assign({}, info, { envelopeKind: envelopeKind });
1575
+ var inner;
1576
+ if (info.envelopeKind === "recipient") {
1577
+ var oldRcp = rwOpts.oldRecipient !== undefined ? rwOpts.oldRecipient : recipient;
1578
+ if (!oldRcp) {
1579
+ throw new BackupError("backup/no-old-recipient",
1580
+ "rewrapBundle: opts.oldRecipient (or the storage's configured recipient) is required to unwrap");
1581
+ }
1582
+ if (!rwOpts.newRecipient || typeof rwOpts.newRecipient !== "object") {
1583
+ throw new BackupError("backup/no-new-recipient",
1584
+ "rewrapBundle: opts.newRecipient is required to re-seal under the rotated key");
1585
+ }
1586
+ inner = archiveLazy().unwrap(sealed, { recipient: oldRcp });
1587
+ var resealed = archiveLazy().wrap(inner, { recipient: rwOpts.newRecipient });
1588
+ await adapter.writeFile(bundleId + rwKeySuffix, resealed);
1589
+ return {
1590
+ bundleId: bundleId,
1591
+ oldEnvelopeKind: "recipient",
1592
+ newEnvelopeKind: "recipient",
1593
+ bytesRewritten: resealed.length,
1594
+ };
1595
+ }
1596
+ // passphrase
1597
+ var oldPass = rwOpts.oldPassphrase !== undefined ? rwOpts.oldPassphrase : passphrase;
1598
+ if (typeof oldPass !== "string" && !Buffer.isBuffer(oldPass)) {
1599
+ throw new BackupError("backup/no-old-passphrase",
1600
+ "rewrapBundle: opts.oldPassphrase (or the storage's configured passphrase) is required to unwrap");
1601
+ }
1602
+ if (typeof rwOpts.newPassphrase !== "string" && !Buffer.isBuffer(rwOpts.newPassphrase)) {
1603
+ throw new BackupError("backup/no-new-passphrase",
1604
+ "rewrapBundle: opts.newPassphrase is required (string or Buffer) to re-seal");
1605
+ }
1606
+ inner = await archiveLazy().unwrapWithPassphrase(sealed, { passphrase: oldPass });
1607
+ // Codex P1 on v0.12.21 PR #172 — preserve the storage's
1608
+ // configured entropy floor across rewrap. The
1609
+ // writeBundle path raises the floor to 128 bits under
1610
+ // HIPAA / PCI-DSS postures (per
1611
+ // BACKUP_ENCRYPTION_REQUIRED_POSTURES); rewrapBundle MUST
1612
+ // apply the same floor so a rotated passphrase that
1613
+ // writeBundle would refuse can't slip through. The
1614
+ // operator's explicit rwOpts.passphraseMinEntropyBits
1615
+ // can raise the floor further but cannot lower it.
1616
+ var effectiveFloor = passphraseMinEntropyBits; // storage's posture-effective floor
1617
+ if (typeof rwOpts.passphraseMinEntropyBits === "number" &&
1618
+ Number.isFinite(rwOpts.passphraseMinEntropyBits) &&
1619
+ rwOpts.passphraseMinEntropyBits > effectiveFloor) {
1620
+ effectiveFloor = Math.floor(rwOpts.passphraseMinEntropyBits);
1621
+ }
1622
+ var resealedP = await archiveLazy().wrapWithPassphrase(inner, {
1623
+ passphrase: rwOpts.newPassphrase,
1624
+ minEntropyBits: effectiveFloor,
1625
+ });
1626
+ await adapter.writeFile(bundleId + rwKeySuffix, resealedP);
1627
+ return {
1628
+ bundleId: bundleId,
1629
+ oldEnvelopeKind: "passphrase",
1630
+ newEnvelopeKind: "passphrase",
1631
+ bytesRewritten: resealedP.length,
1632
+ };
1633
+ },
1634
+ // verifyAllBundles(opts?) — v0.12.20 batch integrity check.
1635
+ // Iterates listBundles() + calls verifyBundle on each. Returns
1636
+ // `{ total, ok, failed, results }` where `results` is an array
1637
+ // of per-bundle verifyBundle outputs. opts.concurrency caps the
1638
+ // parallelism (default 4 — gentle on the storage backend);
1639
+ // opts.stopOnFirstFailure short-circuits the walk when an
1640
+ // unhealthy bundle is found (default false — operators want
1641
+ // the full report). opts.recipient / opts.passphrase forwarded
1642
+ // to verifyBundle for each bundle.
1643
+ async verifyAllBundles(vOpts) {
1644
+ vOpts = vOpts || {};
1645
+ // Codex P1 on v0.12.20 PR #171 — clamp fractional + zero
1646
+ // floors so a stray `0.5` doesn't spawn zero workers + return
1647
+ // a silent ok=0/failed=0 report on non-empty storage. Default
1648
+ // 4; minimum 1; non-finite / non-positive falls back to
1649
+ // default.
1650
+ var concurrency = 4; // allow:raw-byte-literal — default fan-out, not byte count
1651
+ if (typeof vOpts.concurrency === "number" && Number.isFinite(vOpts.concurrency) &&
1652
+ vOpts.concurrency > 0) {
1653
+ concurrency = Math.max(1, Math.floor(vOpts.concurrency));
1654
+ }
1655
+ var stopOnFirst = vOpts.stopOnFirstFailure === true;
1656
+ var list = await this.listBundles();
1657
+ var self = this;
1658
+ var results = [];
1659
+ var failed = 0;
1660
+ var ok = 0;
1661
+ var pending = list.slice();
1662
+ var inflight = [];
1663
+ var aborted = false;
1664
+ // Sequential bounded-parallel walk. Bring up `concurrency`
1665
+ // workers; each pulls the next bundleId until the queue is
1666
+ // empty or stopOnFirstFailure trips.
1667
+ function _spawn() {
1668
+ if (aborted) return null;
1669
+ if (pending.length === 0) return null;
1670
+ var entry = pending.shift();
1671
+ // Codex P2 on v0.12.20 PR #171 — wrap each worker so any
1672
+ // verifyBundle rejection becomes a failed-result entry
1673
+ // rather than rejecting the whole batch. Without this, a
1674
+ // mid-walk failure (payload disappeared between listBundles
1675
+ // + readFile, network blip on object-store, etc.) would
1676
+ // throw out of Promise.race and abort verifyAllBundles
1677
+ // without returning the promised aggregate report.
1678
+ var promise = self.verifyBundle(entry.bundleId, {
1679
+ recipient: vOpts.recipient,
1680
+ passphrase: vOpts.passphrase,
1681
+ }).then(function (r) {
1682
+ results.push(Object.assign({ bundleId: entry.bundleId }, r));
1683
+ if (r.ok) ok += 1;
1684
+ else {
1685
+ failed += 1;
1686
+ if (stopOnFirst) aborted = true;
1687
+ }
1688
+ }, function (err) {
1689
+ // Rejection path — convert to a failed-result entry so
1690
+ // the aggregate stays consistent.
1691
+ results.push({
1692
+ bundleId: entry.bundleId,
1693
+ ok: false,
1694
+ format: null,
1695
+ envelopeKind: null,
1696
+ entryCount: 0,
1697
+ errors: [err && err.code ? err.code : ((err && err.message) || String(err))],
1698
+ });
1699
+ failed += 1;
1700
+ if (stopOnFirst) aborted = true;
1701
+ });
1702
+ inflight.push(promise);
1703
+ promise.finally(function () {
1704
+ var idx = inflight.indexOf(promise);
1705
+ if (idx !== -1) inflight.splice(idx, 1);
1706
+ });
1707
+ return promise;
1708
+ }
1709
+ // Warm-up: spawn up to concurrency workers.
1710
+ var i;
1711
+ for (i = 0; i < concurrency; i += 1) _spawn();
1712
+ // Drain: as each worker resolves, spawn its replacement.
1713
+ while (inflight.length > 0) {
1714
+ await Promise.race(inflight.slice());
1715
+ if (!aborted && pending.length > 0 && inflight.length < concurrency) {
1716
+ while (inflight.length < concurrency && pending.length > 0) _spawn();
1717
+ }
1718
+ }
1719
+ // Sort results back to listBundles order for operator-
1720
+ // friendly output (concurrency reorders inflight completion).
1721
+ results.sort(function (a, b) { return a.bundleId < b.bundleId ? 1 : -1; });
1722
+ return {
1723
+ total: list.length,
1724
+ ok: ok,
1725
+ failed: failed,
1726
+ results: results,
1727
+ };
1728
+ },
1535
1729
  // bundleInfo(bundleId) — v0.12.17 per-bundle introspection.
1536
1730
  // Returns `{ bundleId, format, envelopeKind, sizeBytes }`.
1537
1731
  // `format` is one of `"tar"` / `"tar.gz"` / `"directory"`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.12.19",
3
+ "version": "0.12.21",
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:4b94a721-bd76-436d-8d96-e205956b6d90",
5
+ "serialNumber": "urn:uuid:2525a280-77a5-4844-bb70-d0fe3edb44f8",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-24T05:39:53.422Z",
8
+ "timestamp": "2026-05-24T07:09:48.128Z",
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.12.19",
22
+ "bom-ref": "@blamejs/core@0.12.21",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.12.19",
25
+ "version": "0.12.21",
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.12.19",
29
+ "purl": "pkg:npm/%40blamejs/core@0.12.21",
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.12.19",
57
+ "ref": "@blamejs/core@0.12.21",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]