@blamejs/core 0.12.15 → 0.12.17

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.17 (2026-05-23) — **`bundleAdapterStorage.bundleInfo` + `listBundles.format` — per-bundle introspection for envelope kind + format without restore.** Two introspection additions on `b.backup.bundleAdapterStorage`. `listBundles()` now returns the inferred `format` (`"tar"` / `"tar.gz"` / `"directory"`) per bundle from the storage key suffix — no byte read. `storage.bundleInfo(bundleId)` returns `{ bundleId, format, envelopeKind, sizeBytes }` where `envelopeKind` is the result of a 5-byte magic probe (`"recipient"` / `"passphrase"` / `"none"`). Operators administering a multi-strategy backup repository can now filter bundles by encryption posture or by format without a full restore cycle. **Added:** *`listBundles()` carries inferred format per bundle* — Each entry now includes `format` alongside `bundleId` / `createdAt` / `size`. Inference is from the storage key suffix the writeBundle path produced — `<bid>/bundle.tar` → tar, `<bid>/bundle.tar.gz` → tar.gz, anything else → directory. Cheap: no byte read, no per-key stat call. Operators rendering a bundle picker UI now sort + filter by format from a single list call. · *`storage.bundleInfo(bundleId)` — per-bundle introspection* — Returns `{ bundleId, format, envelopeKind, sizeBytes }`. `format` from the storage layout (no byte read). `envelopeKind` from `b.archive.sniffEnvelope` over the bundle payload — `"recipient"` (BAWRP / v0.12.10 hybrid PQC), `"passphrase"` (BAWPP / v0.12.11 Argon2id), `"none"` (plaintext or directory format). `sizeBytes` is the payload byte count for tar / tar.gz; null for directory format (operator's per-file walk applies if exact size matters). Nonexistent bundles refused with `backup/bundle-not-found`.
12
+
13
+ - v0.12.16 (2026-05-23) — **`b.safeArchive.inspect` auto-unwraps wrap envelopes (parallel to the v0.12.15 extract path).** Mirrors the v0.12.15 auto-unwrap support into `b.safeArchive.inspect`. Operators enumerating entries of a sealed archive get a single inspect() call regardless of envelope shape — pass `opts.recipient` or `opts.passphrase` alongside `source` and the orchestrator unwraps inline before walking the inner format. Missing-key opt surfaces a structured `safe-archive/no-recipient-for-wrap` / `safe-archive/no-passphrase-for-wrap` refusal upfront. Carries the v0.12.15 P1 + P2 fixes (close original source before replacing + forward opts.signal to inner buffer adapter) into the inspect path so the same descriptor-leak + abort-propagation contracts hold. **Added:** *`b.safeArchive.inspect` auto-unwraps `BAWRP` + `BAWPP` envelopes* — The orchestrator's `format: "auto"` sniffer recognises the wrap magics and routes through `b.archive.unwrap` / `b.archive.unwrapWithPassphrase` inline. After unwrap, the inner bytes are wrapped in a buffer adapter + re-sniffed; the resulting summary carries the INNER `format` (`"tar"` / `"zip"` / etc.) — operators querying `summary.format` see the carrier format, not `"wrap-recipient"`. Entry enumeration walks the inner archive after a single key-derivation pass; no temporary file lands on disk.
14
+
11
15
  - v0.12.15 (2026-05-23) — **`b.safeArchive.extract` auto-unwraps v0.12.10 recipient and v0.12.11 passphrase envelopes inline.** The safeArchive orchestrator's `format: "auto"` sniffer recognises `BAWRP` (v0.12.10 recipient) and `BAWPP` (v0.12.11 passphrase) envelope magics and routes through `b.archive.unwrap` / `b.archive.unwrapWithPassphrase` inline before re-sniffing the inner format. Operators pass `opts.recipient` (or `opts.passphrase`) alongside `source` + `destination` and get a single extract() call regardless of envelope shape. Missing the matching key opt surfaces a structured `safe-archive/no-recipient-for-wrap` / `safe-archive/no-passphrase-for-wrap` refusal upfront rather than a downstream crypto error. **Added:** *`b.safeArchive.extract` auto-unwraps wrap envelopes* — The sniffer at byte 0-4 recognises `BAWRP` (returns `format: "wrap-recipient"`) and `BAWPP` (returns `format: "wrap-passphrase"`). The extract path collects the sealed adapter into a Buffer, routes through `b.archive.unwrap` (recipient) or `b.archive.unwrapWithPassphrase` (passphrase), wraps the inner bytes in a buffer adapter, re-sniffs the inner format, and dispatches to the appropriate `b.archive.read.*` reader. A wrap-around-tar.gz envelope round-trips through wrap → unwrap → gunzip → untar with no operator intervention beyond passing the key opt. **Security:** *Missing-key opt refused upfront with structured error* — When the sniffer identifies a wrap envelope but the operator hasn't supplied `opts.recipient` (BAWRP) or `opts.passphrase` (BAWPP), extract refuses with `safe-archive/no-recipient-for-wrap` / `safe-archive/no-passphrase-for-wrap` BEFORE any decryption attempt. Operators wiring extract behind an HTTP boundary get a typed refusal instead of a leaked crypto-level error.
12
16
 
13
17
  - v0.12.14 (2026-05-23) — **`b.archive.sniffEnvelope(bytes)` — identify recipient vs passphrase vs raw payload without attempting decryption.** Small helper closing a gap in the archive-wrap surface. `b.archive.sniffEnvelope(bytes)` reads the first 5 bytes of a buffer and returns one of `"recipient"` (v0.12.10 BAWRP envelope), `"passphrase"` (v0.12.11 BAWPP envelope), or `"none"` (raw payload or unrelated bytes). The sniff does NO cryptographic work — no Argon2id round, no decapsulation, no allocation beyond a 5-byte ASCII compare — so it's safe to call on adversarial input. Operators dispatching between unwrap paths get a clean predicate instead of trial-decrypting under multiple key candidates. **Added:** *`b.archive.sniffEnvelope(bytes)` — magic-byte envelope identifier* — Returns `"recipient"` (BAWRP / v0.12.10 hybrid PQC envelope), `"passphrase"` (BAWPP / v0.12.11 Argon2id + XChaCha20 envelope), or `"none"` (raw archive bytes / unrelated payload). Accepts Buffer + Uint8Array; non-buffer / null / undefined / empty-buffer inputs return `"none"` upfront. Operators wire the result into a switch that dispatches to the matching unwrap primitive — no trial decryption, no per-key candidate attempts.
@@ -1367,6 +1367,19 @@ function bundleAdapterStorage(opts) {
1367
1367
  },
1368
1368
  async listBundles() {
1369
1369
  // Get every key, partition by bundleId prefix, return sorted.
1370
+ // v0.12.17 — each bundle now carries the inferred format
1371
+ // (tar / tar.gz / directory) so operators picking which
1372
+ // bundle to restore can filter by format without touching
1373
+ // bytes. Format is inferred from the key suffix the
1374
+ // writeBundle path produced (rule §2 — the format is part
1375
+ // of the storage layout, not behind a probe).
1376
+ //
1377
+ // Codex P2 on v0.12.17 PR #168 — track WHICH suffixes a
1378
+ // bundle carries (set of booleans) then apply explicit
1379
+ // precedence at the end: tar.gz > tar > directory. Matches
1380
+ // readBundle's preference (which checks hasTarGz first)
1381
+ // so listBundles' reported format aligns with restore
1382
+ // behavior regardless of adapter.listKeys() order.
1370
1383
  var allKeys = await adapter.listKeys("");
1371
1384
  var byBundle = new Map();
1372
1385
  for (var i = 0; i < allKeys.length; i += 1) {
@@ -1377,28 +1390,103 @@ function bundleAdapterStorage(opts) {
1377
1390
  if (!_isValidBundleId(bid)) continue;
1378
1391
  var stats = byBundle.get(bid);
1379
1392
  if (!stats) {
1380
- stats = { count: 0, size: 0 };
1393
+ stats = { count: 0, hasTar: false, hasTarGz: false, hasOther: false };
1381
1394
  byBundle.set(bid, stats);
1382
1395
  }
1383
1396
  stats.count += 1;
1384
- // Note: size is approximate — we'd need a stat-per-key here,
1385
- // and many adapter implementations don't expose a fast stat
1386
- // primitive. listBundles is best-effort; operators wanting
1387
- // exact size do their own walk.
1397
+ var rest = key.slice(slash + 1);
1398
+ if (rest === "bundle.tar") stats.hasTar = true;
1399
+ else if (rest === "bundle.tar.gz") stats.hasTarGz = true;
1400
+ else stats.hasOther = true;
1388
1401
  }
1389
1402
  var out = [];
1390
1403
  var entries = Array.from(byBundle.entries());
1391
1404
  for (var j = 0; j < entries.length; j += 1) {
1392
1405
  var bidJ = entries[j][0];
1406
+ var statsJ = entries[j][1];
1407
+ var fmtJ;
1408
+ if (statsJ.hasTarGz) fmtJ = "tar.gz"; // matches readBundle precedence
1409
+ else if (statsJ.hasTar) fmtJ = "tar";
1410
+ else fmtJ = "directory";
1393
1411
  out.push({
1394
1412
  bundleId: bidJ,
1395
- createdAt: null, // adapter may not expose mtime
1396
- size: null, // best-effort
1413
+ format: fmtJ,
1414
+ createdAt: null, // adapter may not expose mtime
1415
+ size: null, // best-effort; operators with stat-fast adapters call bundleInfo
1397
1416
  });
1398
1417
  }
1399
1418
  out.sort(function (a, b) { return a.bundleId < b.bundleId ? 1 : -1; });
1400
1419
  return out;
1401
1420
  },
1421
+ // bundleInfo(bundleId) — v0.12.17 per-bundle introspection.
1422
+ // Returns `{ bundleId, format, envelopeKind, sizeBytes }`.
1423
+ // `format` is one of `"tar"` / `"tar.gz"` / `"directory"`
1424
+ // inferred from the storage layout (no byte read).
1425
+ // `envelopeKind` is the result of a 5-byte magic probe on the
1426
+ // bundle payload — `"recipient"` (BAWRP) / `"passphrase"`
1427
+ // (BAWPP) / `"none"` (plaintext). `sizeBytes` is the payload
1428
+ // byte count for tar / tar.gz; null for directory format
1429
+ // (operator's per-file walk if exact size matters).
1430
+ //
1431
+ // Instance method — wiki page documents this under the
1432
+ // bundleAdapterStorage primitive rather than as a top-level
1433
+ // b.X primitive.
1434
+ async bundleInfo(bundleId) {
1435
+ _ensureBundleId(bundleId);
1436
+ var tarKey = bundleId + TAR_KEY_SUFFIX;
1437
+ var tarGzKey = bundleId + TAR_GZ_KEY_SUFFIX;
1438
+ var manifestKey = bundleId + "/manifest.json";
1439
+ var fmt = null;
1440
+ var payloadKey = null;
1441
+ if (await adapter.hasKey(tarGzKey)) {
1442
+ fmt = "tar.gz"; payloadKey = tarGzKey;
1443
+ } else if (await adapter.hasKey(tarKey)) {
1444
+ fmt = "tar"; payloadKey = tarKey;
1445
+ } else if (await adapter.hasKey(manifestKey)) {
1446
+ fmt = "directory";
1447
+ } else {
1448
+ throw new BackupError("backup/bundle-not-found",
1449
+ "bundleInfo: '" + bundleId + "' not in storage");
1450
+ }
1451
+ var envelopeKind = "none";
1452
+ var sizeBytes = null;
1453
+ if (payloadKey !== null) {
1454
+ // Codex P1 on v0.12.17 PR #168 — claim was a 5-byte magic
1455
+ // probe; the implementation was reading the entire bundle
1456
+ // into memory. For multi-GB bundles, an administrative
1457
+ // metadata call would allocate the whole payload and put
1458
+ // memory pressure on the host. Prefer the adapter's
1459
+ // optional `readPartial(key, length)` capability for the
1460
+ // probe. fsAdapter + objectStoreAdapter both expose it as
1461
+ // of v0.12.17; legacy adapters without it fall back to a
1462
+ // capped 16-byte readFile via the fallback path (still
1463
+ // bounded; better than full payload).
1464
+ if (typeof adapter.readPartial === "function") {
1465
+ var probe = await adapter.readPartial(payloadKey, 16); // allow:raw-byte-literal — 16-byte probe head, magic comparison
1466
+ envelopeKind = archiveLazy().sniffEnvelope(probe);
1467
+ } else {
1468
+ // Legacy adapter — readPartial missing. Operators using
1469
+ // a custom adapter without the capability get
1470
+ // envelopeKind: "unknown" rather than an OOM risk. They
1471
+ // can probe themselves by reading the first N bytes via
1472
+ // their own client.
1473
+ envelopeKind = "unknown";
1474
+ }
1475
+ // sizeBytes is reported via a stat-like path when the
1476
+ // adapter exposes one; otherwise stays null. fsAdapter +
1477
+ // objectStoreAdapter expose `statKey`.
1478
+ if (typeof adapter.statKey === "function") {
1479
+ var st = await adapter.statKey(payloadKey);
1480
+ if (st && typeof st.size === "number") sizeBytes = st.size;
1481
+ }
1482
+ }
1483
+ return {
1484
+ bundleId: bundleId,
1485
+ format: fmt,
1486
+ envelopeKind: envelopeKind,
1487
+ sizeBytes: sizeBytes,
1488
+ };
1489
+ },
1402
1490
  async deleteBundle(bundleId) {
1403
1491
  _ensureBundleId(bundleId);
1404
1492
  var keys = await adapter.listKeys(bundleId + "/");
@@ -1494,6 +1582,42 @@ bundleAdapterStorage.fsAdapter = function (fsOpts) {
1494
1582
  try { return nodeFs.existsSync(_keyPath(key)); }
1495
1583
  catch (_e) { return false; }
1496
1584
  },
1585
+ // v0.12.17 — optional capabilities consumed by bundleInfo.
1586
+ // readPartial: open + read up to `length` bytes from the start
1587
+ // of the file without materializing the whole payload.
1588
+ // Bundle-info's envelope probe needs at most 16 bytes — the
1589
+ // partial read keeps multi-GB bundle metadata cheap.
1590
+ async readPartial(key, length) {
1591
+ // CodeQL js/file-system-race + js/insecure-temporary-file —
1592
+ // drop the existsSync probe (TOCTOU) and the default mode on
1593
+ // open. Use openSync with explicit owner-only mode + handle
1594
+ // ENOENT atomically; the system call is itself the existence
1595
+ // check.
1596
+ var p = _keyPath(key);
1597
+ var fd;
1598
+ try {
1599
+ fd = nodeFs.openSync(p, "r", 0o600);
1600
+ } catch (e) {
1601
+ if (e && e.code === "ENOENT") {
1602
+ throw new BackupError("backup/no-key",
1603
+ "fsAdapter.readPartial: key not found: " + JSON.stringify(key));
1604
+ }
1605
+ throw e;
1606
+ }
1607
+ try {
1608
+ var buf = Buffer.alloc(length);
1609
+ var bytesRead = nodeFs.readSync(fd, buf, 0, length, 0);
1610
+ return buf.slice(0, bytesRead);
1611
+ } finally {
1612
+ try { nodeFs.closeSync(fd); } catch (_e) { /* drop-silent */ }
1613
+ }
1614
+ },
1615
+ async statKey(key) {
1616
+ var p = _keyPath(key);
1617
+ if (!nodeFs.existsSync(p)) return null;
1618
+ var st = nodeFs.statSync(p);
1619
+ return { size: st.size, mtimeMs: st.mtimeMs };
1620
+ },
1497
1621
  };
1498
1622
  };
1499
1623
 
@@ -1682,6 +1806,36 @@ bundleAdapterStorage.objectStoreAdapter = function (client, osOpts) {
1682
1806
  throw e;
1683
1807
  }
1684
1808
  },
