@blamejs/core 0.6.25 → 0.6.27

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.6.x
10
10
 
11
+ - **0.6.27** (2026-05-02) — Redis backend for `b.queue` so multi-replica apps can share a single queue without each needing to be cluster leader. **Bespoke RESP2 client** (`lib/redis-client.js`) — zero npm runtime deps. TCP via `node:net` + TLS via `node:tls` (`rediss://` auto-detected), legacy single-arg AUTH + ACL `AUTH user pass`, `SELECT db`, pipelining, exponential-backoff reconnect, EVAL helper. **`b.queue` protocol "redis"** (`lib/queue-redis.js`) — full enqueue/lease/extendLease/complete/fail/sweepExpired/size/purge/dlqList/dlqRetry/dlqSize parity with the local backend. Atomicity comes from server-side Lua scripts so concurrent consumers can't double-lease and a sweep can't race a complete. Storage layout: per-job HASH (sealed payload + lastError via `cryptoField.sealRow("_blamejs_jobs", row)` — same crypto config as the local backend), per-queue ready ZSET scored by availableAt, per-queue inflight ZSET scored by leaseExpiresAt, per-queue dlq ZSET scored by finishedAt, plus a queues SET so sweepExpired walks every known queue without a global secondary index. Cron-repeat handled in `complete()` JS — re-enqueues the next firing as a fresh jobId with availableAt=next-cron-fire. **`b.queue.bootFromEnv({ env })`** — env-driven init mirroring `b.network.bootFromEnv` and `b.logStream.bootFromEnv`. Reads `BLAMEJS_QUEUE_PROTOCOL` (`local`|`redis`), `BLAMEJS_QUEUE_REDIS_URL`, `BLAMEJS_QUEUE_REDIS_PASSWORD`, `BLAMEJS_QUEUE_REDIS_USERNAME`, `BLAMEJS_QUEUE_REDIS_TLS`, `BLAMEJS_QUEUE_REDIS_KEY_PREFIX`. Operators flip from local to Redis without a code change; both wiki docker-compose configs declare the new env knobs. Removed `redis` from `DEFERRED_PROTOCOLS`. **Out of scope for v1** (deferred to follow-up patches with explicit re-open conditions): Redis Cluster (slot-routing), Sentinel (managed primary failover), priority ordering on the Redis backend (queue-local supports `priority` opt; Redis backend orders strictly by availableAt for v1), flow children with `dependsOn` cascade. **Wiki**: queue-cache page documents the Redis backend opts schema, bootFromEnv, and the layout. **Tests**: 24 RESP2 protocol parser unit tests (`test/layer-0-primitives/redis-client.test.js`) cover URL parsing, command encoding (binary-safe), every reply type (simple string / error / integer / bulk / nil bulk / nested array / pipelined / incomplete-mid-frame). Live Redis round-trip tests (`test/layer-0-primitives/queue-redis.test.js`) cover enqueue+lease, availableAt scheduling, visibility-timeout sweep, fail+retry path, DLQ list/retry/size, extendLease, purge, and concurrent-leaser no-double-lease — skip cleanly when `BLAMEJS_TEST_REDIS_URL` is not set so the smoke suite passes on dev boxes without a Redis container. Smoke 6780 OK.
12
+ - **0.6.26** (2026-05-02) — `blamejs restore` and `blamejs audit verify-chain` subcommands wrap the existing `b.restore` and `b.audit.verifyChain` primitives so operators can drive them from runbooks without writing app code. **`blamejs restore`**: `list` (enumerate bundles in storage), `inspect` (manifest summary without touching live data), `apply` (live in-place restore with rollback preserved), `rollback` (revert to most-recent OR named restore point), `list-rollbacks` (enumerate preserved rollback points). Two ways to identify a bundle — `--bundle <dir>` matches the shape `blamejs backup extract` produces (parent dir is treated as storage root, basename as bundle id), or `--storage-root <root> --bundle-id <id>` for multi-bundle stores. `apply` honors `--max-pulled-bytes` / `--max-pulled-files` (defaults 4 GiB / 100K), `--rollback-root` (default `<data-dir>.rollbacks`), `--no-audit`, and `BLAMEJS_BACKUP_PASSPHRASE` env. **`blamejs audit verify-chain`**: walks the live audit chain end-to-end, reports tampering with `breakAt` / `breakRowId` / expected-vs-actual prevHash; honours `--max-rows` to bound long walks; default table is `audit_log`. **Wiki CLI snapshot test now validates subcommand pairs**, not just top commands. Walks every wiki + README invocation of the form `blamejs <cmd> <sub>` and verifies that <sub> exists in the perCommand[<cmd>].subcommands list parsed from `lib/cli.js`. Surfaced one real drift on first run: `examples/wiki/seeders/prod/pages/backup-restore.js` documented `blamejs audit verify-signing` after vault rotation, but no such subcommand existed (only `verify-bundle`); fixed by shipping the new `verify-chain` subcommand and updating the wiki to reference it. Two top-level command gaps surfaced and fixed: `blamejs restore` (now real) and `blamejs network status` (was prose-promised in `network-config.js`; reworded to point at `b.network.snapshot()` for /healthz / custom diagnostics routes since wiring a CLI command for it is operator-side work). README CLI table updated with `restore` row and the two new audit subcommands.
11
13
  - **0.6.25** (2026-05-02) — `b.logStream` gains an AWS CloudWatch Logs sink AND framework-level env wiring. **CloudWatch sink**: `protocol: "cloudwatch"` POSTs `PutLogEvents` over HTTPS with SigV4 signing (service `logs`); operator pre-creates the log group + log stream (the framework does NOT auto-create). Honors IAM role + STS session-token credentials. Respects all three CloudWatch caps automatically: 10,000 events / 1 MiB total payload / 256 KiB-per-event. Per-event oversize dropped at `emit()`-time with `onDrop` fired (truncated message in the drop notification). Per-batch oversize split mid-flush. Permanent AWS errors (`ResourceNotFoundException` / `AccessDeniedException` / `InvalidParameterException` / `UnrecognizedClientException` / `SerializationException`) skip the retry budget. `InvalidSequenceTokenException` (legacy CW accounts) extracts the expected token from the error message and retries with it once. `lib/object-store/sigv4.js` `signRequest()` is now service-agnostic — accepts `opts.service` (default still `"s3"` for back-compat). Removed `cloudwatch` from `DEFERRED_PROTOCOLS`. **`b.logStream.bootFromEnv({ env })`**: framework-level env-driven init mirroring `b.network.bootFromEnv`. Reads `BLAMEJS_LOG_STREAM_PROTOCOL` (`local`/`webhook`/`otlp`/`cloudwatch`), `BLAMEJS_LOG_STREAM_URL`, `BLAMEJS_LOG_STREAM_TOKEN`, `BLAMEJS_LOG_STREAM_SERVICE_NAME`, `BLAMEJS_LOG_STREAM_SERVICE_VERSION`, `BLAMEJS_LOG_STREAM_CLOUDWATCH_LOG_GROUP`, `BLAMEJS_LOG_STREAM_CLOUDWATCH_LOG_STREAM`, `BLAMEJS_LOG_STREAM_PATH`, plus standard AWS_*. Operators get a working log-stream sink without writing build-app code. Wiki app's `build-app.js` replaced its inline env-reading with one `b.logStream.bootFromEnv()` call; both docker-compose configs declare the new env knobs. **Wiki env-snapshot test**: parallel to api-snapshot.json — walks `process.env.X` / `env.X` / `safeEnv.readVar("X")` reads in the wiki app + framework `lib/`, walks docker-compose env declarations, captures the union as `examples/wiki/env-snapshot.json`, fails the e2e gate when env vars are added/removed without updating the snapshot OR when source-only / compose-only gaps appear (env knob declared but unread, env read but undocumented). The validator immediately surfaced 13 real gaps in the wiki app: 5 `WIKI_*` env vars read by source but missing from docker-compose (`WIKI_VAULT_MODE`, `WIKI_DB_AT_REST`, `WIKI_AUDIT_SIGNING_MODE`, `WIKI_BIND`, `WIKI_SITE_URL`), 2 framework env vars (`BLAMEJS_AUDIT_SIGNING_MODE`, `BLAMEJS_TMPDIR`) read by `lib/db.js` via `safeEnv.readVar` but never declared in the wiki's compose configs, and 6 dead env knobs in compose that nothing read. All 13 fixed. Update workflow: `BLAMEJS_UPDATE_ENV_SNAPSHOT=1 node examples/wiki/test/validate-env-snapshot.js` (mirrors the api-snapshot UX). Wiki observability page documents the new sink alongside webhook + otlp; README "What ships in the box" calls out all four log-stream sinks. Tests cover endpoint resolution, event-byte accounting, batch sorting + sequence-token round-trip, permanent-error classifier, validation, round-trip via mock CloudWatch, STS session-token propagation, ResourceNotFoundException no-retry path, 256 KiB per-event hard cap, dispatcher integration, AND batch splitting on the 1-MiB cap (5 quarter-MB events POST as 4 + 1 batches).
