@blamejs/core 0.17.12 → 0.17.13

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/lib/daemon.js CHANGED
@@ -31,8 +31,17 @@
31
31
  * `b.appShutdown.pidLock`, which layers O_EXCL atomic-create +
32
32
  * signal-0 liveness probe + reap-on-stale.
33
33
  *
34
+ * On Windows a received signal can never reach a JS handler
35
+ * (process.kill maps it to TerminateProcess), so `stop` drives a
36
+ * cooperative stop-request sentinel (`<pidFile>.stop`) that `start`
37
+ * watches and routes into the same orchestrator, escalating to a hard
38
+ * TerminateProcess only after the stop timeout. `status` is a read-only
39
+ * liveness probe that never mutates the pidfile.
40
+ *
34
41
  * Audit events: `daemon.started` (pidFile + logFile + commandKind +
35
- * pid), `daemon.stopped` (pidFile + signal + waitMs + escalated),
42
+ * pid), `daemon.stopped` (pidFile + signal + waitMs + escalated +
43
+ * mechanism: signal|cooperative|terminate), `daemon.spawn_failed`
44
+ * (pidFile + command) when a detached child fails to launch, and
36
45
  * `daemon.stale_pid_cleaned` (pidFile + stalePid).
37
46
  *
38
47
  * @card
@@ -43,6 +52,7 @@ var nodeFs = require("node:fs");
43
52
  var nodePath = require("node:path");
44
53
  var numericBounds = require("./numeric-bounds");
45
54
  var appShutdown = require("./app-shutdown");
55
+ var pidProbe = require("./pid-probe");
46
56
  var processSpawn = require("./process-spawn");
47
57
  var safeAsync = require("./safe-async");
48
58
  var atomicFile = require("./atomic-file");
@@ -62,27 +72,23 @@ var DEFAULT_STOP_TIMEOUT_MS = C.TIME.seconds(30);
62
72
  var DEFAULT_STOP_SIGNAL = "SIGTERM";
63
73
  var DEFAULT_POLL_MS = 100;
64
74
  var DEFAULT_LOG_FILE_MODE = 0o600;
75
+ // Poll cadence for the Windows cooperative-stop sentinel (a synchronous
76
+ // existsSync on this interval; see _installStopSentinelWatcher for why it is a
77
+ // poll and not a filesystem watch). Runs for a foreground daemon's whole
78
+ // lifetime, so it is coarser than DEFAULT_POLL_MS (which only polls during the
79
+ // brief stop window); 250ms keeps graceful-stop detection sub-second at
80
+ // negligible idle cost.
81
+ var STOP_SENTINEL_POLL_MS = 250;
65
82
 
66
83
  function _safeAuditEmit(action, outcome, metadata) {
67
84
  auditEmit.emit(action, metadata, outcome);
68
85
  }
69
86
 
70
- function _isLivePid(pid) {
71
- if (typeof pid !== "number" || !isFinite(pid) || pid <= 0) return false;
72
- try { process.kill(pid, 0); return true; }
73
- catch (e) { return e && e.code === "EPERM"; }
74
- }
75
-
76
- function _readPidFile(pidFile) {
77
- try {
78
- // Same fd-safe + capped + symlink-refusing read as app-shutdown's lockfile
79
- // reader (one shape): a PID file is never a legit symlink mount, so
80
- // refuseSymlink is safe; any throw → null ("nothing live there").
81
- var raw = atomicFile.fdSafeReadSync(pidFile, { maxBytes: C.BYTES.kib(1), refuseSymlink: true, encoding: "utf8" });
82
- var pid = parseInt(String(raw).trim(), 10);
83
- return isFinite(pid) && pid > 0 ? pid : null;
84
- } catch (_e) { return null; }
85
- }
87
+ // Signal-0 liveness probe + fd-safe pidfile reader live in lib/pid-probe.js so
88
+ // b.daemon and b.appShutdown.pidLock share ONE implementation (they carried
89
+ // byte-identical copies). Local aliases keep the call sites terse.
90
+ var _isLivePid = pidProbe.isLivePid;
91
+ var _readPidFile = pidProbe.readPidFile;
86
92
 