1809
+ // v0.12.17 — readPartial uses the b.objectStore client's range
1810
+ // capability (every shipped backend honours `{ range: [start,
1811
+ // end] }` per the client contract). bundleInfo's envelope probe
1812
+ // reads 16 bytes regardless of bundle size.
1813
+ async readPartial(key, length) {
1814
+ var scoped = _scopedKey(key);
1815
+ try {
1816
+ var body = await client.get(scoped, { range: [0, Math.max(0, length - 1)] });
1817
+ var buf = Buffer.isBuffer(body) ? body : Buffer.from(body);
1818
+ return buf.slice(0, length);
1819
+ } catch (e) {
1820
+ if (e && (e.code === "NOT_FOUND" || /NOT_FOUND|not found/i.test(e.message || ""))) {
1821
+ throw new BackupError("backup/no-key",
1822
+ "objectStoreAdapter.readPartial: key not found: " + JSON.stringify(key));
1823
+ }
1824
+ throw e;
1825
+ }
1826
+ },
1827
+ async statKey(key) {
1828
+ try {
1829
+ var meta = await client.head(_scopedKey(key));
1830
+ if (!meta || typeof meta.size !== "number") return null;
1831
+ return { size: meta.size, mtimeMs: meta.lastModified || null };
1832
+ } catch (e) {
1833
+ if (e && (e.code === "NOT_FOUND" || /NOT_FOUND|not found/i.test(e.message || ""))) {
1834
+ return null;
1835
+ }
1836
+ throw e;
1837
+ }
1838
+ },
1685
1839
  };
