@blamejs/core 0.6.24 → 0.6.26

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.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.
12
+ - **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).
11
13
  - **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`.
12
14
  - **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.
13
15
  - **0.6.22** (2026-05-02) — `b.queue.enqueue({ availableAt })` is now honoured. Previously the local-protocol enqueue() ignored opts.availableAt entirely, recomputing from `Date.now() + delaySeconds*1000`. The cron-repeat path passes both fields (the exact next-fire ms in availableAt + the floored seconds in delaySeconds), and the enqueue's recomputation lost sub-second precision plus drifted on the internal clock-vs-caller delta. Symptoms: cron-scheduled jobs landed up to 999ms off the intended boundary; the queue-flow-repeat smoke test was intermittently flaky on slow CI runners (caught by ubuntu-latest on the v0.6.21 commit). Fix: enqueue() honours opts.availableAt directly when finite; falls back to delaySeconds-based shorthand otherwise. Operators relying on `enqueue({ availableAt: T })` for non-cron scheduled jobs (e.g. "deliver this notification at exactly 09:00 tomorrow") now get the requested time instead of nowMs+0.
package/README.md CHANGED
@@ -47,7 +47,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
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`).
48
48
  - **Defensive parsers** — `b.safeJson`, `b.safeBuffer`, `b.safeSql`, `b.safeSchema`, `b.parsers` (XML / TOML / YAML / .env), `b.config` (schema-validated env), `b.fileType` magic-byte content classification with deny-on-upload categories (image / document / archive / executable / etc.).
49
49
  - **Communication** — WebSockets with channel/room fan-out across cluster replicas (`b.websocket`, `b.websocketChannels`); mail with multipart + attachments + DKIM + calendar invites + bounce intake (`b.mail`, `b.mailBounce`); generic notification dispatcher with operator-supplied transports (`b.notify`).
50
- - **Observability** — tamper-evident audit chain with SLH-DSA-signed checkpoints, metrics, tracing (OTel pass-through when wired), PII redaction, log-stream sinks (local file rotation, generic webhook, OTLP/HTTP-JSON to an OTel collector), OTLP/HTTP-JSON exporter for traces + metrics (`b.audit`, `b.metrics`, `b.tracing`, `b.redact`, `b.logStream`, `b.otelExport`); operator-callable boot-time security policy assertions (`b.security.assertProduction`) and tamper-evident config-baseline drift detection signed with the audit-signing key (`b.configDrift`).
50
+ - **Observability** — tamper-evident audit chain with SLH-DSA-signed checkpoints, metrics, tracing (OTel pass-through when wired), PII redaction, log-stream sinks (local file rotation, generic webhook, OTLP/HTTP-JSON to an OTel collector, AWS CloudWatch Logs via SigV4), OTLP/HTTP-JSON exporter for traces + metrics (`b.audit`, `b.metrics`, `b.tracing`, `b.redact`, `b.logStream`, `b.otelExport`); operator-callable boot-time security policy assertions (`b.security.assertProduction`) and tamper-evident config-baseline drift detection signed with the audit-signing key (`b.configDrift`).
51
51
  - **i18n** — CLDR plural rules, Accept-Language negotiation, Intl formatters, RTL (`b.i18n`).
52
52
  - **Format helpers** — RFC 4180 CSV with Excel formula-injection prevention (`b.csv`), RFC 9562 UUID v4 + v7 (`b.uuid`), URL-safe slugs (`b.slug`), TZ-aware datetime (`b.time`), ZIP creation (`b.archive`), HMAC-signed cursor pagination (`b.pagination`), HTML form rendering + validation + CSRF (`b.forms`).
53
53
  - **Production** — cluster leader election with fenced leases over Postgres/SQLite (`b.cluster`); cron + interval scheduler that runs exactly-once globally (`b.scheduler`); retry with full-jitter backoff + circuit breaker (`b.retry`); graceful shutdown (`b.appShutdown`); NTP boot check (`b.ntpCheck`); end-to-end-encrypted backup bundles with pre-flush fail-closed mode (`b.backup`); restore with pulled-bundle footprint preflight (`b.restore`); GDPR / PCI / HIPAA-shaped retention rules with multi-stage warn → archive → erase, legal-hold exemptions, dry-run preview, cross-table cascade (`b.retention`).