12
14
  - **0.6.24** (2026-05-02) — `b.logStream` gains an OTLP/HTTP-JSON sink. `protocol: "otlp"` now forwards log records to any OpenTelemetry collector via the OTel Logs Data Model — `resourceLogs` → `scopeLogs` → `logRecords` envelope with `severityNumber` (debug=5/info=9/warn=13/error=17), `severityText`, `timeUnixNano` (string-encoded for JSON-safe 64-bit), `body.stringValue`, and OTel-typed attributes. Operator config: `{ url, serviceName, serviceVersion, resourceAttributes, auth, headers, batchSize, retry, onDrop, ... }` — same back-pressure semantics as the webhook sink (per-sink ring buffer, batched flush on size or maxBatchAgeMs, exponential-backoff retry, drop-on-overflow with operator-supplied onDrop callback). URL convention: `/v1/logs` is auto-appended when the operator passes the collector root. JSON not protobuf — operators benchmarking >100K logs/s ship the OTel Collector locally so the framework hands JSON to a sidecar that forwards via gRPC. Removed `otlp` from `DEFERRED_PROTOCOLS`. Wiki observability page documents the new sink with a `b.logStream.init` example pointing at an OTel collector. Tests cover URL resolution, attribute encoding (string / int / float / bool / array / nested object), severity mapping, round-trip via mock collector, auth header pass-through, retry on 5xx + drop on retry-exhaustion, buffer overflow, dispatcher integration. Verified 0 leaks across 291 commits via `gitleaks`.