1686
1840
  };
1687
1841
 
@@ -330,6 +330,38 @@ async function inspect(opts) {
330
330
  var sniff = await _sniffMagic(source);
331
331
  format = sniff.format;
332
332
  }
333
+ // v0.12.16 — auto-unwrap path for inspect, parallel to the
334
+ // v0.12.15 extract path. Wrap envelopes (BAWRP / BAWPP) are
335
+ // unwrapped inline + re-sniffed so operators can enumerate
336
+ // entries of a sealed archive in a single inspect() call.
337
+ if (format === "wrap-recipient" || format === "wrap-passphrase") {
338
+ var sealedBytes = await _collectSourceBytes(source);
339
+ var inner;
340
+ if (format === "wrap-recipient") {
341
+ if (!opts.recipient) {
342
+ throw new SafeArchiveError("safe-archive/no-recipient-for-wrap",
343
+ "inspect: source is a wrap-recipient envelope (BAWRP) but opts.recipient was not supplied. " +
344
+ "Pass `{ recipient: { privateKey, ecPrivateKey } }` (or peer-cert form) to unwrap inline.");
345
+ }
346
+ inner = archiveWrap().unwrap(sealedBytes, { recipient: opts.recipient });
347
+ } else {
348
+ if (typeof opts.passphrase !== "string" && !Buffer.isBuffer(opts.passphrase)) {
349
+ throw new SafeArchiveError("safe-archive/no-passphrase-for-wrap",
350
+ "inspect: source is a wrap-passphrase envelope (BAWPP) but opts.passphrase was not supplied. " +
351
+ "Pass `{ passphrase: <string|Buffer> }` to unwrap inline.");
352
+ }
353
+ inner = await archiveWrap().unwrapWithPassphrase(sealedBytes, { passphrase: opts.passphrase });
354
+ }
355
+ // v0.12.15 P1 — close the original fs adapter (if string-
356
+ // backed) BEFORE replacing the source reference. v0.12.15 P2
357
+ // — forward opts.signal to the inner buffer adapter.
358
+ if (typeof source.close === "function" && typeof opts.source === "string") {
359
+ try { source.close(); } catch (_e) { /* drop-silent */ }
360
+ }
361
+ source = archiveAdapters().buffer(inner, { signal: opts.signal });
362
+ var innerSniff = await _sniffMagic(source);
363
+ format = innerSniff.format;
364
+ }
333
365
  var reader;