@@ -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
  };
@@ -0,0 +1,309 @@
1
+ "use strict";
2
+ /**
3
+ * AWS CloudWatch Logs sink — PutLogEvents over HTTPS with SigV4.
4
+ *
5
+ * Operator config:
6
+ *
7
+ * {
8
+ * region: "us-east-1"
9
+ * accessKeyId: env("AWS_ACCESS_KEY_ID")
10
+ * secretAccessKey: env("AWS_SECRET_ACCESS_KEY")
11
+ * sessionToken: env("AWS_SESSION_TOKEN") // optional, STS creds
12
+ * logGroupName: "my-app-logs" // operator pre-creates
13
+ * logStreamName: "instance-1" // operator pre-creates
14
+ * endpoint: "https://logs.us-east-1.amazonaws.com" // optional
15
+ * batchSize: 100 // CW caps at 10K events / 1 MiB per call
16
+ * maxBatchAgeMs: C.TIME.seconds(5)
17
+ * timeoutMs: C.TIME.seconds(30)
18
+ * retry: { maxAttempts, baseDelayMs, ... }
19
+ * bufferLimit: 10000
20
+ * onDrop: function ({ reason, batch, error }) { ... }
21
+ * }
22
+ *
23
+ * Wire format (Logs_20140328 PutLogEvents — JSON-1.1 over HTTPS):
24
+ *
25
+ * POST /
26
+ * X-Amz-Target: Logs_20140328.PutLogEvents
27
+ * Content-Type: application/x-amz-json-1.1
28
+ * Authorization: AWS4-HMAC-SHA256 Credential=... SignedHeaders=... Signature=...
29
+ * Body: { logGroupName, logStreamName, logEvents: [{ timestamp, message }, ...] }
30
+ *
31
+ * AWS quirks the framework handles:
32
+ * - Events MUST be sorted by timestamp ascending — sink sorts before send.
33
+ * - Per-batch caps: 10,000 events AND <= 1 MiB total payload. Operator
34
+ * batchSize is enforced; the framework also splits batches when the
35
+ * 1 MiB ceiling is reached mid-build.
36
+ * - Per-event 256 KiB hard cap. Oversized events are dropped at emit-time
37
+ * with onDrop fired.
38
+ * - sequenceToken is optional in modern CloudWatch (post-2023). If a
39
+ * legacy account requires it, CloudWatch returns
40
+ * InvalidSequenceTokenException with the expected token; the
41
+ * framework retries with that token transparently.
42
+ * - ResourceNotFoundException -> permanent error (operator forgot to
43
+ * create the log group or stream); surfaced via onDrop with a
44
+ * clear error.
45
+ *
46
+ * SigV4 signing reuses lib/object-store/sigv4.js with service: "logs".
47
+ */
48
+ var C = require("./constants");
49
+ var nodeCrypto = require("node:crypto");
50
+ var sigv4 = require("./object-store/sigv4");
51
+ var retryHelper = require("./retry");
52
+ var { LogStreamError } = require("./framework-error");
53
+ var httpClient = require("./http-client");
54
+
55
+ var MAX_RESPONSE_BYTES = C.BYTES.mib(1);
56
+ var CW_MAX_EVENTS_PER_BATCH = 10000;
57
+ var CW_MAX_BATCH_BYTES = C.BYTES.mib(1);
58
+ var CW_MAX_EVENT_BYTES = 256 * 1024;
59
+ var CW_EVENT_OVERHEAD_BYTES = 26;
60
+
61
+ var DEFAULTS = {
62
+ batchSize: 100,
63
+ maxBatchAgeMs: C.TIME.seconds(5),
64
+ timeoutMs: C.TIME.seconds(30),
65
+ bufferLimit: 10000,
66
+ };
67
+
68
+ var _err = LogStreamError.factory;
69
+
70
+ function _resolveEndpoint(cfg) {
71
+ if (cfg.endpoint) return cfg.endpoint.replace(/\/+$/, "") + "/";
72
+ return "https://logs." + cfg.region + ".amazonaws.com/";
73
+ }
74
+
75
+ function _eventByteSize(message) {
76
+ return Buffer.byteLength(message, "utf8") + CW_EVENT_OVERHEAD_BYTES;
77
+ }
78
+
79
+ function _serializeBatch(events, cfg, sequenceToken) {
80
+ events.sort(function (a, b) { return a.timestamp - b.timestamp; });
81
+ var body = {
82
+ logGroupName: cfg.logGroupName,
83
+ logStreamName: cfg.logStreamName,
84
+ logEvents: events,
85
+ };
86
+ if (sequenceToken) body.sequenceToken = sequenceToken;
87
+ return Buffer.from(JSON.stringify(body), "utf8");
88
+ }
89
+
90
+ function _signedHeaders(cfg, body) {
91
+ var url = _resolveEndpoint(cfg);
92
+ var payloadHash = nodeCrypto.createHash("sha256").update(body).digest("hex");
93
+ var unsigned = {
94
+ "Content-Type": "application/x-amz-json-1.1",
95
+ "X-Amz-Target": "Logs_20140328.PutLogEvents",
96
+ };
97
+ var signed = sigv4.signRequest({
98
+ method: "POST",
99
+ url: url,
100
+ headers: unsigned,
101
+ payloadHash: payloadHash,
102
+ region: cfg.region,
103
+ service: "logs",
104
+ accessKeyId: cfg.accessKeyId,
105
+ secretAccessKey: cfg.secretAccessKey,
106
+ sessionToken: cfg.sessionToken || null,
107
+ });
108
+ return signed.headers;
109
+ }
110
+
111
+ function _post(cfg, body, headers) {
112
+ return httpClient.request({
113
+ method: "POST",
114
+ url: _resolveEndpoint(cfg),
115
+ headers: headers,
116
+ body: body,
117
+ idleTimeoutMs: cfg.timeoutMs,
118
+ maxResponseBytes: MAX_RESPONSE_BYTES,
119
+ errorClass: LogStreamError,
120
+ allowedProtocols: cfg.allowedProtocols,
121
+ allowInternal: cfg.allowInternal,
122
+ });
123
+ }
124
+
125
+ function _isPermanentAwsError(err) {
126
+ if (!err) return false;
127
+ var msg = err.message || "";
128
+ if (/ResourceNotFoundException/.test(msg)) return true;
129
+ if (/InvalidParameterException/.test(msg)) return true;
130
+ if (/UnrecognizedClientException/.test(msg)) return true;
131
+ if (/AccessDeniedException/.test(msg)) return true;
132
+ if (/SerializationException/.test(msg)) return true;
133
+ return false;
134
+ }
135
+
136
+ function create(config) {
137
+ if (!config || !config.region) {
138
+ throw _err("BAD_OPT", "log-stream cloudwatch requires { region }");
139
+ }
140
+ if (!config.accessKeyId || !config.secretAccessKey) {
141
+ throw _err("BAD_OPT",
142
+ "log-stream cloudwatch requires { accessKeyId, secretAccessKey } " +
143
+ "(IAM role or env-supplied STS credentials)");
144
+ }
145
+ if (!config.logGroupName || !config.logStreamName) {
146
+ throw _err("BAD_OPT",
147
+ "log-stream cloudwatch requires { logGroupName, logStreamName } " +
148
+ "(operator pre-creates both via aws logs create-log-group / create-log-stream " +
149
+ "or CDK / Terraform; the framework does NOT auto-create)");
150
+ }
151
+ var cfg = Object.assign({}, DEFAULTS, config);
152
+ var onDrop = typeof cfg.onDrop === "function" ? cfg.onDrop : null;
153
+ function _emitDrop(reason, batch, err) {
154
+ if (!onDrop) return;
155
+ try { onDrop({ reason: reason, batch: batch, error: err || null }); }
156
+ catch (_e) { /* best-effort */ }
157
+ }
158
+ var buffer = [];
159
+ var dropCount = 0;
160
+ var flushTimer = null;
161
+ var inFlight = false;
162
+ var closed = false;
163
+ var sequenceToken = null;
164
+
165
+ function _scheduleFlush() {
166
+ if (flushTimer) return;
167
+ flushTimer = setTimeout(function () { flushTimer = null; _flush(); }, cfg.maxBatchAgeMs);
168
+ flushTimer.unref();
169
+ }
170
+
171
+ function _takeBatch() {
172
+ var batch = [];
173
+ var totalBytes = 0;
174
+ while (buffer.length > 0) {
175
+ var nextEvent = buffer[0];
176
+ var size = _eventByteSize(nextEvent.message);
177
+ if (batch.length > 0 &&
178
+ (batch.length >= cfg.batchSize ||
179
+ batch.length >= CW_MAX_EVENTS_PER_BATCH ||
180
+ totalBytes + size > CW_MAX_BATCH_BYTES)) {
181
+ break;
182
+ }
183
+ batch.push(buffer.shift());
184
+ totalBytes += size;
185
+ }
186
+ return batch;
187
+ }
188
+
189
+ async function _flush() {
190
+ if (inFlight) return;
191
+ if (buffer.length === 0) return;
192
+ inFlight = true;
193
+ try {
194
+ while (buffer.length > 0 && !closed) {
195
+ var batch = _takeBatch();
196
+ if (batch.length === 0) break;
197
+ try {
198
+ await retryHelper.withRetry(function () {
199
+ return _send(batch);
200
+ }, Object.assign({
201
+ isPermanent: _isPermanentAwsError,
202
+ }, cfg.retry || {}));
203
+ } catch (e) {
204
+ dropCount += batch.length;
205
+ _emitDrop("retry-exhausted", batch, e);
206
+ break;
207
+ }
208
+ }
209
+ } finally {
210
+ inFlight = false;
211
+ if (buffer.length > 0) _scheduleFlush();
212
+ }
213
+ }
214
+
215
+ async function _send(batch) {
216
+ var body = _serializeBatch(batch, cfg, sequenceToken);
217
+ var headers = _signedHeaders(cfg, body);
218
+ var res;
219
+ try {
220
+ res = await _post(cfg, body, headers);
221
+ } catch (e) {
222
+ var match = /expected sequenceToken is:\s*(\S+)/.exec(e.message || "");
223
+ if (match) {
224
+ sequenceToken = match[1];
225
+ var retryBody = _serializeBatch(batch, cfg, sequenceToken);
226
+ var retryHeaders = _signedHeaders(cfg, retryBody);
227
+ res = await _post(cfg, retryBody, retryHeaders);
228
+ } else {
229
+ throw e;
230
+ }
231
+ }
232
+ if (res && res.body) {
233
+ try {
234
+ var parsed = JSON.parse(res.body.toString("utf8"));
235
+ if (parsed && parsed.nextSequenceToken) sequenceToken = parsed.nextSequenceToken;
236
+ } catch (_e) { /* response body not JSON; modern CW returns empty */ }
237
+ }
238
+ return res;
239
+ }
240
+
241
+ function emit(record) {
242
+ if (closed) return Promise.resolve({ accepted: false, reason: "sink closed" });
243
+ var message;
244
+ if (typeof record.message === "string") {
245
+ message = record.message;
246
+ } else {
247
+ message = JSON.stringify(record);
248
+ }
249
+ var size = _eventByteSize(message);
250
+ if (size > CW_MAX_EVENT_BYTES) {
251
+ _emitDrop("event-too-large", [{
252
+ timestamp: record.ts || Date.now(),
253
+ message: message.slice(0, 200) + "...[truncated for drop event]",
254
+ }], new Error("event exceeds 256 KiB CloudWatch hard cap (was " + size + " bytes)"));
255
+ dropCount += 1;
256
+ return Promise.resolve({ accepted: false, reason: "event too large" });
257
+ }
258
+ if (buffer.length >= cfg.bufferLimit) {
259
+ var dropped = buffer.shift();
260
+ dropCount += 1;
261
+ _emitDrop("overflow", [dropped], null);
262
+ }
263
+ buffer.push({
264
+ timestamp: record.ts || Date.now(),
265
+ message: message,
266
+ });
267
+ if (buffer.length >= cfg.batchSize) {
268
+ _flush().catch(function () {});
269
+ } else {
270
+ _scheduleFlush();
271
+ }
272
+ return Promise.resolve({ accepted: true, queued: buffer.length });
273
+ }
274
+
275
+ async function close() {
276
+ closed = true;
277
+ if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
278
+ await _flush();
279
+ }
280
+
281
+ function stats() {
282
+ return {
283
+ queued: buffer.length,
284
+ dropped: dropCount,
285
+ inFlight: inFlight,
286
+ sequenceToken: sequenceToken,
287
+ endpoint: _resolveEndpoint(cfg),
288
+ };
289
+ }
290
+
291
+ return {
292
+ protocol: "cloudwatch",
293
+ emit: emit,
294
+ close: close,
295
+ stats: stats,
296
+ flush: _flush,
297
+ };
298
+ }
299
+
300
+ module.exports = {
301
+ create: create,
302
+ _resolveEndpoint: _resolveEndpoint,
303
+ _eventByteSize: _eventByteSize,
304
+ _serializeBatch: _serializeBatch,
305
+ _isPermanentAwsError: _isPermanentAwsError,
306
+ CW_MAX_EVENTS_PER_BATCH: CW_MAX_EVENTS_PER_BATCH,
307
+ CW_MAX_BATCH_BYTES: CW_MAX_BATCH_BYTES,
308
+ CW_MAX_EVENT_BYTES: CW_MAX_EVENT_BYTES,
309
+ };
package/lib/log-stream.js CHANGED
@@ -14,12 +14,16 @@
14
14
  * Model. Operators with an OTel collector running