13
15
  - **0.6.23** (2026-05-02) — v0.6.22 follow-up cleanup. The cron-repeat call site in queue-local.js was passing both `availableAt` AND `delaySeconds` (the former was the precise next-fire ms; the latter was a redundant `Math.floor((nextMs - nowMs) / 1000)` computation that only existed to work around the bug v0.6.22 fixed). Dropped — the cron repeat now passes `availableAt` alone, matching the queue's documented precedence rule. The enqueue() docstring gains a 20-line "SCHEDULING PRECEDENCE" header documenting that `opts.availableAt` wins over `opts.delaySeconds` when both are passed, why the framework chose that direction, and which callers should use which form. New round-trip preservation regression test (`testEnqueueRoundTripsAvailableAt`) covers three precise targets, the delaySeconds-only path, and the both-opts-set case — gates against any future "I'll just rederive it from the floored seconds" mistake. Audited the rest of the framework for the same `(absolute-time, relative-time)` opt-overlap shape (cache.set, session.rotate, apiKey, dualControl): queue is the only primitive carrying both forms, so a generalized `b.time.resolveTimePoint` primitive would be premature with one call site.
package/README.md CHANGED
@@ -41,7 +41,7 @@ var b = require("@blamejs/core");
41
41
 
42
42
  The framework bundles the surface a typical Node app reaches for. Every primitive listed is callable today; nothing is a stub.
43
43
 
44
- - **Data layer** — SQLite with sealed-by-default columns (`b.db`), migrations, seeders, atomic-file writes; bring-your-own external Postgres / MySQL / etc. with pool tuning + role-aware connect + read-replica routing (`b.externalDb`); declarative role-narrowed views and Postgres row-level-security migrations (`b.db.declareView`, `b.db.declareRowPolicy`); S3 / R2 / B2 / GCS / Azure object store with multipart upload + SSE + bucket ops (`b.storage`, `b.objectStore`); durable queue with priority + cron + flows (`b.queue`, `b.jobs`); cluster-shared cache (`b.cache`).
44
+ - **Data layer** — SQLite with sealed-by-default columns (`b.db`), migrations, seeders, atomic-file writes; bring-your-own external Postgres / MySQL / etc. with pool tuning + role-aware connect + read-replica routing (`b.externalDb`); declarative role-narrowed views and Postgres row-level-security migrations (`b.db.declareView`, `b.db.declareRowPolicy`); S3 / R2 / B2 / GCS / Azure object store with multipart upload + SSE + bucket ops (`b.storage`, `b.objectStore`); durable queue with priority + cron + flows on the local SQLite backend OR a shared Redis backend for multi-replica deploys (`b.queue`, `b.jobs`); cluster-shared cache (`b.cache`).
45
45
  - **Identity & access** — passwords (Argon2id) + policy primitive (NIST 800-63B / PCI-DSS 4.0 / HIPAA-AAL2 profiles, HaveIBeenPwned k-anonymity breach check, length / context / dictionary / complexity rules, rotation + history) (`b.auth.password`); passkeys (WebAuthn), TOTP, JWT (PQ-default), OAuth, sessions with optional IP / UA fingerprint drift detection + anomaly scoring, brute-force lockout (`b.auth.*`, `b.session`); RBAC + optional per-role DB binding + role-spec `requireMfa` + per-route MFA freshness window + ABAC predicate registry (`b.permissions`); API keys with rotation (`b.apiKey`); break-glass column gates with second-factor + audit (`b.breakGlass`); two-person-rule approval workflow with m-of-n quorum + cooling-off lock + approver-role gate + cancellation (`b.dualControl`).
46
46
  - **Crypto** — envelope-versioned PQC at rest (ML-KEM-1024 + P-384 hybrid, XChaCha20-Poly1305, SHAKE256), vault sealing, field-level crypto + cryptographic erasure (`b.cryptoField.eraseRow`), signed webhooks (SLH-DSA-SHAKE-256f), ECIES API encryption (`b.crypto`, `b.vault`, `b.webhook`); pure-JS mTLS CA, PQC TLS gates inbound + outbound (`b.mtlsCa`, `b.pqcGate`, `b.pqcAgent`).
47
47
  - **HTTP** — router with schema-validated routes + OpenAPI publication; full middleware stack (CSRF, CORS, rate-limit, security headers, CSP nonce, body parser, compression, SSE, request log, request-time DB role binding via `b.middleware.dbRoleFor`, in-process CIDR fence via `b.middleware.networkAllowlist`) wired by `createApp`; HTTP/1.1 + HTTP/2 outbound client with SSRF gate, scheme + userinfo + per-host (wildcard / per-method) destination allowlist, redirects, multipart, interceptors, progress, encrypted cookie jar (`b.httpClient`, `b.ssrfGuard`, `b.safeUrl`); operator-tunable network configurability — env-driven NTP / NTS (RFC 8915 authenticated time), DNS with IPv6 / DoH / DoT / cache / lookup timeout, outbound HTTP proxy (`HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`), runtime DPI trust-store CA additions, application-level heartbeats, TCP socket defaults (`b.network`).