334
366
  if (format === "zip") {
335
367
  reader = archiveRead().zip(source, {
@@ -343,7 +375,7 @@ async function inspect(opts) {
343
375
  });
344
376
  } else {
345
377
  throw new SafeArchiveError("safe-archive/format-unsupported",
346
- "inspect: format=" + JSON.stringify(format) + " — v0.12.8 ships ZIP + tar");
378
+ "inspect: format=" + JSON.stringify(format) + " — v0.12.8 ships ZIP + tar; v0.12.16 auto-unwraps wrap envelopes");
347
379
  }
348
380
  var entries = await reader.inspect();
349
381
  var totalCompressed = 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.12.15",
3
+ "version": "0.12.17",
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:73e48489-0fa1-477f-b6f3-eec74e9ba65f",
5
+ "serialNumber": "urn:uuid:5b052d03-601b-4eb1-93d8-60aaaf81b2f7",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-24T03:18:46.285Z",
8
+ "timestamp": "2026-05-24T04:43:58.102Z",
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.15",
22
+ "bom-ref": "@blamejs/core@0.12.17",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.12.15",
25
+ "version": "0.12.17",
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.15",
29
+ "purl": "pkg:npm/%40blamejs/core@0.12.17",
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.15",
57
+ "ref": "@blamejs/core@0.12.17",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]