15
15
  * (k8s, cloud) get standard log forwarding without a
16
16
  * vendor-specific adapter.
17
+ * cloudwatch — AWS CloudWatch Logs (PutLogEvents) over HTTPS with
18
+ * SigV4. Operator pre-creates the log group + log stream;
19
+ * the framework signs and POSTs batches respecting the
20
+ * 10K-event / 1 MiB / 256 KiB-per-event AWS caps. Honors
21
+ * IAM role + STS session tokens.
17
22
  *
18
23
  * Adapters listed as deferred and surfacing a clear error when
19
24
  * selected:
20
25
  *
21
- * syslog — RFC 5424 syslog over TLS
22
- * cloudwatch — AWS CloudWatch Logs (PutLogEvents)
26
+ * syslog — RFC 5424 syslog over TLS
23
27
  *
24
28
  * Every emit goes through lib/redact.js BEFORE any sink sees it. PHI/PCI
25
29
  * never reaches operational logs even on a misconfigured field name —
@@ -42,11 +46,12 @@
42
46
  * logStream.shutdown()
43
47
  * logStream.listSinks() → [{ name, protocol, stats }]
44
48
  */
45
- var localProto = require("./log-stream-local");
46
- var webhookProto = require("./log-stream-webhook");
47
- var otlpProto = require("./log-stream-otlp");
48
- var redactor = require("./redact");
49
- var lazyRequire = require("./lazy-require");
49
+ var localProto = require("./log-stream-local");
50
+ var webhookProto = require("./log-stream-webhook");
51
+ var otlpProto = require("./log-stream-otlp");
52
+ var cloudwatchProto = require("./log-stream-cloudwatch");
53
+ var redactor = require("./redact");
54
+ var lazyRequire = require("./lazy-require");
50
55
  var protocolDispatcher = require("./protocol-dispatcher");