87
93
  function _validateStartOpts(opts) {
88
94
  validateOpts.shape(opts, {
@@ -132,6 +138,13 @@ function _validateStopOpts(opts) {
132
138
  }, "daemon.stop", DaemonError, "daemon/bad-opts");
133
139
  }
134
140
 
141
+ function _validateStatusOpts(opts) {
142
+ validateOpts.shape(opts, {
143
+ pidFile: { rule: "required-string", code: "daemon/bad-pid-file",
144
+ label: "daemon.status: opts.pidFile" },
145
+ }, "daemon.status", DaemonError, "daemon/bad-opts");
146
+ }
147
+
135
148
  function _maybeReapStale(pidFile) {
136
149
  var existing = _readPidFile(pidFile);
137
150
  if (existing === null) return false;
@@ -187,6 +200,85 @@ function _redirectStdio(fd) {
187
200
  // start() in the same process don't double-install signals.
188
201
  var _foregroundOrchestrators = Object.create(null);
189
202
 
203
+ // Sibling-sentinel path a cooperative stop request is written to. Kept beside
204
+ // the pidFile so it inherits the same operator-owned directory + permissions.
205
+ function _stopSentinelPath(pidFile) {
206
+ return pidFile + ".stop";
207
+ }
208
+
209
+ // Remove a cooperative stop sentinel (best-effort — it may already be gone, or
210
+ // never have existed on the POSIX path). unlink removes the LINK, not a target.
211
+ function _cleanupSentinel(sentinelPath) {
212
+ try { nodeFs.unlinkSync(sentinelPath); } catch (_e) { /* best-effort — may not exist */ }
213
+ }
214
+
215
+ // Install the Windows cooperative-stop watcher for a foreground daemon. Fires
216
+ // the orchestrator's graceful shutdown the first time the sibling <pidFile>.stop
217
+ // sentinel appears — the same orchestrator.shutdown() the POSIX signal path
218
+ // drives, so the exit code is set from the phase result and the event loop
219
+ // drains once the phases release the daemon's resources.
220
+ //
221
+ // Detection is a synchronous existsSync on a plain unref'd interval, NOT a
222
+ // filesystem watch, for two Windows-specific reasons:
223
+ // - fs.watch (libuv ReadDirectoryChangesW) aborts the whole process — an
224
+ // uncatchable src/win/fs-event.c assertion, "!_wcsnicmp(filename, dir,
225
+ // dirlen)" — when the watched directory is reached through an 8.3
226
+ // short-name path, exactly the shape of a CI runner's temp dir
227
+ // (C:\Users\RUNNER~1\AppData\Local\Temp\...): GetFinalPathNameByHandle
228
+ // returns the long form and the prefix check fails. A try/catch cannot
229
+ // recover from an abort().
230
+ // - fs.watchFile's StatWatcher stats through the libuv threadpool, which
231
+ // starves under heavy concurrent filesystem load and delays detection by
232
+ // seconds; a synchronous existsSync runs inline on the main thread and is
233
+ // immune.
234
+ // The interval is unref'd so it never itself keeps the process alive; a clean
235
+ // daemon exits on its own once shutdown completes. If the sentinel is never
236
+ // written the poll is a no-op the release phase clears at shutdown. Returns a
237
+ // handle exposing close().
238
+ function _installStopSentinelWatcher(pidFile, orchestrator) {
239
+ var dir = nodePath.dirname(pidFile);
240
+ var sentinelName = nodePath.basename(pidFile) + ".stop";
241
+ var sentinelPath = nodePath.join(dir, sentinelName);
242
+ var fired = false;
243
+ var timer = null;
244
+ function _stopPolling() {
245
+ if (!timer) return;
246
+ try { clearInterval(timer); } catch (_e) { /* best-effort */ }
247
+ timer = null;
248
+ }
249
+ function _maybeFire() {
250
+ if (fired) return;
251
+ // Synchronous existsSync on the main thread — deliberately not an async
252
+ // stat, so detection never queues behind a saturated libuv threadpool.
253
+ if (!nodeFs.existsSync(sentinelPath)) return;
254
+ fired = true;
255
+ _stopPolling();
256
+ log("cooperative stop-request observed (" + sentinelPath + ") — initiating graceful shutdown");
257
+ // Mirror the POSIX signal path: run the orchestrator's phases, derive the
258
+ // exit code from the result, then let the loop drain (a foreground daemon's
259
+ // phases release its server/db so the process exits on its own).
260
+ Promise.resolve(orchestrator.shutdown()).then(function (result) {
261
+ if (process.exitCode === undefined || process.exitCode === 0) {
262
+ process.exitCode = (result && result.ok) ? 0 : 1;
263
+ }
264
+ }).catch(function () { process.exitCode = 1; });
265
+ }
266
+ try {
267
+ timer = setInterval(_maybeFire, STOP_SENTINEL_POLL_MS);
268
+ if (timer && typeof timer.unref === "function") timer.unref();
269
+ } catch (_e) {
270
+ // Timer scheduling unavailable — no cooperative channel; daemon.stop()
271
+ // still hard-stops via TerminateProcess after its timeout.
272
+ timer = null;
273
+ }
274
+ // The sentinel may already exist (stop() raced ahead of this install).
275
+ _maybeFire();
276
+ return {
277
+ close: function () { _stopPolling(); },
278
+ sentinelPath: sentinelPath,
279
+ };
280
+ }
281
+
190
282
  /**
191
283
  * @primitive b.daemon.start
192
284
  * @signature b.daemon.start(opts)
@@ -286,6 +378,33 @@ function start(opts) {
286
378
  throw new DaemonError("daemon/spawn-failed",
287
379
  "daemon.start: spawn failed: " + ((e && e.message) || String(e)));
288
380
  }
381
+ // A bad command does NOT throw synchronously from spawn — child_process
382
+ // reports it ASYNC via a 'error' event, with child.pid left undefined. The
383
+ // sync try/catch above only covers spawn() itself, so without this the old
384
+ // path wrote "undefined\n" to the pidFile and returned success. Subscribe a
385
+ // one-shot 'error' handler that reaps the sidecar + audits the failure, then
386
+ // refuse to proceed for a child that never got a pid.
387
+ child.on("error", function (err) {
388
+ try { nodeFs.unlinkSync(pidFile); } catch (_e) { /* best-effort — may not exist */ }
389
+ _safeAuditEmit("daemon.spawn_failed", "failure", {
390
+ pidFile: pidFile,
391
+ command: opts.command,
392
+ error: (err && err.message) || String(err),
393
+ });
394
+ });
395
+ // child.pid must be a real, positive PID. A command that failed to launch
396
+ // leaves it undefined; the same positive-integer front-guard the shared
397
+ // liveness probe (pidProbe.isLivePid) applies before it will signal a pid —
398
+ // a non-number / non-finite / non-positive value is never a launched child.
399
+ // Fail closed BEFORE any pidfile write so no "undefined" sidecar survives
400
+ // for daemon.stop to misread.
401
+ if (typeof child.pid !== "number" || !isFinite(child.pid) || child.pid <= 0) {
402
+ try { if (typeof logFd === "number") nodeFs.closeSync(logFd); }
403
+ catch (_c) { /* best-effort */ }
404
+ throw new DaemonError("daemon/spawn-failed",
405
+ "daemon.start: spawn of '" + opts.command + "' produced no pid (the command " +
406
+ "failed to launch)");
407
+ }
289
408
  // Write the child's PID via atomic temp+rename so a concurrent