@@ -76,8 +76,9 @@ blamejs seed run | status --db <path> --
76
76
  blamejs dev --command <cmd> [--watch <dir>...]
77
77
  blamejs api-snapshot capture | compare --file <path>
78
78
  blamejs api-key issue | revoke | list | rotate | verify --data-dir <path> --namespace <ns>
79
- blamejs audit archive | export | verify | purge --data-dir <path>
79
+ blamejs audit archive | export | verify-bundle | verify-chain | purge --data-dir <path>
80
80
  blamejs backup inspect | verify | extract --bundle <path>
81
+ blamejs restore list | inspect | apply | rollback | list-rollbacks --data-dir <path> --bundle <dir>
81
82
  blamejs mtls status | show-cert | init | issue | issue-p12 --data-dir <path>
82
83
  blamejs vault status | seal | unseal | rotate --data-dir <path>
83
84
  blamejs security assert --data-dir <path>
package/lib/cli.js CHANGED
@@ -38,14 +38,18 @@ var fs = require("node:fs");
38
38
  var os = require("node:os");
39
39
  var path = require("path");
40
40
  var apiSnapshot = require("./api-snapshot");
41
+ var auditChain = require("./audit-chain");
41
42
  var auditTools = require("./audit-tools");
43
+ var backup = require("./backup");
42
44
  var cliHelpers = require("./cli-helpers");
43
45
  var constants = require("./constants");
44
46
  var crypto = require("./crypto");
45
47
  var dev = require("./dev");
46
48
  var migrations = require("./migrations");
47
49
  var requestHelpers = require("./request-helpers");
50
+ var restore = require("./restore");
48
51
  var restoreBundle = require("./restore-bundle");
52
+ var restoreRollback = require("./restore-rollback");
49
53
  var seeders = require("./seeders");
50
54
  var vaultPassphraseOps = require("./vault/passphrase-ops");
51
55
 