51
56
  var { LogStreamError } = require("./framework-error");
52
57
 
@@ -54,13 +59,13 @@ var dispatcher = protocolDispatcher.create({
54
59
  name: "log-stream",
55
60
  errorClass: LogStreamError,
56
61
  protocols: {
57
- "local": localProto,
58
- "webhook": webhookProto,
59
- "otlp": otlpProto,
62
+ "local": localProto,
63
+ "webhook": webhookProto,
64
+ "otlp": otlpProto,
65
+ "cloudwatch": cloudwatchProto,
60
66
  },
61
67
  deferred: {
62
68
  "syslog": { description: "RFC 5424 syslog over TLS" },
63
- "cloudwatch": { description: "AWS CloudWatch Logs (PutLogEvents)" },
64
69
  },
65
70
  fallbackProtocol: "local",
66
71
  });
@@ -197,6 +202,71 @@ function listSinks() {
197
202
  });
198
203
  }
199
204
 
205
+ // ---- bootFromEnv ----
206
+ //
207
+ // Operator-friendly env-driven init that mirrors b.network.bootFromEnv.
208
+ // Reads BLAMEJS_LOG_STREAM_* env vars and constructs a single-sink
209
+ // configuration matching the operator's choice. Skipped silently when
210
+ // BLAMEJS_LOG_STREAM_PROTOCOL isn't set (operators using the in-code
211
+ // init() path keep their existing wiring).
212
+ //
213
+ // Recognised env vars:
214
+ // BLAMEJS_LOG_STREAM_PROTOCOL "local" | "webhook" | "otlp" | "cloudwatch"
215
+ // BLAMEJS_LOG_STREAM_MIN_LEVEL "debug" | "info" | "warn" | "error"
216
+ //
217
+ // webhook + otlp shared:
218
+ // BLAMEJS_LOG_STREAM_URL
219
+ // BLAMEJS_LOG_STREAM_TOKEN (auth: bearer)
220
+ // otlp-only:
221
+ // BLAMEJS_LOG_STREAM_SERVICE_NAME
222
+ // BLAMEJS_LOG_STREAM_SERVICE_VERSION
223
+ // cloudwatch-only (AWS_* are standard):
224
+ // AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN
225
+ // BLAMEJS_LOG_STREAM_CLOUDWATCH_LOG_GROUP
226
+ // BLAMEJS_LOG_STREAM_CLOUDWATCH_LOG_STREAM
227
+ // local-only:
228
+ // BLAMEJS_LOG_STREAM_PATH
229
+ function bootFromEnv(opts) {
230
+ opts = opts || {};
231
+ var env = opts.env || process.env;
232
+ var proto = env.BLAMEJS_LOG_STREAM_PROTOCOL;
233
+ if (!proto) return false;
234
+ var sink = { protocol: proto };
235
+ if (proto === "webhook") {
236
+ sink.url = env.BLAMEJS_LOG_STREAM_URL;
237
+ if (env.BLAMEJS_LOG_STREAM_TOKEN) {
238
+ sink.auth = "bearer";
239
+ sink.token = env.BLAMEJS_LOG_STREAM_TOKEN;
240
+ }
241
+ } else if (proto === "otlp") {
242
+ sink.url = env.BLAMEJS_LOG_STREAM_URL;
243
+ sink.serviceName = env.BLAMEJS_LOG_STREAM_SERVICE_NAME || "blamejs";
244
+ sink.serviceVersion = env.BLAMEJS_LOG_STREAM_SERVICE_VERSION || null;
245
+ if (env.BLAMEJS_LOG_STREAM_TOKEN) {
246
+ sink.auth = "bearer";
247
+ sink.token = env.BLAMEJS_LOG_STREAM_TOKEN;
248
+ }
249
+ } else if (proto === "cloudwatch") {
250
+ sink.region = env.AWS_REGION;
251
+ sink.accessKeyId = env.AWS_ACCESS_KEY_ID;
252
+ sink.secretAccessKey = env.AWS_SECRET_ACCESS_KEY;
253
+ sink.sessionToken = env.AWS_SESSION_TOKEN || null;
254
+ sink.logGroupName = env.BLAMEJS_LOG_STREAM_CLOUDWATCH_LOG_GROUP;
255
+ sink.logStreamName = env.BLAMEJS_LOG_STREAM_CLOUDWATCH_LOG_STREAM;
256
+ } else if (proto === "local") {
257
+ sink.path = env.BLAMEJS_LOG_STREAM_PATH;
258
+ } else {
259
+ throw _err("BAD_OPT",
260
+ "BLAMEJS_LOG_STREAM_PROTOCOL='" + proto + "' is not one of " +
261
+ "local | webhook | otlp | cloudwatch (or a custom backend wired via init())");
262
+ }
263
+ init({
264
+ sinks: { primary: sink },
265
+ minLevel: env.BLAMEJS_LOG_STREAM_MIN_LEVEL || undefined,
266
+ });
267
+ return true;
268
+ }
269
+
200
270
  function _resetForTest() {
201
271
  Object.keys(sinks).forEach(function (n) {
202
272
  try { if (sinks[n].raw.close) sinks[n].raw.close(); } catch (_e) {}
@@ -209,6 +279,7 @@ function _resetForTest() {
209
279
 
210
280
  module.exports = {
211
281
  init: init,
282
+ bootFromEnv: bootFromEnv,
212
283
  emit: emit,
213
284
  debug: debug,
214
285
  info: info,
@@ -165,6 +165,12 @@ function signRequest(opts) {
165
165
  var amzDate = _formatAmzDate(date);
166
166
  var dateStamp = _formatDateStamp(date);
167
167
  var url = opts.url instanceof URL ? opts.url : new URL(opts.url);
168
+ // service defaults to s3 for back-compat — every call site predating
169
+ // v0.6.25 was object-store / S3. Other AWS services (logs, sqs, sns,
170
+ // kinesis, etc.) pass opts.service explicitly. The credentialScope
171
+ // and signing-key derivation both incorporate the service name, so
172
+ // this MUST match what the target service expects.
173
+ var service = opts.service || SERVICE;
168
174
 
169
175
  var headers = Object.assign({}, opts.headers || {});
170
176
  headers["host"] = url.host;
@@ -177,9 +183,9 @@ function signRequest(opts) {
177
183
  }
178
184
 
179
185
  var canon = canonicalRequest(opts.method, url, headers, opts.payloadHash);
180
- var credentialScope = dateStamp + "/" + opts.region + "/" + SERVICE + "/aws4_request";
186
+ var credentialScope = dateStamp + "/" + opts.region + "/" + service + "/aws4_request";
181
187
  var sts = stringToSign(amzDate, credentialScope, canon);
182
- var signingKey = deriveSigningKey(opts.secretAccessKey, dateStamp, opts.region, SERVICE);
188
+ var signingKey = deriveSigningKey(opts.secretAccessKey, dateStamp, opts.region, service);
183
189
  var signature = nodeCrypto.createHmac("sha256", signingKey).update(sts).digest("hex");
184
190
 
185
191
  var canonHeaders = canonicalHeaders(headers);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.6.24",
3
+ "version": "0.6.26",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
@@ -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:aa9c8c0c-504f-4c75-87d4-297f40e4d752",
5
+ "serialNumber": "urn:uuid:e07786d1-53d1-4554-b3d1-ff784476a034",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-02T14:22:45.058Z",
8
+ "timestamp": "2026-05-02T15:17:41.618Z",
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.6.24",
22
+ "bom-ref": "@blamejs/core@0.6.26",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.6.24",
25
+ "version": "0.6.26",
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.6.24",
29
+ "purl": "pkg:npm/%40blamejs/core@0.6.26",
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.6.24",
57
+ "ref": "@blamejs/core@0.6.26",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]