290
409
  // observer never sees a half-written pidFile.
291
410
  atomicFile.ensureDir(nodePath.dirname(pidFile));
@@ -334,6 +453,15 @@ function start(opts) {
334
453
  }
335
454
  }
336
455
 
456
+ // Cooperative stop channel (Windows). Node maps process.kill(pid, "SIGTERM")
457
+ // to TerminateProcess on win32, so a "signal" never reaches a JS handler and
458
+ // the graceful orchestrator would be unreachable. daemon.stop() writes a
459
+ // sibling <pidFile>.stop sentinel; a watcher installed below routes it into
460
+ // the SAME orchestrator.shutdown() the POSIX signal path uses. Assigned after
461
+ // the orchestrator exists; the release phase (which runs at shutdown) closes
462
+ // it + removes the sentinel.
463
+ var stopWatcher = null;
464
+
337
465
  var orchestrator = appShutdown.create({
338
466
  signals: signals,
339
467
  installSignalHandlers: true,
@@ -341,16 +469,21 @@ function start(opts) {
341
469
  {
342
470
  name: "pidLock-release",
343
471
  run: function () {
472
+ if (stopWatcher) { try { stopWatcher.close(); } catch (_w) { /* best-effort */ } }
344
473
  try { lock.release(); } catch (_e) { /* best-effort */ }
345
474
  if (logFdForeground !== null) {
346
475
  try { nodeFs.closeSync(logFdForeground); } catch (_c) { /* best-effort */ }
347
476
  }
477
+ _cleanupSentinel(_stopSentinelPath(pidFile));
348
478
  },
349
479
  timeoutMs: C.TIME.seconds(2),
350
480
  },
351
481
  ],
352
482
  });
353
483
  _foregroundOrchestrators[pidFile] = orchestrator;
484
+ if (process.platform === "win32") {
485
+ stopWatcher = _installStopSentinelWatcher(pidFile, orchestrator);
486
+ }
354
487
 