@@ -512,6 +516,7 @@ var AUDIT_USAGE = [
512
516
  " archive Bundle audit rows older than --before into a verified archive",
513
517
  " export Auditor evidence bundle for a date range",
514
518
  " verify-bundle Round-trip integrity check on an archive or export bundle",
519
+ " verify-chain Walk the live audit chain end-to-end; reports tampering",
515
520
  " purge Delete live rows already captured in a verified archive",
516
521
  "",
517
522
  "Common flags:",
@@ -527,12 +532,17 @@ var AUDIT_USAGE = [
527
532
  " --to <date> Latest recordedAt (inclusive)",
528
533
  " --action <name> Restrict to a single audit action",
529
534
  "",
535
+ "verify-chain flags:",
536
+ " --db <path> SQLite database path (required)",
537
+ " --table <name> Audit table name (default audit_log)",
538
+ " --max-rows <N> Stop after walking N rows (default: walk all)",
539
+ "",
530
540
  "purge flags:",
531
541
  " --confirm REQUIRED — operator acknowledgement of destructive op",
532
542
  "",
533
543
  "Exit codes:",
534
- " 0 success",
535
- " 1 operation failed",
544
+ " 0 success (or chain verified ok)",
545
+ " 1 operation failed (or chain tampered)",
536
546
  " 2 bad invocation",
537
547
  ].join("\n");
538
548
 
@@ -561,6 +571,7 @@ async function _runAudit(args, ctx) {
561
571
  }
562
572
  var sub = args.pos[0];
563
573
  var passphrase = _resolvePassphrase(args, ctx);
574
+ // verify-chain reads the live DB, no bundle passphrase needed.
564
575
  var passRequired = sub === "archive" || sub === "export" ||
565
576
  sub === "verify-bundle" || sub === "purge";
566
577
  if (passRequired && !passphrase) {
@@ -633,6 +644,56 @@ async function _runAudit(args, ctx) {
633
644
  }
634
645
  }
635
646
 
647
+ if (sub === "verify-chain") {
648
+ var dbPathV = args.flags.db;
649
+ if (!dbPathV || dbPathV === true) {
650
+ _writeLine(ctx.stderr, "blamejs audit verify-chain: --db <path> is required");
651
+ return 2;
652
+ }
653
+ dbPathV = _resolvePath(String(dbPathV), ctx.cwd);
654
+ var tableV = args.flags.table ? String(args.flags.table) : "audit_log";
655
+ var maxRows = args.flags["max-rows"];
656
+ var maxRowsN = maxRows === undefined ? undefined : Number(maxRows);
657
+ if (maxRowsN !== undefined && (!Number.isFinite(maxRowsN) || maxRowsN < 1)) {
658
+ _writeLine(ctx.stderr, "blamejs audit verify-chain: --max-rows must be a positive integer");
659
+ return 2;
660
+ }
661
+ var dbV;
662
+ try { dbV = _openSqlite(dbPathV); }
663
+ catch (e) {
664
+ _writeLine(ctx.stderr, "blamejs audit verify-chain: cannot open db at " + dbPathV +
665
+ ": " + ((e && e.message) || String(e)));
666
+ return 1;
667
+ }
668
+ try {
669
+ var queryAllAsync = async function (sql, params) {
670
+ var stmt = dbV.prepare(sql);
671
+ return Array.isArray(params) ? stmt.all.apply(stmt, params) : stmt.all();
672
+ };
673
+ var vc = await auditChain.verifyChain(queryAllAsync, tableV,
674
+ maxRowsN ? { maxRows: maxRowsN } : {});
675
+ if (vc.ok) {
676
+ _writeLine(ctx.stdout, "OK — chain verified" +
677
+ " (table=" + vc.table +
678
+ ", rowsVerified=" + vc.rowsVerified + ")");
679
+ return 0;
680
+ }
681
+ _writeLine(ctx.stderr, "FAIL — " + vc.reason +
682
+ " (table=" + vc.table +
683
+ ", rowsVerified=" + vc.rowsVerified +
684
+ ", breakAt=" + vc.breakAt +
685
+ ", breakRowId=" + vc.breakRowId + ")");
686
+ _writeLine(ctx.stderr, " expected prevHash: " + vc.expected);
687
+ _writeLine(ctx.stderr, " actual: " + vc.actual);
688
+ return 1;
689
+ } catch (e) {
690
+ _writeLine(ctx.stderr, "blamejs audit verify-chain: " + ((e && e.message) || String(e)));
691
+ return 1;
692
+ } finally {
693
+ try { dbV.close(); } catch (_e) { /* close best-effort */ }
694
+ }
695
+ }
696
+
636
697
  if (sub === "purge") {
637
698
  var inP = _resolveOutPath(args.flags.archive || args.flags.in, ctx);
638
699
  if (!inP) {
@@ -661,6 +722,282 @@ async function _runAudit(args, ctx) {
661
722
  return 2;
662
723
  }
663
724
 
725
+ // ---- Subcommand: restore ----
726
+ //
727
+ // Operator workflow on top of b.restore: list bundles in storage,
728
+ // inspect a specific one, do a live in-place restore (with rollback
729
+ // preservation), and roll back to a previous restore point. Wraps the
730
+ // restore primitive's run / inspect / list / rollback / list-rollbacks
731
+ // surface; uses b.backup.localStorage as the storage adapter (the same
732
+ // adapter that wrote the bundles).
733
+ //
734
+ // Two ways to identify a bundle for inspect / apply:
735
+ // --bundle <dir> point at an extracted bundle directory
736
+ // (parent dir is treated as storage root,
737
+ // dir basename as bundle id — matches the
738
+ // shape `blamejs backup extract` produces)
739
+ // --storage-root <root> --bundle-id <id> use a multi-bundle root
740
+
741
+ var RESTORE_USAGE = [
742
+ "Usage: blamejs restore <subcommand> [flags]",
743
+ "",
744
+ "Subcommands:",
745
+ " list List bundles available in storage",
746
+ " inspect Read a bundle manifest summary (no live changes)",
747
+ " apply Live in-place restore with rollback preserved",
748
+ " rollback Revert the most-recent (or named) restore",
749
+ " list-rollbacks List preserved rollback points",
750
+ "",
751
+ "Common flags:",
752
+ " --data-dir <path> Live data directory (apply / rollback / list-rollbacks)",
753
+ " --storage-root <path> Directory containing bundle subdirs (list)",
754
+ " --bundle <dir> Extracted bundle directory (inspect / apply)",
755
+ " --bundle-id <id> Alternative: pass id with --storage-root",
756
+ " --rollback-root <path> Override rollback dir (default <data-dir>.rollbacks)",
757
+ " --passphrase <string> Bundle passphrase (or env BLAMEJS_BACKUP_PASSPHRASE)",
758
+ "",
759
+ "apply flags:",
760
+ " --max-pulled-bytes <N> Refuse a bundle whose pulled bytes exceed N (default 4 GiB)",
761
+ " --max-pulled-files <N> Refuse a bundle whose pulled file count exceeds N (default 100K)",
762
+ " --no-audit Suppress audit emission (default ON)",
763
+ "",
764
+ "rollback flags:",
765
+ " --rollback <pathOrId> Specific rollback point to restore (default: most recent)",
766
+ "",
767
+ "Exit codes:",
768
+ " 0 success",
769
+ " 1 operation failed",
770
+ " 2 bad invocation",
771
+ ].join("\n");
772
+
773
+ // Resolve {storageRoot, bundleId} from either --bundle <dir> OR
774
+ // --storage-root <root> --bundle-id <id>. Returns null + writes an
775
+ // error on the report when neither shape works.
776
+ function _resolveRestoreBundleSelector(args, ctx, report, requireBundle) {
777
+ var bundleFlag = args.flags.bundle;
778
+ var storageRootFlag = args.flags["storage-root"];
779
+ var bundleIdFlag = args.flags["bundle-id"];
780
+ if (bundleFlag && bundleFlag !== true) {
781
+ var bundlePath = _resolvePath(String(bundleFlag), ctx.cwd);
782
+ return {
783
+ storageRoot: path.dirname(bundlePath),
784
+ bundleId: path.basename(bundlePath),
785
+ };
786
+ }
787
+ if (storageRootFlag && storageRootFlag !== true) {
788
+ var sr = _resolvePath(String(storageRootFlag), ctx.cwd);
789
+ if (requireBundle) {
790
+ if (!bundleIdFlag || bundleIdFlag === true) {
791
+ report.error("--bundle-id is required when using --storage-root", 2);
792
+ return null;
793
+ }
794
+ return { storageRoot: sr, bundleId: String(bundleIdFlag) };
795
+ }
796
+ return { storageRoot: sr, bundleId: null };
797
+ }
798
+ if (requireBundle) {
799
+ report.error("--bundle <dir> OR --storage-root <root> --bundle-id <id> is required", 2);
800
+ } else {
801
+ report.error("--storage-root <root> is required", 2);
802
+ }
803
+ return null;
804
+ }
805
+
806
+ async function _runRestore(args, ctx) {
807
+ if (args.pos.length === 0) {
808
+ return cliHelpers.makeReporter(ctx, "blamejs restore").usage(RESTORE_USAGE);
809
+ }
810
+ var sub = args.pos[0];
811
+ var report = cliHelpers.makeReporter(ctx, "blamejs restore " + sub);
812
+ if (sub === "help" || args.flags.help || args.flags.h) {
813
+ return report.helpStdout(RESTORE_USAGE);
814
+ }
815
+ if (["list", "inspect", "apply", "rollback", "list-rollbacks"].indexOf(sub) === -1) {
816
+ cliHelpers.makeReporter(ctx, "blamejs restore").error("unknown subcommand '" + sub + "'", 2);
817
+ return cliHelpers.makeReporter(ctx, "blamejs restore").usage(RESTORE_USAGE);
818
+ }
819
+
820
+ // list / inspect / apply all need a storage root + (for inspect/apply) a bundle id.
821
+ // rollback / list-rollbacks only need data-dir.
822
+ var dataDirFlag = args.flags["data-dir"];
823
+ function _requireDataDir() {
824
+ if (!dataDirFlag || dataDirFlag === true) {
825
+ report.error("--data-dir <path> is required", 2);
826
+ return null;
827
+ }
828
+ return _resolvePath(String(dataDirFlag), ctx.cwd);
829
+ }
830
+
831
+ if (sub === "list") {
832
+ var sel = _resolveRestoreBundleSelector(args, ctx, report, false);
833
+ if (!sel) return 2;
834
+ try {
835
+ var storage = backup.localStorage({ root: sel.storageRoot });
836
+ var bundles = await storage.listBundles();
837
+ if (bundles.length === 0) {
838
+ report.write("no bundles in " + sel.storageRoot);
839
+ return report.ok();
840
+ }
841
+ report.write("bundles in " + sel.storageRoot + ": " + bundles.length);
842
+ for (var i = 0; i < bundles.length; i++) {
843
+ var b = bundles[i];
844
+ report.write(" " + b.bundleId +
845
+ " size=" + (b.size != null ? b.size + "B" : "?") +
846
+ " createdAt=" + (b.createdAt || "?"));
847
+ }
848
+ return report.ok();
849
+ } catch (e) {
850
+ return report.error((e && e.message) || String(e));
851
+ }
852
+ }
853
+
854
+ if (sub === "inspect") {
855
+ var selI = _resolveRestoreBundleSelector(args, ctx, report, true);
856
+ if (!selI) return 2;
857
+ try {
858
+ var storageI = backup.localStorage({ root: selI.storageRoot });
859
+ // restore.create needs a passphrase + dataDir even for inspect because
860
+ // its closure captures them; pass placeholders since inspect doesn't
861
+ // touch them.
862
+ var rI = restore.create({
863
+ dataDir: path.join(os.tmpdir(), "blamejs-restore-inspect-noop"),
864
+ storage: storageI,
865
+ passphrase: "inspect-only-not-used",
866
+ audit: false,
867
+ });
868
+ var manifest = await rI.inspect(selI.bundleId);
869
+ var totalBytesI = 0;
870
+ for (var ix = 0; ix < manifest.files.length; ix++) {
871
+ totalBytesI += manifest.files[ix].encryptedSize || 0;
872
+ }
873
+ report.write("bundle: " + selI.bundleId);
874
+ report.write("storage root: " + selI.storageRoot);
875
+ report.write("manifest: v" + (manifest.manifestVersion || manifest.version || "unknown"));
876
+ report.write("created: " + (manifest.createdAt || "unknown"));
877
+ report.write("files: " + manifest.files.length);
878
+ report.write("encrypted size: " + totalBytesI + " bytes");
879
+ return report.ok();
880
+ } catch (e) {
881
+ return report.error((e && e.message) || String(e));
882
+ }
883
+ }
884
+
885
+ if (sub === "apply") {
886
+ var dd = _requireDataDir();
887
+ if (!dd) return 2;
888
+ var selA = _resolveRestoreBundleSelector(args, ctx, report, true);
889
+ if (!selA) return 2;
890
+ var pp = cliHelpers.resolvePassphrase(args, ctx, {
891
+ flag: "passphrase", envVar: "BLAMEJS_BACKUP_PASSPHRASE",
892
+ });
893
+ if (!pp) {
894
+ return report.error("--passphrase or BLAMEJS_BACKUP_PASSPHRASE is required", 2);
895
+ }
896
+ var rollbackRootA = args.flags["rollback-root"]
897
+ ? _resolvePath(String(args.flags["rollback-root"]), ctx.cwd) : undefined;
898
+ var maxBytes = args.flags["max-pulled-bytes"];
899
+ var maxFiles = args.flags["max-pulled-files"];
900
+ if (maxBytes !== undefined && (!Number.isFinite(Number(maxBytes)) || Number(maxBytes) <= 0)) {
901
+ return report.error("--max-pulled-bytes must be a positive number", 2);
902
+ }
903
+ if (maxFiles !== undefined && (!Number.isFinite(Number(maxFiles)) || Number(maxFiles) <= 0)) {
904
+ return report.error("--max-pulled-files must be a positive number", 2);
905
+ }
906
+ try {
907
+ var storageA = backup.localStorage({ root: selA.storageRoot });
908
+ var rA = restore.create({
909
+ dataDir: dd,
910
+ storage: storageA,
911
+ passphrase: pp,
912
+ rollbackRoot: rollbackRootA,
913
+ audit: args.flags["no-audit"] !== true,
914
+ maxPulledBytes: maxBytes !== undefined ? Number(maxBytes) : undefined,
915
+ maxPulledFiles: maxFiles !== undefined ? Number(maxFiles) : undefined,
916
+ });
917
+ var summary = await rA.run({ bundleId: selA.bundleId });
918
+ report.write("OK — restored");
919
+ report.write(" bundle: " + summary.bundleId);
920
+ report.write(" files: " + summary.fileCount);
921
+ report.write(" bytes: " + summary.totalBytes);
922
+ report.write(" rollback at: " + summary.rollbackPath);
923
+ report.write(" duration ms: " + summary.durationMs);
924
+ report.write("");
925
+ report.write("Stop and start the app fresh against " + dd + " to pick up the restored state.");
926
+ report.write("Roll back with: blamejs restore rollback --data-dir " + dd);
927
+ return report.ok();
928
+ } catch (e) {
929
+ return report.error((e && e.message) || String(e));
930
+ }
931
+ }
932
+
933
+ if (sub === "rollback") {
934
+ var ddR = _requireDataDir();
935
+ if (!ddR) return 2;
936
+ var rollbackRootR = args.flags["rollback-root"]
937
+ ? _resolvePath(String(args.flags["rollback-root"]), ctx.cwd)
938
+ : (ddR + ".rollbacks");
939
+ var rollbackTarget = args.flags.rollback;
940
+ var targetPath = null;
941
+ if (rollbackTarget && rollbackTarget !== true) {
942
+ // operator can pass either a full path or just the basename inside rollback-root
943
+ var rt = String(rollbackTarget);
944
+ targetPath = path.isAbsolute(rt) ? rt : path.resolve(rollbackRootR, rt);
945
+ } else {
946
+ // Default to most-recent rollback point (mirrors restore.create().rollback()).
947
+ var ptsR;
948
+ try { ptsR = restoreRollback.list({ rollbackRoot: rollbackRootR }); }
949
+ catch (e) {
950
+ return report.error("listing rollbacks at " + rollbackRootR + ": " + ((e && e.message) || String(e)));
951
+ }
952
+ if (!ptsR || ptsR.length === 0) {
953
+ return report.error("no rollback points at " + rollbackRootR + " — pass --rollback <pathOrId> explicitly", 2);
954
+ }
955
+ targetPath = ptsR[0].rollbackPath;
956
+ }
957
+ try {
958
+ var rR = await restoreRollback.rollback({
959
+ dataDir: ddR,
960
+ rollbackPath: targetPath,
961
+ rollbackRoot: rollbackRootR,
962
+ });
963
+ report.write("OK — rolled back");
964
+ report.write(" data dir: " + ddR);
965
+ report.write(" used: " + (targetPath || "most-recent rollback point"));
966
+ report.write(" discarded at: " + (rR.discardedAt || "(unknown)"));
967
+ return report.ok();
968
+ } catch (e) {
969
+ return report.error((e && e.message) || String(e));
970
+ }
971
+ }
972
+
973
+ if (sub === "list-rollbacks") {
974
+ var ddL = _requireDataDir();
975
+ if (!ddL) return 2;
976
+ var rollbackRootL = args.flags["rollback-root"]
977
+ ? _resolvePath(String(args.flags["rollback-root"]), ctx.cwd)
978
+ : (ddL + ".rollbacks");
979
+ try {
980
+ var pts = restoreRollback.list({ rollbackRoot: rollbackRootL });
981
+ if (pts.length === 0) {
982
+ report.write("no rollback points at " + rollbackRootL);
983
+ return report.ok();
984
+ }
985
+ report.write("rollback points at " + rollbackRootL + ": " + pts.length);
986
+ for (var p = 0; p < pts.length; p++) {
987
+ var pt = pts[p];
988
+ report.write(" " + (pt.rollbackPath || pt) +
989
+ (pt.recordedAt ? " recordedAt=" + pt.recordedAt : "") +
990
+ (pt.bundleId ? " bundleId=" + pt.bundleId : ""));
991
+ }
992
+ return report.ok();
993
+ } catch (e) {
994
+ return report.error((e && e.message) || String(e));
995
+ }
996
+ }
997
+
998
+ return 2;
999
+ }
1000
+
664
1001
  // ---- Top-level help ----
665
1002
 
666
1003
  // ---- Subcommand: api-key ----
@@ -1860,6 +2197,7 @@ var TOP_USAGE = [
1860
2197
  " api-key Issue / revoke / list / rotate / verify API keys for a namespace",
1861
2198
  " audit Operator tooling on top of the audit chain (archive / export / verify / purge)",
1862
2199
  " backup Inspect / verify / extract a backup bundle from disk",
2200
+ " restore Live in-place restore from a bundle (list / inspect / apply / rollback / list-rollbacks)",
1863
2201
  " mtls Inspect or generate the in-box mTLS CA + leaf certs (status / show-cert / init / issue / issue-p12)",
1864
2202
  " vault Seal / unseal / rotate the on-disk vault keypair (plaintext ↔ wrapped)",
1865
2203
  " security Run b.security.assertProduction against the live framework",
@@ -1919,6 +2257,7 @@ async function main(argv, opts) {
1919
2257
  if (subTopic === "api-key") { _writeLine(ctx.stdout, API_KEY_USAGE); return 0; }
1920
2258
  if (subTopic === "audit") { _writeLine(ctx.stdout, AUDIT_USAGE); return 0; }
1921
2259
  if (subTopic === "backup") { _writeLine(ctx.stdout, BACKUP_USAGE); return 0; }
2260
+ if (subTopic === "restore") { _writeLine(ctx.stdout, RESTORE_USAGE); return 0; }
1922
2261
  if (subTopic === "mtls") { _writeLine(ctx.stdout, MTLS_USAGE); return 0; }
1923
2262
  if (subTopic === "vault") { _writeLine(ctx.stdout, VAULT_USAGE); return 0; }
1924
2263
  if (subTopic === "security") { _writeLine(ctx.stdout, SECURITY_USAGE); return 0; }
@@ -1940,6 +2279,7 @@ async function main(argv, opts) {
1940
2279
  if (cmd === "api-key") return await _runApiKey(rest, ctx);
1941
2280
  if (cmd === "audit") return await _runAudit(rest, ctx);
1942
2281
  if (cmd === "backup") return await _runBackup(rest, ctx);
2282
+ if (cmd === "restore") return await _runRestore(rest, ctx);
1943
2283
  if (cmd === "mtls") return await _runMtls(rest, ctx);
1944
2284
  if (cmd === "vault") return await _runVault(rest, ctx);
1945
2285
  if (cmd === "security") return await _runSecurity(rest, ctx);
@@ -1966,6 +2306,7 @@ module.exports = {
1966
2306
  API_KEY_USAGE: API_KEY_USAGE,
1967
2307
  AUDIT_USAGE: AUDIT_USAGE,
1968
2308
  BACKUP_USAGE: BACKUP_USAGE,
2309
+ RESTORE_USAGE: RESTORE_USAGE,
1969
2310
  MTLS_USAGE: MTLS_USAGE,
1970
2311
  VAULT_USAGE: VAULT_USAGE,
1971
2312
  };
@@ -118,6 +118,13 @@ function defineClass(name, opts) {
118
118
  var ObjectStoreError = defineClass("ObjectStoreError", { withStatusCode: true });
119
119
  var LogStreamError = defineClass("LogStreamError", { withStatusCode: true });
120
120
  var QueueError = defineClass("QueueError");
121
+ // RedisError covers transport (CONNECT/CONNECT_TIMEOUT/SOCKET/WRITE),
122
+ // protocol parsing (PROTOCOL/BAD_URL/BAD_OPTS), command-level
123
+ // (REDIS_REPLY/COMMAND_TIMEOUT), and lifecycle (CLOSED/RECONNECT_GAVE_UP).
124
+ // Transient by default — operators wrap calls in retry/breaker. Bad-opts
125
+ // and bad-URL paths surface as alwaysPermanent code names so retry sees
126
+ // them and skips immediately rather than hammering a misconfig.
127
+ var RedisError = defineClass("RedisError");
121
128
  var ExternalDbError = defineClass("ExternalDbError");
122
129
  var ClusterError = defineClass("ClusterError");
123
130
  var ClusterProviderError = defineClass("ClusterProviderError");
@@ -173,6 +180,7 @@ module.exports = {
173
180
  ObjectStoreError: ObjectStoreError,
174
181
  LogStreamError: LogStreamError,
175
182
  QueueError: QueueError,
183
+ RedisError: RedisError,
176
184
  ExternalDbError: ExternalDbError,
177
185
  ClusterError: ClusterError,
178
186
  ClusterProviderError: ClusterProviderError,