@blamejs/core 0.8.13 → 0.8.16

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.
@@ -0,0 +1,283 @@
1
+ "use strict";
2
+ /**
3
+ * vault/seal-pem-file — seal a PEM file at rest with file-watch auto-
4
+ * reseal.
5
+ *
6
+ * Operator workflow this primitive solves: ACME / Let's Encrypt
7
+ * renewals run on a 30-60 day cadence, write fresh certbot output to
8
+ * `/etc/letsencrypt/live/<domain>/privkey.pem`, and signal the
9
+ * application to reload. The fresh PEM lives unencrypted on disk
10
+ * between the renewal write and the next operator-driven re-seal.
11
+ * Auto-reseal closes that window: every renewal writes the plaintext
12
+ * PEM, the framework's watcher sees the mtime change, re-seals on the
13
+ * spot, and the in-process key material rotates without human
14
+ * intervention.
15
+ *
16
+ * Surface:
17
+ *
18
+ * var watcher = b.vault.sealPemFile({
19
+ * source: "/etc/letsencrypt/live/example.com/privkey.pem",
20
+ * destination: "/var/lib/blamejs/server.key.sealed",
21
+ * audit: true, // default
22
+ * pollInterval: b.constants.TIME.seconds(2), // fs.watchFile cadence
23
+ * onResealed: function (info) { ... }, // { srcPath, destPath, bytes,
24
+ * resealedAt, generation }
25
+ * onError: function (err) { ... }, // sealing failed
26
+ * });
27
+ * // watcher.stop()
28
+ * // watcher.generation — monotonically increases per reseal
29
+ * // watcher.lastResealedAt — Unix-ms of most recent successful reseal
30
+ * // watcher.lastError — most recent failure, or null
31
+ *
32
+ * Crash-safe write protocol:
33
+ *
34
+ * 1. Write `<destination>.tmp` with mode 0o600, fsync.
35
+ * 2. Create `<destination>.rewriting` marker (operator-visible).
36
+ * 3. Rename `<destination>.tmp` → `<destination>` (atomic on POSIX).
37
+ * 4. Remove `<destination>.rewriting` marker.
38
+ *
39
+ * If the framework crashes between steps 2 and 4, the marker remains
40
+ * on disk and the next sealPemFile() call detects it. Recovery: the
41
+ * sealedPath is either complete (rename happened) or still .tmp
42
+ * (rename did not happen). The recovery routine re-runs the seal from
43
+ * source — idempotent because the source PEM is the source of truth.
44
+ *
45
+ * fs.watchFile semantics:
46
+ *
47
+ * Node's fs.watchFile is a polling stat() loop with the configured
48
+ * pollInterval. It fires on mtime / size change. fs.watch (the
49
+ * inotify / kqueue backend) is more efficient but inconsistent across
50
+ * platforms — single rename events surface as multiple change events
51
+ * on Linux (events fire on the directory entry, the file, and the
52
+ * inode), and not at all on macOS for renamed-into files. Polling
53
+ * with watchFile is consistent everywhere and the latency cost (one
54
+ * pollInterval) is acceptable for renewal cadences measured in days.
55
+ */
56
+
57
+ var fs = require("fs");
58
+ var path = require("path");
59
+ var atomicFile = require("../atomic-file");
60
+ var C = require("../constants");
61
+ var lazyRequire = require("../lazy-require");
62
+ var validateOpts = require("../validate-opts");
63
+ var { defineClass } = require("../framework-error");
64
+ var { boot } = require("../log");
65
+
66
+ var vault = lazyRequire(function () { return require("./index"); });
67
+ var audit = lazyRequire(function () { return require("../audit"); });
68
+
69
+ var log = boot("vault-seal-pem");
70
+
71
+ var SealPemFileError = defineClass("SealPemFileError", { alwaysPermanent: true });
72
+
73
+ // Default poll cadence balances latency against syscall pressure.
74
+ // At 2s, ACME renewals (which happen every ~60 days) experience a
75
+ // 2-second worst-case re-seal latency — negligible against the
76
+ // renewal cadence. Operators with sub-second-sensitive use cases
77
+ // override via opts.pollInterval.
78
+ var DEFAULT_POLL_MS = C.TIME.seconds(2);
79
+
80
+ function sealPemFile(opts) {
81
+ opts = opts || {};
82
+ validateOpts(opts, [
83
+ "source", "destination", "audit", "pollInterval",
84
+ "onResealed", "onError",
85
+ ], "vault.sealPemFile");
86
+
87
+ validateOpts.requireNonEmptyString(opts.source,
88
+ "vault.sealPemFile: source must be a non-empty path",
89
+ SealPemFileError, "seal-pem-file/bad-source");
90
+ validateOpts.requireNonEmptyString(opts.destination,
91
+ "vault.sealPemFile: destination must be a non-empty path",
92
+ SealPemFileError, "seal-pem-file/bad-destination");
93
+ if (opts.source === opts.destination) {
94
+ throw new SealPemFileError("seal-pem-file/same-path",
95
+ "vault.sealPemFile: source and destination must differ — sealing in place would overwrite the plaintext");
96
+ }
97
+ validateOpts.optionalPositiveFinite(opts.pollInterval,
98
+ "vault.sealPemFile: pollInterval", SealPemFileError, "seal-pem-file/bad-poll-interval");
99
+ validateOpts.optionalFunction(opts.onResealed,
100
+ "vault.sealPemFile: onResealed", SealPemFileError, "seal-pem-file/bad-on-resealed");
101
+ validateOpts.optionalFunction(opts.onError,
102
+ "vault.sealPemFile: onError", SealPemFileError, "seal-pem-file/bad-on-error");
103
+
104
+ var source = opts.source;
105
+ var destination = opts.destination;
106
+ // optionalPositiveFinite above already threw on a bad-shaped opts.pollInterval;
107
+ // here only undefined / null / valid-positive-finite remain.
108
+ var pollInterval = opts.pollInterval || DEFAULT_POLL_MS;
109
+ var auditOn = opts.audit !== false;
110
+ var onResealed = typeof opts.onResealed === "function" ? opts.onResealed : null;
111
+ var onError = typeof opts.onError === "function" ? opts.onError : null;
112
+
113
+ var generation = 0;
114
+ var lastResealedAt = null;
115
+ var lastError = null;
116
+ var watching = false;
117
+ var listener = null;
118
+ var resealing = false;
119
+ var pendingMtime = null;
120
+
121
+ function _emitAudit(action, outcome, metadata) {
122
+ if (!auditOn) return;
123
+ try {
124
+ audit().safeEmit({
125
+ action: "vault.seal_pem_file." + action,
126
+ outcome: outcome,
127
+ metadata: metadata || {},
128
+ });
129
+ } catch (_e) { /* drop-silent */ }
130
+ }
131
+
132
+ function _writeSealed(plaintextBytes) {
133
+ // atomicFile.writeSync already does the .tmp + fsync + rename +
134
+ // fsyncDir sequence atomically. The marker is the framework's
135
+ // operator-visible crash-detection signal — created BEFORE the
136
+ // atomic rename, removed AFTER. If the framework crashes between
137
+ // marker create and marker remove, the marker remains on disk
138
+ // and _recoverIfNeeded() detects it on the next start().
139
+ var markerPath = destination + ".rewriting";
140
+ atomicFile.ensureDir(path.dirname(destination));
141
+ var sealed = vault().seal(plaintextBytes);
142
+ fs.writeFileSync(markerPath, String(Date.now()), { mode: 0o600 }); // allow:raw-byte-literal — POSIX file mode
143
+ try {
144
+ atomicFile.writeSync(destination, sealed, { fileMode: 0o600 }); // allow:raw-byte-literal — POSIX file mode
145
+ } catch (e) {
146
+ try { fs.unlinkSync(markerPath); } catch (_e) { /* best-effort */ }
147
+ throw e;
148
+ }
149
+ try { fs.unlinkSync(markerPath); } catch (_e) { /* marker cleanup best-effort */ }
150
+ }
151
+
152
+ function _resealNow() {
153
+ if (resealing) return;
154
+ resealing = true;
155
+ try {
156
+ var plaintext;
157
+ try { plaintext = fs.readFileSync(source); }
158
+ catch (e) {
159
+ var err = new SealPemFileError("seal-pem-file/source-read-failed",
160
+ "vault.sealPemFile: failed to read source '" + source + "': " + e.message);
161
+ lastError = err;
162
+ _emitAudit("read_failed", "failure", { source: source, error: e.message });
163
+ if (onError) { try { onError(err); } catch (_e) { /* drop-silent */ } }
164
+ return;
165
+ }
166
+ try {
167
+ _writeSealed(plaintext);
168
+ } catch (e2) {
169
+ var err2 = new SealPemFileError("seal-pem-file/seal-failed",
170
+ "vault.sealPemFile: failed to seal '" + source + "' to '" + destination + "': " + e2.message);
171
+ lastError = err2;
172
+ _emitAudit("seal_failed", "failure", {
173
+ source: source, destination: destination, error: e2.message,
174
+ });
175
+ if (onError) { try { onError(err2); } catch (_e) { /* drop-silent */ } }
176
+ return;
177
+ }
178
+ generation += 1;
179
+ lastResealedAt = Date.now();
180
+ lastError = null;
181
+ _emitAudit("resealed", "success", {
182
+ source: source,
183
+ destination: destination,
184
+ bytes: plaintext.length,
185
+ generation: generation,
186
+ });
187
+ if (onResealed) {
188
+ try {
189
+ onResealed({
190
+ srcPath: source,
191
+ destPath: destination,
192
+ bytes: plaintext.length,
193
+ resealedAt: lastResealedAt,
194
+ generation: generation,
195
+ });
196
+ } catch (_e) { /* drop-silent */ }
197
+ }
198
+ } finally {
199
+ resealing = false;
200
+ if (pendingMtime) {
201
+ // A change event arrived while we were resealing — reseal again
202
+ // so the latest source bytes land. Single-flight: only one
203
+ // pending reseal is queued.
204
+ pendingMtime = null;
205
+ setImmediate(_resealNow);
206
+ }
207
+ }
208
+ }
209
+
210
+ // Recover from a prior crash: if the marker is present, the previous
211
+ // reseal was interrupted. Re-seal from source idempotently.
212
+ function _recoverIfNeeded() {
213
+ var markerPath = destination + ".rewriting";
214
+ if (fs.existsSync(markerPath)) {
215
+ log.info("vault.sealPemFile: recovery — marker '" + markerPath +
216
+ "' present from prior crashed reseal; re-sealing from source");
217
+ _emitAudit("recovery_started", "success", {
218
+ source: source, destination: destination,
219
+ });
220
+ // Don't unlink the marker yet — _writeSealed will rewrite it
221
+ // and remove it as part of the normal sequence.
222
+ }
223
+ }
224
+
225
+ function start() {
226
+ if (watching) return;
227
+ _recoverIfNeeded();
228
+ // Initial seal — operator gets the destination populated on
229
+ // start() even if the source's mtime never changes.
230
+ _resealNow();
231
+ listener = function (curr, prev) {
232
+ // mtime change OR the source appearing for the first time.
233
+ if (curr.mtimeMs !== prev.mtimeMs || curr.size !== prev.size) {
234
+ if (resealing) { pendingMtime = curr.mtimeMs; return; }
235
+ _resealNow();
236
+ }
237
+ };
238
+ fs.watchFile(source, { persistent: false, interval: pollInterval }, listener);
239
+ watching = true;
240
+ _emitAudit("watch_started", "success", {
241
+ source: source,
242
+ destination: destination,
243
+ pollInterval: pollInterval,
244
+ });
245
+ }
246
+
247
+ function stop() {
248
+ if (!watching) return;
249
+ fs.unwatchFile(source, listener);
250
+ listener = null;
251
+ watching = false;
252
+ _emitAudit("watch_stopped", "success", {
253
+ source: source,
254
+ destination: destination,
255
+ generation: generation,
256
+ });
257
+ }
258
+
259
+ // Auto-start so the operator's `var watcher = sealPemFile(...)` call
260
+ // produces a populated destination immediately. Operators wiring it
261
+ // into a deferred lifecycle override by passing autoStart: false —
262
+ // not yet a frequent enough use case to surface, opens cleanly when
263
+ // the first operator surfaces it.
264
+ start();
265
+
266
+ return {
267
+ stop: stop,
268
+ get generation() { return generation; },
269
+ get lastResealedAt() { return lastResealedAt; },
270
+ get lastError() { return lastError; },
271
+ get watching() { return watching; },
272
+ // Force a reseal — useful for tests and operator-triggered rotations
273
+ // (e.g. after a manual ACME renewal). Idempotent: produces an
274
+ // updated destination from the current source bytes.
275
+ forceReseal: _resealNow,
276
+ };
277
+ }
278
+
279
+ module.exports = {
280
+ sealPemFile: sealPemFile,
281
+ SealPemFileError: SealPemFileError,
282
+ DEFAULT_POLL_MS: DEFAULT_POLL_MS,
283
+ };
package/lib/websocket.js CHANGED
@@ -725,6 +725,21 @@ class WebSocketConnection extends EventEmitter {
725
725
  return this._abort(CLOSE_PROTOCOL_ERROR, "RSV1 on continuation frame (must be on start)");
726
726
  }