355
488
  _safeAuditEmit("daemon.started", "success", {
356
489
  pidFile: pidFile,
@@ -423,16 +556,26 @@ async function stop(opts) {
423
556
  }
424
557
 
425
558
  var t0 = Date.now();
426
- // First signal — typically SIGTERM. Wait up to timeoutMs for exit.
559
+
560
+ // Windows has no cooperative signal: process.kill(pid, "SIGTERM") maps to
561
+ // TerminateProcess (a hard kill), so the graceful appShutdown orchestration is
562
+ // only reachable via the cooperative stop-request sentinel start() watches.
563
+ // Drive that channel first and escalate to the hard kill only on timeout.
564
+ if (process.platform === "win32") {
565
+ return await _stopWin32Cooperative(pidFile, pid, signal, timeoutMs, pollMs, t0, opts);
566
+ }
567
+
568
+ // POSIX signal path — first signal (typically SIGTERM), wait up to timeoutMs
569
+ // for exit, then escalate to SIGKILL. mechanism is always "signal" here.
427
570
  try { process.kill(pid, signal); }
428
571
  catch (e) {
429
572
  if (e && e.code === "ESRCH") {
430
573
  // Died between read and kill — cleanup + report.
431
574
  try { nodeFs.unlinkSync(pidFile); } catch (_u) { /* best-effort */ }
432
575
  _safeAuditEmit("daemon.stopped", "success", {
433
- pidFile: pidFile, signal: signal, waitMs: Date.now() - t0, escalated: false,
576
+ pidFile: pidFile, signal: signal, waitMs: Date.now() - t0, escalated: false, mechanism: "signal",
434
577
  });
435
- return { stopped: true, pid: pid, signal: signal };
578
+ return { stopped: true, pid: pid, signal: signal, mechanism: "signal" };
436
579
  }
437
580
  throw new DaemonError("daemon/kill-failed",
438
581
  "daemon.stop: kill(" + pid + ", " + signal + ") failed: " + e.message);
@@ -443,9 +586,9 @@ async function stop(opts) {
443
586
  if (!_isLivePid(pid)) {
444
587
  try { nodeFs.unlinkSync(pidFile); } catch (_u) { /* best-effort */ }
445
588
  _safeAuditEmit("daemon.stopped", "success", {
446
- pidFile: pidFile, signal: signal, waitMs: Date.now() - t0, escalated: false,
589
+ pidFile: pidFile, signal: signal, waitMs: Date.now() - t0, escalated: false, mechanism: "signal",
447
590
  });
448
- return { stopped: true, pid: pid, signal: signal };
591
+ return { stopped: true, pid: pid, signal: signal, mechanism: "signal" };
449
592
  }
450
593
  await safeAsync.sleep(pollMs, { signal: opts.abortSignal });
451
594
  }
@@ -466,9 +609,109 @@ async function stop(opts) {
466
609
  }
467
610
  try { nodeFs.unlinkSync(pidFile); } catch (_u) { /* best-effort */ }
468
611
  _safeAuditEmit("daemon.stopped", "success", {
469
- pidFile: pidFile, signal: "SIGKILL", waitMs: Date.now() - t0, escalated: true,
612
+ pidFile: pidFile, signal: "SIGKILL", waitMs: Date.now() - t0, escalated: true, mechanism: "signal",
613
+ });
614
+ return { stopped: true, pid: pid, signal: "SIGKILL", escalated: true, mechanism: "signal" };
615
+ }
616
+
617
+ // Windows cooperative stop. Writes the sibling <pidFile>.stop sentinel the
618
+ // foreground start() watcher routes into the graceful orchestrator, polls for a
619
+ // clean exit up to timeoutMs, and escalates to a hard TerminateProcess (Windows
620
+ // maps any real signal to it) ONLY on timeout — preserving the POSIX
621
+ // graceful-first / forced-on-timeout shape and the same brief post-kill reap
622
+ // wait. mechanism distinguishes the cooperative exit from the forced one; the
623
+ // sentinel is removed on both paths.
624
+ async function _stopWin32Cooperative(pidFile, pid, signal, timeoutMs, pollMs, t0, opts) {
625
+ var sentinel = _stopSentinelPath(pidFile);
626
+ // O_NOFOLLOW-staged write (atomicFile.writeSync → _openExclTemp with
627
+ // O_EXCL | O_NOFOLLOW) so a symlink planted at <pidFile>.stop can't redirect
628
+ // the request to an attacker-chosen file (CWE-59).
629
+ try {
630
+ atomicFile.writeSync(sentinel, String(pid) + "\n", { fileMode: 0o600 });
631
+ } catch (e) {
632
+ throw new DaemonError("daemon/stop-request-failed",
633
+ "daemon.stop: failed to write cooperative stop-request '" + sentinel + "': " +
634
+ ((e && e.message) || String(e)));
635
+ }
636
+
637
+ // Poll for cooperative exit up to timeoutMs.
638
+ var deadline = t0 + timeoutMs;
639
+ while (Date.now() < deadline) {
640
+ if (!_isLivePid(pid)) {
641
+ _cleanupSentinel(sentinel);
642
+ try { nodeFs.unlinkSync(pidFile); } catch (_u) { /* best-effort */ }
643
+ _safeAuditEmit("daemon.stopped", "success", {
644
+ pidFile: pidFile, signal: signal, waitMs: Date.now() - t0, escalated: false, mechanism: "cooperative",
645
+ });
646
+ return { stopped: true, pid: pid, signal: signal, mechanism: "cooperative" };
647
+ }
648
+ await safeAsync.sleep(pollMs, { signal: opts.abortSignal });
649
+ }
650
+
651
+ // Timed out — the daemon ignored the cooperative request. Hard-stop it.
652
+ try { process.kill(pid, "SIGKILL"); }
653
+ catch (e) {
654
+ if (!(e && e.code === "ESRCH")) {
655
+ _cleanupSentinel(sentinel);
656
+ throw new DaemonError("daemon/kill-failed",
657
+ "daemon.stop: TerminateProcess escalation failed for pid " + pid + ": " + e.message);
658
+ }
659
+ }
660
+ var killDeadline = Date.now() + C.TIME.seconds(2);
661
+ while (Date.now() < killDeadline) {
662
+ if (!_isLivePid(pid)) break;
663
+ await safeAsync.sleep(pollMs, { signal: opts.abortSignal });
664
+ }
665
+ _cleanupSentinel(sentinel);
666
+ try { nodeFs.unlinkSync(pidFile); } catch (_u) { /* best-effort */ }
667
+ _safeAuditEmit("daemon.stopped", "success", {
668
+ pidFile: pidFile, signal: "SIGKILL", waitMs: Date.now() - t0, escalated: true, mechanism: "terminate",
470
669
  });
471
- return { stopped: true, pid: pid, signal: "SIGKILL", escalated: true };
670
+ return { stopped: true, pid: pid, signal: "SIGKILL", escalated: true, mechanism: "terminate" };
671
+ }
672
+
673
+ /**
674
+ * @primitive b.daemon.status
675
+ * @signature b.daemon.status(opts)
676
+ * @since 0.17.13
677
+ * @status stable
678
+ * @related b.daemon.start, b.daemon.stop
679
+ *
680
+ * Read-only PID-liveness probe. Reads `pidFile` and reports whether the
681
+ * recorded process is alive, WITHOUT mutating anything — unlike `stop()`,
682
+ * a stale pidfile is reported but never unlinked, so a health check can't
683
+ * disturb the daemon's lifecycle state. A missing / malformed / symlinked /
684
+ * oversized pidfile reports `running: false` with `reason: "no-pidfile"`
685
+ * rather than throwing (the same fd-safe, symlink-refusing, 1 KiB-capped
686
+ * read that `start` and `stop` use). Bad opts throw `daemon/bad-pid-file`.
687
+ *
688
+ * Returns `{ running, pid, reason? }`. `reason` is `"no-pidfile"` when no
689
+ * live sidecar was found and `"stale"` when the pidfile pointed at a dead
690
+ * PID — the file is left in place for the operator to inspect or for `stop`
691
+ * to reap.
692
+ *
693
+ * @opts
694
+ * pidFile: string, // absolute path of the PID sidecar (required)
695
+ *
696
+ * @example
697
+ * var s = b.daemon.status({ pidFile: "/tmp/blamejs-daemon-demo.pid" });
698
+ * s.running; // → false
699
+ * s.reason; // → "no-pidfile"
700
+ */
701
+ function status(opts) {
702
+ _validateStatusOpts(opts);
703
+ var pidFile = opts.pidFile;
704
+ var pid = _readPidFile(pidFile);
705
+ if (pid === null) {
706
+ // Missing / malformed / symlinked / oversized — nothing live to report.
707
+ // READ-ONLY: never unlink (stop() reaps; status() must not).
708
+ return { running: false, pid: null, reason: "no-pidfile" };
709
+ }
710
+ if (!_isLivePid(pid)) {
711
+ // Recorded PID is dead. Report it but leave the sidecar in place.
712
+ return { running: false, pid: pid, reason: "stale" };
713
+ }
714
+ return { running: true, pid: pid };
472
715
  }
473
716
 
474
717
  // Test-only — drop process-wide foreground orchestrator state so smoke
@@ -485,6 +728,7 @@ function _resetForTest() {
485
728
  module.exports = {
486
729
  start: start,
487
730
  stop: stop,
731
+ status: status,
488
732
  DaemonError: DaemonError,
489
733
  DEFAULT_STOP_SIGNAL: DEFAULT_STOP_SIGNAL,
490
734
  DEFAULT_STOP_TIMEOUT_MS: DEFAULT_STOP_TIMEOUT_MS,
@@ -359,7 +359,7 @@ function declareView(opts) {
359
359
  // Build CREATE VIEW. Each column is independently quoted so a
360
360
  // reserved-word column name (e.g. "user", "order") resolves correctly.
361
361
  var quotedCols = selectedColumns.map(function (c) {
362
- return safeSql.quoteIdentifier(c, "postgres");
362
+ return safeSql.quoteIdentifier(c, "postgres", { allowReserved: true }); // reserved-word column names (e.g. "user"/"order") — parity with b.db.from()
363
363
  }).join(", ");
364
364
  var createSql = "CREATE VIEW " + qView + " AS SELECT " + quotedCols +
365
365
  " FROM " + qSource;
@@ -370,7 +370,7 @@ function declareView(opts) {
370
370
  // GRANT SELECT — one statement covers all roles.
371
371
  if (spec.grantTo.length > 0) {
372
372
  var quotedRoles = spec.grantTo.map(function (r) {
373
- return safeSql.quoteIdentifier(r, "postgres");
373
+ return safeSql.quoteIdentifier(r, "postgres", { allowReserved: true }); // parity with b.db.from()
374
374
  }).join(", ");
375
375
  await xdb.query(
376
376
  "GRANT SELECT ON " + qView + " TO " + quotedRoles,
package/lib/db.js CHANGED
@@ -2211,7 +2211,10 @@ function exportCsv(opts) {
2211
2211
  validateOpts.requireNonEmptyString(opts.table, "exportCsv: opts.table", DbError, "db/bad-export-table");
2212
2212
  // Quote-validate the table identifier — refuses anything with embedded
2213
2213
  // quotes, schema-qualified names valid via dot-separated parts.
2214
- safeSql.quoteIdentifier(opts.table);
2214
+ // allowReserved: parity with b.db.from() — a schema-valid operator table
2215
+ // whose name is a SQL keyword must validate here too (still fails closed on
2216
+ // shape/length/null-byte/sqlite_ prefix; the keyword is safe once quoted).
2217
+ safeSql.quoteIdentifier(opts.table, undefined, { allowReserved: true });
2215
2218
  var meta = tableMetadata[opts.table];
2216
2219
  if (!meta) {
2217
2220
  throw new DbError("db/unknown-table",
package/lib/dsr.js CHANGED
@@ -1053,7 +1053,7 @@ function dbTicketStore(opts) {
1053
1053
  var SQL_OPTS = { dialect: "sqlite", quoteName: true };
1054
1054
  var qTable, qEmailIdx, qStatusIdx;
1055
1055
  try {
1056
- qTable = safeSql.quoteIdentifier(tableRaw, "sqlite");
1056
+ qTable = safeSql.quoteIdentifier(tableRaw, "sqlite", { allowReserved: true }); // parity with b.db.from() (the _idx names below are suffix-derived — can't be a bare keyword)
1057
1057
  qEmailIdx = safeSql.quoteIdentifier(tableRaw + "_email_idx", "sqlite");
1058
1058
  qStatusIdx = safeSql.quoteIdentifier(tableRaw + "_status_idx", "sqlite");
1059
1059
  } catch (sqlErr) {
@@ -48,6 +48,7 @@
48
48
  */
49
49
  var lazyRequire = require("./lazy-require");
50
50
  var boundedMap = require("./bounded-map");
51
+ var safeObject = require("./safe-object");
51
52
  var { defineClass } = require("./framework-error");
52
53
 
53
54
  var I18nMessageFormatError = defineClass("I18nMessageFormatError",
@@ -370,7 +371,7 @@ function _renderSequence(nodes, vars, locale, hashContext, depth) {
370
371
  // a non-array (a request DoS). Every case-map lookup goes through this so no key
371
372
  // can reach the prototype chain.
372
373
  function _ownCase(cases, key) {
373
- return Object.prototype.hasOwnProperty.call(cases, key) ? cases[key] : undefined;
374
+ return safeObject.ownProp(cases, key);
374
375
  }
375
376
 
376
377
  // Own-property variable lookup. A template argument NAME is parse-derived from
@@ -383,7 +384,7 @@ function _ownCase(cases, key) {
383
384
  // which is already own-property-only. Every argument / plural / select value
384
385
  // lookup goes through this so no template name can reach the prototype chain.
385
386
  function _ownVar(vars, name) {
386
- return Object.prototype.hasOwnProperty.call(vars, name) ? vars[name] : undefined;
387
+ return safeObject.ownProp(vars, name);
387
388
  }
388
389
 
389
390
  function _renderNode(node, vars, locale, hashContext, depth) {
@@ -40,6 +40,10 @@ var lazyRequire = require("./lazy-require");
40
40
  // scrub attribute values through the telemetry redactor before they cross the
41
41
  // OTLP egress boundary (CWE-532).
42
42
  var observability = lazyRequire(function () { return require("./observability"); });
43
+ // Lazy to match the observability cycle-break above. Scrubs secrets embedded in
44
+ // the free-text log body for a directly-wired sink (defense in depth — emit()
45
+ // already redacts, but a sink can be driven without it, same as meta/redactAttrs).
46
+ var redact = lazyRequire(function () { return require("./redact"); });
43
47
  // Lazy — network-tls is widely required; audit an insecure (cert-validation-
44
48
  // disabled) outbound TLS session at honor time, same surface as connectWithEch.
45
49
  var networkTls = lazyRequire(function () { return require("./network-tls"); });
@@ -148,7 +152,7 @@ function _encodeLogRecord(record) {
148
152
  var attrPieces = _encodeAttributes(observability().redactAttrs(record.meta)).map(function (kvBody) {
149
153
  return pb.embeddedMessage(6, kvBody);
150
154
  });
151
- var msg = (record.message != null ? String(record.message) : "");
155
+ var msg = (record.message != null ? redact().redactText(String(record.message)) : "");
152
156
  return Buffer.concat([
153
157
  pb.fixed64(1, tsNs),
154
158
  pb.uint32(2, sev.number),
@@ -65,6 +65,10 @@ var lazyRequire = require("./lazy-require");
65
65
  // log path can reach a log-stream sink). Used only to scrub attribute values
66
66
  // through the telemetry redactor before they cross the OTLP egress boundary.
67
67
  var observability = lazyRequire(function () { return require("./observability"); });
68
+ // Lazy to match the observability cycle-break above. Scrubs secrets embedded in
69
+ // the free-text log body for a directly-wired sink (defense in depth — emit()
70
+ // already redacts, but a sink can be driven without it, same as meta/redactAttrs).
71
+ var redact = lazyRequire(function () { return require("./redact"); });
68
72
 
69
73
  var MAX_RESPONSE_BYTES = C.BYTES.mib(1);
70
74
  var FRAMEWORK_VERSION = (pkg && pkg.version) || "unknown";
@@ -157,7 +161,7 @@ function _toLogRecord(record) {
157
161
  observedTimeUnixNano: nanos,
158
162
  severityNumber: sev.number,
159
163
  severityText: sev.text,
160
- body: { stringValue: record.message == null ? "" : String(record.message) },
164
+ body: { stringValue: record.message == null ? "" : redact().redactText(String(record.message)) },
161
165
  attributes: attrs,
162
166
  };
163
167
  }
package/lib/log-stream.js CHANGED
@@ -210,11 +210,14 @@ function emit(level, message, meta) {
210
210
  if (LEVELS.indexOf(level) === -1) {
211
211
  throw _err("INVALID_LEVEL", "log level must be one of " + LEVELS.join(", "), true);
212
212
  }
213
- // Build the record. Redact metadata BEFORE distribution to any sink.
213
+ // Build the record. Redact BOTH the structured metadata AND the free-text
214
+ // message BEFORE distribution to any sink — a secret interpolated into the
215
+ // message string (a JWT, an AWS key, a URL password) must not reach a file /
216
+ // remote sink verbatim. redactText scrubs embedded fragments in place.
214
217
  var record = {
215
218
  ts: Date.now(),
216
219
  level: level,
217
- message: message == null ? null : String(message),
220
+ message: message == null ? null : redact.redactText(String(message)),
218
221
  };
219
222
  if (meta) {
220
223
  record.meta = redact.redact(meta);
@@ -52,6 +52,7 @@ var DEFAULT_BLOCKED_AGENTS = [
52
52
 
53
53
  var lazyRequire = require("../lazy-require");
54
54
  var requestHelpers = require("../request-helpers");
55
+ var ssrfGuard = require("../ssrf-guard");
55
56
  var validateOpts = require("../validate-opts");
56
57
  var denyResponse = require("./deny-response").denyResponse;
57
58
  var { defineClass } = require("../framework-error");
@@ -185,15 +186,10 @@ function create(opts) {
185
186
  if (_proto.resolve(req) === "https") return true;
186
187
  var host = (req.headers && req.headers.host) || "";
187
188
  host = String(host).toLowerCase().replace(/:\d+$/, ""); // strip :port
188
- if (host.charAt(0) === "[") { // [::1] IPv6 literal
189
- var end = host.indexOf("]");
190
- host = end === -1 ? host.slice(1) : host.slice(1, end);
191
- }
192
- host = host.replace(/\.$/, ""); // strip trailing root-zone dot (RFC 1034 §3.1) so "localhost." matches
193
- if (host === "localhost" || /\.localhost$/.test(host)) return true;
194
- if (host === "::1") return true;
195
- if (/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host)) return true; // allow:regex-no-length-cap — bounded dotted-quad loopback
196
- return false;
189
+ // Loopback HTTP is a secure context (can't be reached off-box). Compose the
190
+ // canonical hostname-aware classifier — 127/8, ::1 / [::1], localhost, and
191
+ // *.localhost (RFC 6761 §6.3) — instead of re-rolling the triad here.
192
+ return ssrfGuard.isLoopbackHost(host);
197
193
  }
198
194
 
199
195
  function _checkHeuristics(req) {
package/lib/outbox.js CHANGED
@@ -99,7 +99,7 @@ var KEY_MAX_LEN = C.BYTES.bytes(255);
99
99
  function _validateTableName(name) {
100
100
  // SQL identifier — quoteIdentifier rejects anything with embedded
101
101
  // quotes, schema-qualified names valid via dot-separated parts.
102
- return safeSql.quoteIdentifier(name);
102
+ return safeSql.quoteIdentifier(name, undefined, { allowReserved: true }); // parity with b.db.from()
103
103
  }
104
104
 
105
105
  // Map the operator backend's dialect tag to the b.sql dialect vocabulary.
@@ -0,0 +1,55 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (c) blamejs contributors
3
+ "use strict";
4
+ //
5
+ // pid-probe — the signal-0 liveness probe + fd-safe pidfile reader shared by
6
+ // b.daemon (lib/daemon.js) and b.appShutdown.pidLock (lib/app-shutdown.js).
7
+ // Both files carried a byte-for-byte copy of the liveness check and the capped,
8
+ // symlink-refusing pidfile reader; a single owner keeps the liveness semantics
9
+ // (process.kill(pid, 0) → alive; EPERM → alive-but-unowned; ESRCH → dead) and
10
+ // the read hardening (1 KiB cap + O_NOFOLLOW refusal so a planted symlink or an
11
+ // oversized file can neither redirect nor OOM the read) identical across every
12
+ // caller — daemon.start / stop / status and pidLock.acquire / release.
13
+ //
14
+ // This is not request-reachable: the two callers are process-lifecycle
15
+ // primitives, so a throw here would only ever surface at daemon start/stop.
16
+ // Both entry points are defensive readers (return-default, never throw): a
17
+ // missing / malformed / hostile pidfile yields null ("nothing live there")
18
+ // rather than propagating an error into the shutdown path.
19
+
20
+ var atomicFile = require("./atomic-file");
21
+ var C = require("./constants");
22
+
23
+ // isLivePid(pid) — signal-0 existence probe. process.kill(pid, 0) sends no
24
+ // signal but performs the permission + existence check the kernel would do for
25
+ // a real signal: it succeeds when the process is alive and signalable, throws
26
+ // EPERM when the process is alive but owned by another user (still "live"), and
27
+ // throws ESRCH when no such process exists (dead). A non-numeric / non-finite /
28
+ // non-positive pid is never live.
29
+ function isLivePid(pid) {
30
+ if (typeof pid !== "number" || !isFinite(pid) || pid <= 0) return false;
31
+ try { process.kill(pid, 0); return true; }
32
+ catch (e) { return !!(e && e.code === "EPERM"); }
33
+ }
34
+
35
+ // readPidFile(pidFile) — fd-safe + capped + symlink-refusing read of a PID
36
+ // sidecar. A PID file is never a legitimate symlink mount (unlike a k8s/certbot
37
+ // secret mount), so refuseSymlink is safe here and stops a planted symlink from
38
+ // redirecting the read; the 1 KiB cap stops an oversized planted file from
39
+ // OOM-ing it. Returns the parsed positive PID, or null for any failure
40
+ // (missing / symlink / too-large / non-numeric) — the uniform "nothing live
41
+ // there" sentinel both callers already relied on.
42
+ function readPidFile(pidFile) {
43
+ try {
44
+ var raw = atomicFile.fdSafeReadSync(pidFile, {
45
+ maxBytes: C.BYTES.kib(1), refuseSymlink: true, encoding: "utf8",
46
+ });
47
+ var pid = parseInt(String(raw).trim(), 10);
48
+ return isFinite(pid) && pid > 0 ? pid : null;
49
+ } catch (_e) { return null; }
50
+ }
51
+
52
+ module.exports = {
53
+ isLivePid: isLivePid,
54
+ readPidFile: readPidFile,
55
+ };
package/lib/pqc-agent.js CHANGED
@@ -176,6 +176,13 @@ function _buildAgentOpts(opts) {
176
176
  } else {
177
177
  merged.ecdhCurve = C.TLS_GROUP_CURVE_STR;
178
178
  }
179
+ // Mirror the resolved ecdhCurve into `groups` from one string (same
180
+ // shape as network-tls.buildOptions). `applyToContext` only fills
181
+ // `groups` when it is undefined, so setting it here preserves the
182
+ // caller's narrowed/reordered selection instead of letting the
183
+ // context filler re-derive `groups` from STATE.tlsKeyShares — a
184
+ // different ordering that would ignore the caller's ecdhCurve.
185
+ merged.groups = merged.ecdhCurve;
179
186
  merged.minVersion = "TLSv1.3";
180
187
  if (networkTls && typeof networkTls.applyToContext === "function") {
181
188
  merged = networkTls.applyToContext({ base: merged });
@@ -203,7 +210,7 @@ function _buildAgentOpts(opts) {
203
210
  * maxSockets?: number,
204
211
  * maxFreeSockets?: number,
205
212
  * scheduling?: string,
206
- * ecdhCurve?: string, // colon-separated group names; must subset C.TLS_GROUP_PREFERENCE
213
+ * ecdhCurve?: string, // colon-separated group names; must subset C.TLS_GROUP_PREFERENCE. The TLS `groups` list tracks this value exactly (mirrored from one resolved string), so a narrowed/reordered ecdhCurve is the negotiated key-share order.
207
214
  * allowOperatorGroups?: boolean, // default false; opt in to operator-supplied groups outside the framework PQC preference
208
215
  *
209
216
  * @example