727
727
 
728
+ // RFC 6455 §5.5 — control frames (opcodes >= 0x8: CLOSE/PING/PONG)
729
+ // MUST have payload length ≤ 125 and MUST NOT be fragmented.
730
+ // Without the cap an attacker can send a 1 MiB PING and we echo it
731
+ // verbatim as PONG — a 2× outbound-bandwidth amplification DoS.
732
+ if (frame.opcode >= 0x8) {
733
+ if (frame.payload.length > 125) {
734
+ return this._abort(CLOSE_PROTOCOL_ERROR,
735
+ "control frame payload exceeds 125 bytes (RFC 6455 §5.5)");
736
+ }
737
+ if (!frame.fin) {
738
+ return this._abort(CLOSE_PROTOCOL_ERROR,
739
+ "control frame must not be fragmented (RFC 6455 §5.5)");
740
+ }
741
+ }
742
+
728
743
  if (frame.opcode === OPCODE_CONTINUATION) {
729
744
  if (this._fragOpcode === null) {
730
745
  return this._abort(CLOSE_PROTOCOL_ERROR, "continuation without start");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.8.13",
3
+ "version": "0.8.16",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
@@ -54,7 +54,7 @@
54
54
  "owns-its-stack"
55
55
  ],
56
56
  "engines": {
57
- "node": ">=24.0.0"
57
+ "node": ">=24.4.0"
58
58
  },
59
59
  "files": [
60
60
  "index.js",
@@ -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:52762d7e-86ab-4e69-9636-eacc1acc2d2d",
5
+ "serialNumber": "urn:uuid:9d91dfe3-4449-4315-ac6c-d78f5dd92d0c",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-07T04:52:07.494Z",
8
+ "timestamp": "2026-05-07T07:18:43.056Z",
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.8.13",
22
+ "bom-ref": "@blamejs/core@0.8.16",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.8.13",
25
+ "version": "0.8.16",
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.8.13",
29
+ "purl": "pkg:npm/%40blamejs/core@0.8.16",
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.8.13",
57
+ "ref": "@blamejs/core@0.8.16",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]