@blamejs/core 0.17.23 → 0.18.0

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/auth/oauth.js CHANGED
@@ -272,6 +272,27 @@ function _generatePkce() {
272
272
  return { verifier: verifier, challenge: challenge };
273
273
  }
274
274
 
275
+ /**
276
+ * @primitive b.auth.oauth.generatePkce
277
+ * @signature b.auth.oauth.generatePkce()
278
+ * @since 0.18.0
279
+ * @status stable
280
+ * @related b.auth.oauth.parseCallback
281
+ *
282
+ * Generate a PKCE (RFC 7636) verifier/challenge pair for a hand-rolled
283
+ * authorization-code flow that does not go through `create()`. The
284
+ * `code_challenge_method` is always `S256`: the challenge is the base64url
285
+ * of SHA-256 over the verifier, and the verifier is 43 base64url characters
286
+ * (32 CSPRNG bytes).
287
+ *
288
+ * @example
289
+ * var pkce = b.auth.oauth.generatePkce();
290
+ * // → { verifier: "…43 chars…", challenge: "…base64url(SHA-256(verifier))…" }
291
+ */
292
+ function generatePkce() {
293
+ return _generatePkce();
294
+ }
295
+
275
296
  function _validateUrl(url, allowHttp, label) {
276
297
  if (typeof url !== "string" || url.length === 0) {
277
298
  throw new OAuthError("auth-oauth/bad-url", label + ": URL is required");
@@ -550,6 +571,7 @@ function _toAttestationPrivateKey(value, label) {
550
571
  catch (e) {
551
572
  var code = (e && e.code) === "auth-jwt-external/sign-no-key"
552
573
  ? "auth-oauth/attestation-no-key" : "auth-oauth/attestation-bad-key";
574
+ /* c8 ignore next -- String(e) fallback: the jwt-external error always carries a message */
553
575
  throw new OAuthError(code, (e && e.message) || String(e));
554
576
  }
555
577
  }
@@ -567,8 +589,10 @@ function _resolveAttestationAlg(explicitAlg, privateKey, label) {
567
589
  try {
568
590
  return jwtExternal._resolveSignAlg(explicitAlg, privateKey, label);
569
591
  } catch (e) {
592
+ /* c8 ignore next -- || "" is unreachable: _resolveSignAlg always throws a coded AuthError */
570
593
  var ec = (e && e.code) || "";
571
594
  if (ec === "auth-jwt-external/sign-alg-key-mismatch") {
595
+ /* c8 ignore next -- String(e) fallback: the mapped error always carries a message */
572
596
  throw new OAuthError("auth-oauth/attestation-alg-key-mismatch", (e && e.message) || String(e));
573
597
  }
574
598
  if (ec === "auth-jwt-external/sign-alg-refused" || ec === "auth-jwt-external/sign-alg-unsupported") {
@@ -576,8 +600,10 @@ function _resolveAttestationAlg(explicitAlg, privateKey, label) {
576
600
  label + ": alg '" + explicitAlg + "' is not an accepted attestation algorithm");
577
601
  }
578
602
  if (ec === "auth-jwt-external/sign-key-unsupported") {
603
+ /* c8 ignore next -- String(e) fallback: the mapped error always carries a message */
579
604
  throw new OAuthError("auth-oauth/attestation-key-unsupported", (e && e.message) || String(e));
580
605
  }
606
+ /* c8 ignore next -- unreachable: _resolveSignAlg only emits the four codes handled above */
581
607
  throw new OAuthError("auth-oauth/attestation-bad-key", (e && e.message) || String(e));
582
608
  }
583
609
  }
@@ -613,6 +639,7 @@ function _verifyAttestationJws(jws, publicKeyJwk, label, expectedTyp) {
613
639
  header = safeJson.parse(_b64urlDecode(parts[0]).toString("utf8"), { maxBytes: MAX_ATTESTATION_JWT_BYTES });
614
640
  payload = safeJson.parse(_b64urlDecode(parts[1]).toString("utf8"), { maxBytes: MAX_ATTESTATION_JWT_BYTES });
615
641
  } catch (e) {
642
+ /* c8 ignore next 2 -- String(e) fallback: the decode error always carries a message */
616
643
  throw new OAuthError("auth-oauth/attestation-malformed",
617
644
  label + ": header/payload decode failed: " + ((e && e.message) || String(e)));
618
645
  }
@@ -653,6 +680,7 @@ function _verifyAttestationJws(jws, publicKeyJwk, label, expectedTyp) {
653
680
  var ok;
654
681
  try {
655
682
  ok = nodeCrypto.verify(params.hash, Buffer.from(signingInput, "ascii"), verifyOpts, sig);
683
+ /* c8 ignore next 4 -- verify cannot raise: the alg/kty/crv cross-check above guarantees a compatible key/params pairing, so a bad signature returns false rather than throwing */
656
684
  } catch (verifyErr) {
657
685
  throw new OAuthError("auth-oauth/attestation-bad-signature",
658
686
  label + ": signature verification raised: " + ((verifyErr && verifyErr.message) || String(verifyErr)));
@@ -667,6 +695,7 @@ function _verifyAttestationJws(jws, publicKeyJwk, label, expectedTyp) {
667
695
  // never reach the attestation's cnf claim. Mirrors the dpop.buildProof
668
696
  // public-only embed.
669
697
  function _publicCnfJwk(jwk, label) {
698
+ /* c8 ignore next 4 -- unreachable: the sole caller validates instanceKeyJwk as a required object before this call */
670
699
  if (!jwk || typeof jwk !== "object") {
671
700
  throw new OAuthError("auth-oauth/attestation-bad-cnf",
672
701
  label + ": instanceKeyJwk (public JWK for the cnf claim) is required");
@@ -1006,6 +1035,7 @@ async function verifyClientAttestation(attestationJwt, popJwt, vopts) {
1006
1035
  // strings, returns false (never throws) on length mismatch, and refuses
1007
1036
  // non-string/Buffer input — so it carries the timing + type discipline.
1008
1037
  function _constantTimeStrEq(a, b) {
1038
+ /* c8 ignore next -- every caller passes a String()-wrapped first argument, so the typeof-a arm never short-circuits */
1009
1039
  if (typeof a !== "string" || typeof b !== "string") return false;
1010
1040
  return cryptoTimingSafeEqual(a, b);
1011
1041
  }
@@ -1040,6 +1070,7 @@ var RESERVED_AUTHZ_PARAMS = {
1040
1070
  // closed; shared by authorizationUrl, pushAuthorizationRequest, and
1041
1071
  // endSessionUrl so every URL/PAR builder guards the same way.
1042
1072
  function _assertNoReservedExtraParams(extraParams, reserved, errCode, ctx) {
1073
+ /* c8 ignore next -- every caller guards `extraParams && typeof === "object"` first, so the !extraParams arm never short-circuits */
1043
1074
  if (!extraParams || typeof extraParams !== "object") return;
1044
1075
  var ek = Object.keys(extraParams);
1045
1076
  for (var i = 0; i < ek.length; i++) {
@@ -1187,13 +1218,16 @@ function create(opts) {
1187
1218
  Object.assign(req, httpClientOpts);
1188
1219
  var res = await hc.request(req);
1189
1220
  if (res.statusCode < 200 || res.statusCode >= 300) {
1221
+ /* c8 ignore next -- httpClient always yields a Buffer body, so the empty-string arm is unreachable */
1190
1222
  var bodyText = res.body ? res.body.toString("utf8") : "";
1191
1223
  throw new OAuthError("auth-oauth/http-" + res.statusCode,
1192
1224
  url + " returned " + res.statusCode + ": " + bodyText.slice(0, 500));
1193
1225
  }
1226
+ /* c8 ignore next -- httpClient always yields a Buffer body (empty Buffer for no content), so this never returns null */
1194
1227
  if (!res.body) return null;
1195
1228
  try { return safeJson.parse(res.body.toString("utf8"), { maxBytes: OAUTH_MAX_RESPONSE_BYTES }); }
1196
1229
  catch (e) {
1230
+ /* c8 ignore next 2 -- String(e) fallback: the parse error always carries a message */
1197
1231
  throw new OAuthError("auth-oauth/bad-json",
1198
1232
  url + " response not JSON: " + ((e && e.message) || String(e)));
1199
1233
  }
@@ -1262,6 +1296,7 @@ function create(opts) {
1262
1296
  async function _peekDiscovery() {
1263
1297
  if (!isOidc || !issuer) return null;
1264
1298
  try { return (await _discoveryCache.get("config")) || null; }
1299
+ /* c8 ignore next -- defensive: the in-memory discovery cache get() does not throw */
1265
1300
  catch (_e) { return null; }
1266
1301
  }
1267
1302
 
@@ -1312,6 +1347,7 @@ function create(opts) {
1312
1347
  // base64url(SHA-256(verifier)) per RFC 7636.
1313
1348
  var state = uopts.state || _generateRandomToken(STATE_NONCE_BYTES);
1314
1349
  var nonce = uopts.nonce || (isOidc ? _generateRandomToken(STATE_NONCE_BYTES) : null);
1350
+ /* c8 ignore next -- pkce is always true (create() refuses pkce:false), so the : null alternate is dead */
1315
1351
  var pkceVals = pkce ? _generatePkce() : null;
1316
1352
  var params = new URLSearchParams();
1317
1353
  params.set("response_type", "code");
@@ -1356,6 +1392,7 @@ function create(opts) {
1356
1392
  url: endpoint + sep + params.toString(),
1357
1393
  state: state,
1358
1394
  nonce: nonce,
1395
+ /* c8 ignore next 2 -- pkceVals is always truthy (pkce is always on), so the : null alternates are dead */
1359
1396
  verifier: pkceVals ? pkceVals.verifier : null,
1360
1397
  challenge: pkceVals ? pkceVals.challenge : null,
1361
1398
  authorizationDetails: requestedAuthzDetails,
@@ -1764,6 +1801,7 @@ function create(opts) {
1764
1801
  if (allowInternal !== null) req.allowInternal = allowInternal;
1765
1802
  Object.assign(req, httpClientOpts);
1766
1803
  var res = await hc.request(req);
1804
+ /* c8 ignore next -- httpClient always yields a Buffer body, so the empty-string arm is unreachable */
1767
1805
  var text = res.body ? res.body.toString("utf8") : "";
1768
1806
  if (res.statusCode < 200 || res.statusCode >= 300) {
1769
1807
  throw new OAuthError("auth-oauth/token-error-" + res.statusCode,
@@ -1772,6 +1810,7 @@ function create(opts) {
1772
1810
  var parsed;
1773
1811
  try { parsed = safeJson.parse(text, { maxBytes: OAUTH_MAX_RESPONSE_BYTES }); }
1774
1812
  catch (e) {
1813
+ /* c8 ignore next 2 -- String(e) fallback: the parse error always carries a message */
1775
1814
  throw new OAuthError("auth-oauth/bad-token-json",
1776
1815
  "token endpoint response not JSON: " + ((e && e.message) || String(e)));
1777
1816
  }
@@ -1779,6 +1818,7 @@ function create(opts) {
1779
1818
  }
1780
1819
 
1781
1820
  async function _normalizeTokens(raw, vopts) {
1821
+ /* c8 ignore next -- every caller passes a vopts object, so the || {} default is unreachable */
1782
1822
  vopts = vopts || {};
1783
1823
  // RFC 6749 §3.3 — scope is space-separated, ONLY U+0020. `\s+` previously
1784
1824
  // matched U+0085 NEL, U+00A0 NBSP, etc., so a hostile AS returning
@@ -1879,6 +1919,7 @@ function create(opts) {
1879
1919
  action: "jwt.jwe.refused",
1880
1920
  outcome: "denied",
1881
1921
  metadata: { reason: "jwe-on-jws-verifier", primitive: "oauth.verifyIdToken" },
1922
+ /* c8 ignore next -- drop-silent observability sink: safeEmit does not throw */
1882
1923
  }); } catch (_e) { /* drop-silent — observability sink */ }
1883
1924
  throw new OAuthError("auth-oauth/jwe-refused",
1884
1925
  "5-segment JWE id_token refused — verifyIdToken only handles JWS " +
@@ -1892,6 +1933,7 @@ function create(opts) {
1892
1933
  header = safeJson.parse(_b64urlDecode(parts[0]).toString("utf8"), { maxBytes: OAUTH_MAX_RESPONSE_BYTES });
1893
1934
  payload = safeJson.parse(_b64urlDecode(parts[1]).toString("utf8"), { maxBytes: OAUTH_MAX_RESPONSE_BYTES });
1894
1935
  } catch (e) {
1936
+ /* c8 ignore next 2 -- String(e) fallback: the decode error always carries a message */
1895
1937
  throw new OAuthError("auth-oauth/malformed-jwt",
1896
1938
  "ID token header/payload base64 decode failed: " + ((e && e.message) || String(e)));
1897
1939
  }
@@ -1980,6 +2022,7 @@ function create(opts) {
1980
2022
  var verified;
1981
2023
  try {
1982
2024
  verified = nodeCrypto.verify(params.hash, Buffer.from(signingInput, "ascii"), verifyOpts, sig);
2025
+ /* c8 ignore next 5 -- verify cannot raise: the alg/kty/crv cross-check above guarantees a compatible key/params pairing, so a bad signature returns false rather than throwing */
1983
2026
  } catch (verifyErr) {
1984
2027
  throw new OAuthError("auth-oauth/bad-signature",
1985
2028
  "ID token signature verification raised: " +
@@ -2055,6 +2098,7 @@ function create(opts) {
2055
2098
  reason: "cross-realm-jwt-refused",
2056
2099
  primitive: "oauth.verifyIdToken",
2057
2100
  },
2101
+ /* c8 ignore next -- drop-silent observability sink: safeEmit does not throw */
2058
2102
  }); } catch (_e) { /* drop-silent — observability sink */ }
2059
2103
  throw new OAuthError("auth-oauth/iss-mismatch",
2060
2104
  "ID token iss '" + payload.iss + "' does not match expected '" + issuer +
@@ -2335,6 +2379,7 @@ function create(opts) {
2335
2379
  reason: "frontchannel-logout-cross-realm",
2336
2380
  primitive: "oauth.parseFrontchannelLogoutRequest",
2337
2381
  },
2382
+ /* c8 ignore next -- drop-silent observability sink: safeEmit does not throw */
2338
2383
  }); } catch (_e) { /* drop-silent — observability sink */ }
2339
2384
  throw new OAuthError("auth-oauth/frontchannel-logout-iss-mismatch",
2340
2385
  "parseFrontchannelLogoutRequest: iss \"" + iss +
@@ -2423,6 +2468,7 @@ function create(opts) {
2423
2468
  var claims = verified.claims;
2424
2469
 
2425
2470
  // §2.6 — events claim presence + correct shape
2471
+ /* c8 ignore next 5 -- defense-in-depth: verifyIdToken's skipExpCheck self-guard already enforced the backchannel-logout event before returning, so this re-check never fires */
2426
2472
  if (!claims.events || typeof claims.events !== "object" ||
2427
2473
  !claims.events["http://schemas.openid.net/event/backchannel-logout"]) {
2428
2474
  throw new OAuthError("auth-oauth/missing-logout-event",
@@ -2449,6 +2495,7 @@ function create(opts) {
2449
2495
  ? vopts.maxAgeSec
2450
2496
  : DEFAULT_LOGOUT_TOKEN_MAX_AGE_SEC;
2451
2497
  var nowSecLogout = Math.floor(Date.now() / C.TIME.seconds(1));
2498
+ /* c8 ignore next 4 -- defense-in-depth: verifyIdToken's skipExpCheck freshness gate already required a numeric iat before returning, so this re-check never fires */
2452
2499
  if (typeof claims.iat !== "number") {
2453
2500
  throw new OAuthError("auth-oauth/logout-token-no-iat",
2454
2501
  "verifyBackchannelLogoutToken: payload.iat required (OIDC BCL §2.4)");
@@ -2513,6 +2560,7 @@ function create(opts) {
2513
2560
  sub: claims.sub || null,
2514
2561
  sid: claims.sid || null,
2515
2562
  jti: claims.jti || null,
2563
+ /* c8 ignore next -- iat is always a positive number here (enforced above), so the || null arm is unreachable */
2516
2564
  iat: claims.iat || null,
2517
2565
  events: claims.events,
2518
2566
  claims: claims,
@@ -2672,6 +2720,7 @@ function create(opts) {
2672
2720
  if (allowInternal !== null) req.allowInternal = allowInternal;
2673
2721
  Object.assign(req, httpClientOpts);
2674
2722
  var res = await hc.request(req);
2723
+ /* c8 ignore next -- httpClient always yields a Buffer body, so the empty-string arm is unreachable */
2675
2724
  var text = res.body ? res.body.toString("utf8") : "";
2676
2725
  if (res.statusCode < 200 || res.statusCode >= 300) {
2677
2726
  throw new OAuthError("auth-oauth/register-failed-" + res.statusCode,
@@ -2680,6 +2729,7 @@ function create(opts) {
2680
2729
  var parsed;
2681
2730
  try { parsed = safeJson.parse(text, { maxBytes: OAUTH_MAX_RESPONSE_BYTES }); }
2682
2731
  catch (e) {
2732
+ /* c8 ignore next 2 -- String(e) fallback: the parse error always carries a message */
2683
2733
  throw new OAuthError("auth-oauth/bad-register-response",
2684
2734
  "registerClient: response not JSON: " + ((e && e.message) || String(e)));
2685
2735
  }
@@ -2803,13 +2853,16 @@ function create(opts) {
2803
2853
  "deleteClient: " + res.statusCode);
2804
2854
  }
2805
2855
  if (res.statusCode < 200 || res.statusCode >= 300) {
2856
+ /* c8 ignore next -- httpClient always yields a Buffer body, so the empty-string arm is unreachable */
2806
2857
  var errText = res.body ? res.body.toString("utf8").slice(0, 500) : "";
2807
2858
  throw new OAuthError("auth-oauth/dcr-" + method.toLowerCase() + "-failed-" + res.statusCode,
2808
2859
  method.toLowerCase() + "Client: " + res.statusCode + ": " + errText);
2809
2860
  }
2861
+ /* c8 ignore next -- httpClient always yields a Buffer body, so the empty-string arm is unreachable */
2810
2862
  var text = res.body ? res.body.toString("utf8") : "";
2811
2863
  try { return safeJson.parse(text, { maxBytes: OAUTH_MAX_RESPONSE_BYTES }); }
2812
2864
  catch (e) {
2865
+ /* c8 ignore next 2 -- String(e) fallback: the parse error always carries a message */
2813
2866
  throw new OAuthError("auth-oauth/dcr-bad-response",
2814
2867
  method.toLowerCase() + "Client: response not JSON: " + ((e && e.message) || String(e)));
2815
2868
  }
@@ -2937,6 +2990,7 @@ function create(opts) {
2937
2990
  // reinstate buffer mode.
2938
2991
  req.responseMode = "always-resolve";
2939
2992
  var res = await hc.request(req);
2993
+ /* c8 ignore next -- httpClient always yields a Buffer body, so the empty-string arm is unreachable */
2940
2994
  var text = res.body ? res.body.toString("utf8") : "";
2941
2995
  var parsed;
2942
2996
  try { parsed = safeJson.parse(text, { maxBytes: OAUTH_MAX_RESPONSE_BYTES }); }
@@ -2959,6 +3013,7 @@ function create(opts) {
2959
3013
  throw new OAuthError("auth-oauth/device-" + (err || "unknown"),
2960
3014
  "pollDeviceCode: " + (parsed && parsed.error_description ? parsed.error_description : text.slice(0, 200))); // 200-char error-snippet cap, not bytes
2961
3015
  }
3016
+ /* c8 ignore next 2 -- the || fallback is unreachable: reaching this timeout requires a truthy (small) maxWaitMs; a falsy one yields the 10-minute deadline that never expires within a test window */
2962
3017
  throw new OAuthError("auth-oauth/device-poll-timeout",
2963
3018
  "pollDeviceCode: exceeded maxWaitMs " + (popts.maxWaitMs || C.TIME.minutes(10)));
2964
3019
  }
@@ -3180,6 +3235,8 @@ module.exports = {
3180
3235
  buildClientAttestation: buildClientAttestation,
3181
3236
  buildClientAttestationPop: buildClientAttestationPop,
3182
3237
  verifyClientAttestation: verifyClientAttestation,
3238
+ // PKCE (RFC 7636) generator for hand-rolled authorization-code flows.
3239
+ generatePkce: generatePkce,
3183
3240
  // Internal helpers exposed for tests
3184
3241
  _generatePkce: _generatePkce,
3185
3242
  _generateRandomToken: _generateRandomToken,
package/lib/cli.js CHANGED
@@ -1425,15 +1425,21 @@ var MTLS_USAGE = [
1425
1425
  " 'auto' loads whichever form exists.",
1426
1426
  "",
1427
1427
  "Subcommand flags:",
1428
+ " init: [--algorithm ECDSA-P384-SHA384]",
1428
1429
  " issue: --subject <CN> [--days <N>]",
1429
1430
  " issue-p12: --subject <CN> --password <pkcs12-passphrase> [--days <N>] [--out <path>]",
1430
1431
  "",
1431
1432
  "Cert issuance ('init', 'issue', 'issue-p12') uses the framework's",
1432
- "bundled pure-JS engine (lib/mtls-engine-default.js, ECDSA P-384",
1433
- "signatures, AES-256-CBC + HMAC-SHA-512 PBKDF2 PKCS#12 with 2,000,000",
1434
- "iterations). Operators with custom requirements pass a different",
1435
- "engine via b.mtlsCa.create({ engine: ... }) when wiring their app;",
1436
- "the CLI always uses the default.",
1433
+ "bundled pure-JS engine (lib/mtls-engine-default.js) on the vendored",
1434
+ "@blamejs/pki toolkit: post-quantum ML-DSA-87 (FIPS 204) certificate",
1435
+ "signatures by default, which node:tls verifies in a real mutual-auth",
1436
+ "handshake on OpenSSL 3.5. Pass 'init --algorithm ECDSA-P384-SHA384'",
1437
+ "for a classical CA a peer predating OpenSSL 3.5 can verify (the pin",
1438
+ "covers the CA and its leaves). PKCS#12 export keeps AES-256-CBC +",
1439
+ "PBKDF2-HMAC-SHA-512 @ 2,000,000-iter bag protection with a",
1440
+ "tier-dependent integrity MAC (PBMAC1/RFC 9579 for the PQC default,",
1441
+ "RFC 7292 HMAC MacData for the classical bridge). Operators with custom",
1442
+ "requirements pass a different engine via b.mtlsCa.create({ engine: ... }).",
1437
1443
  ].join("\n");
1438
1444
 
1439
1445
  async function _runMtls(args, ctx) {
@@ -1481,6 +1487,10 @@ async function _runMtls(args, ctx) {
1481
1487
  dataDir: dataDir,
1482
1488
  vault: booted.b.vault,
1483
1489
  caKeySealedMode: sealedMode,
1490
+ // --algorithm pins the CA + its leaves; omitted -> the ML-DSA-87 default.
1491
+ // Pass "ECDSA-P384-SHA384" for the classical bridge (a peer predating
1492
+ // OpenSSL 3.5). undefined when the flag is absent.
1493
+ algorithm: args.flags.algorithm,
1484
1494
  // No engine passed — b.mtlsCa falls back to the bundled default
1485
1495
  // (lib/mtls-engine-default.js).
1486
1496
  });
package/lib/daemon.js CHANGED
@@ -72,6 +72,13 @@ var DEFAULT_STOP_TIMEOUT_MS = C.TIME.seconds(30);
72
72
  var DEFAULT_STOP_SIGNAL = "SIGTERM";
73
73
  var DEFAULT_POLL_MS = 100;
74
74
  var DEFAULT_LOG_FILE_MODE = 0o600;
75
+ // A detached child that exits within this window of spawn is treated as a boot
76
+ // death (spawn_failed audit); an abnormal exit after it is a normal run/crash,
77
+ // and a clean exit or an operator stop() is never a spawn failure.
78
+ var BOOT_DEATH_WINDOW_MS = C.TIME.seconds(5);
79
+ // setTimeout clamps a delay above this 32-bit ceiling to ~1ms, which would
80
+ // silently defeat the boot-window loop-hold — so the opt is refused above it.
81
+ var MAX_BOOT_DEATH_WINDOW_MS = 0x7FFFFFFF; // 2,147,483,647
75
82
  // Poll cadence for the Windows cooperative-stop sentinel (a synchronous
76
83
  // existsSync on this interval; see _installStopSentinelWatcher for why it is a
77
84
  // poll and not a filesystem watch). Runs for a foreground daemon's whole
@@ -90,6 +97,71 @@ function _safeAuditEmit(action, outcome, metadata) {
90
97
  var _isLivePid = pidProbe.isLivePid;
91
98
  var _readPidFile = pidProbe.readPidFile;
92
99
 
100
+ // An in-flight stop() writes a `<pidFile>.stopping` marker holding the pid it is
101
+ // stopping. The detached boot-death exit handler consults it so a stop()-induced
102
+ // exit within the boot window is not misread as a spawn failure — a FILESYSTEM
103
+ // marker (not an in-process flag) so it works whether stop() runs in the same
104
+ // process that called start() OR a different one (e.g. a `daemon stop` CLI). The
105
+ // marker carries the target pid so a stale marker from a crashed stopper can
106
+ // only ever suppress that same pid, never a later daemon reusing the pidfile.
107
+ function _stoppingMarkerPath(pidFile) { return pidFile + ".stopping"; }
108
+
109
+ // Reap the boot-dead child's OWN stale pidfile without a check-then-unlink race.
110
+ // A naive `if (read(pidFile) === childPid) unlink(pidFile)` can delete the WRONG
111
+ // file: a fast operator restart that rewrites pidFile with a NEW child's pid
112
+ // between the read and the unlink leaves the new daemon unmanageable via stop().
113
+ //
114
+ // A NON-DESTRUCTIVE ownership pre-check runs first: only when the sidecar still
115
+ // records THIS child's pid do we ATOMICALLY claim it (rename it to a per-pid path)
116
+ // so the confirming check and the removal act on the same exclusive file. A fast
117
+ // restart's replacement pidfile (a different pid) is thus never even temporarily
118
+ // hidden — we leave it untouched. Should a restart land in the tiny window between
119
+ // the pre-check and the claim, the post-claim re-check catches it (the claimed
120
+ // file's pid won't match) and restores the file. `readPid` defaults to the
121
+ // hardened reader and is injectable for tests. Returns whether the reaped sidecar
122
+ // was this child's.
123
+ function _reapOwnStalePidfile(pidFile, childPid, readPid) {
124
+ readPid = readPid || _readPidFile;
125
+ // Pre-check: don't claim (and momentarily hide) a pidfile that isn't ours.
126
+ var preOwned = false;
127
+ try { preOwned = String(readPid(pidFile)) === String(childPid); } catch (_pe) { preOwned = false; }
128
+ if (!preOwned) return false;
129
+ var claim = pidFile + ".reap-" + childPid;
130
+ try {
131
+ atomicFile.renameWithRetry(pidFile, claim); // retries a transient Windows AV/indexer lock
132
+ } catch (_e) {
133
+ return false; // pidFile vanished between the pre-check and the claim (stop() / a restart) — nothing to reap
134
+ }
135
+ // We now exclusively hold `claim`. Verify ownership against it (not pidFile) so
136
+ // the check and the removal act on the same file; a restart that rewrites
137
+ // pidFile after our rename creates a fresh, separate pidfile we never touch.
138
+ var mine = false;
139
+ try { mine = String(readPid(claim)) === String(childPid); } catch (_e2) { mine = false; }
140
+ if (mine) {
141
+ try { nodeFs.unlinkSync(claim); } catch (_e3) { /* best-effort reap — the sidecar is already off pidFile */ }
142
+ } else {
143
+ // Not ours (a fast restart's newer pidfile, or an unreadable one) — put it
144
+ // back so stop() still finds the running daemon's sidecar, but ONLY if nothing
145
+ // newer has since taken pidFile's place. linkSync is atomic and fails with
146
+ // EEXIST when a still-newer daemon already wrote pidFile during our inspection,
147
+ // so the restore never clobbers the newest pidfile with our older claimed one
148
+ // (a plain rename would). A NON-EEXIST failure means hard links aren't
149
+ // available on this filesystem (ENOTSUP on FAT / some network mounts), where
150
+ // nothing newer is present — fall back to a plain rename so the pidfile isn't
151
+ // lost entirely (it may clobber, but losing the running daemon's pidfile is
152
+ // worse). Either way, drop our claim afterward.
153
+ try {
154
+ nodeFs.linkSync(claim, pidFile);
155
+ } catch (linkErr) {
156
+ if (linkErr.code !== "EEXIST") {
157
+ try { atomicFile.renameWithRetry(claim, pidFile); } catch (_re) { /* best-effort restore */ }
158
+ }
159
+ }
160
+ try { nodeFs.unlinkSync(claim); } catch (_e5) { /* consumed by the rename fallback, or already gone */ }
161
+ }
162
+ return mine;
163
+ }
164
+
93
165
  function _validateStartOpts(opts) {
94
166
  validateOpts.shape(opts, {
95
167
  pidFile: { rule: "required-string", code: "daemon/bad-pid-file",
@@ -118,6 +190,15 @@ function _validateStartOpts(opts) {
118
190
  "daemon.start: opts.args requires opts.command");
119
191
  }
120
192
  },
193
+ bootDeathWindowMs: function (value) {
194
+ if (value !== undefined && (typeof value !== "number" || !isFinite(value) ||
195
+ value < 0 || value > MAX_BOOT_DEATH_WINDOW_MS)) {
196
+ throw new DaemonError("daemon/bad-boot-window",
197
+ "daemon.start: opts.bootDeathWindowMs must be a finite number of " +
198
+ "milliseconds in [0, " + MAX_BOOT_DEATH_WINDOW_MS + "] when present " +
199
+ "(a larger delay clamps setTimeout to ~1ms and defeats the boot window)");
200
+ }
201
+ },
121
202
  }, "daemon.start", DaemonError, "daemon/bad-opts");
122
203
  }
123
204
 
@@ -166,6 +247,7 @@ function _maybeReapStale(pidFile) {
166
247
  // Used both by detached-spawn (passed via stdio) and by foreground
167
248
  // redirect of the current process' stdout/stderr.
168
249
  function _openLogFd(logFile) {
250
+ /* c8 ignore next -- every caller gates on a truthy logFile string, so this guard never returns null */
169
251
  if (typeof logFile !== "string" || logFile.length === 0) return null;
170
252
  atomicFile.ensureDir(nodePath.dirname(logFile));
171
253
  // O_NOFOLLOW append: refuse (ELOOP) a symlink planted at the daemon log
@@ -182,6 +264,7 @@ function _openLogFd(logFile) {
182
264
  // pattern for foreground daemons that don't want to lose output when
183
265
  // detached from a terminal.
184
266
  function _redirectStdio(fd) {
267
+ /* c8 ignore next -- only ever called with the numeric fd from _openLogFd; the non-number guard is unreachable */
185
268
  if (typeof fd !== "number") return;
186
269
  function _writer(chunk, encOrCb, maybeCb) {
187
270
  var enc = typeof encOrCb === "string" ? encOrCb : "utf8";
@@ -247,6 +330,7 @@ function _installStopSentinelWatcher(pidFile, orchestrator) {
247
330
  timer = null;
248
331
  }
249
332
  function _maybeFire() {
333
+ /* c8 ignore next -- _stopPolling clears the interval on the first fire, so _maybeFire can't re-enter with fired=true */
250
334
  if (fired) return;
251
335
  // Synchronous existsSync on the main thread — deliberately not an async
252
336
  // stat, so detection never queues behind a saturated libuv threadpool.
@@ -310,6 +394,7 @@ function _installStopSentinelWatcher(pidFile, orchestrator) {
310
394
  * command: string, // executable for detached-fork mode
311
395
  * args: string[], // argv for the detached child
312
396
  * cwd: string, // cwd for the detached child
397
+ * bootDeathWindowMs: number, // detached: keep the parent loop alive this long after spawn to observe a boot death (an abnormal exit in the window is audited as a spawn failure + reaps the pidfile); default 5000, 0 opts out (fire-and-forget)
313
398
  *
314
399
  * @example
315
400
  * var handle = b.daemon.start({
@@ -378,6 +463,12 @@ function start(opts) {
378
463
  throw new DaemonError("daemon/spawn-failed",
379
464
  "daemon.start: spawn failed: " + ((e && e.message) || String(e)));
380
465
  }
466
+ // Boot-death window is measured from the moment the child was spawned. An
467
+ // abnormal exit within it is a boot failure; a later one is a normal
468
+ // run/crash. Operators tune it for a slow-booting child (default 5s).
469
+ var spawnedAt = Date.now();
470
+ var bootWindowMs = (typeof opts.bootDeathWindowMs === "number")
471
+ ? opts.bootDeathWindowMs : BOOT_DEATH_WINDOW_MS;
381
472
  // A bad command does NOT throw synchronously from spawn — child_process
382
473
  // reports it ASYNC via a 'error' event, with child.pid left undefined. The
383
474
  // sync try/catch above only covers spawn() itself, so without this the old
@@ -385,7 +476,18 @@ function start(opts) {
385
476
  // one-shot 'error' handler that reaps the sidecar + audits the failure, then
386
477
  // refuse to proceed for a child that never got a pid.
387
478
  child.on("error", function (err) {
388
- try { nodeFs.unlinkSync(pidFile); } catch (_e) { /* best-effort may not exist */ }
479
+ // Reap ONLY a pidfile this child actually wrote. A numeric pid means the
480
+ // sync path below wrote one, and the claim-then-verify reap removes only its
481
+ // own sidecar (never a fast restart's). A no-pid spawn failure (child.pid
482
+ // undefined) throws below BEFORE any pidfile write, so this late-firing
483
+ // callback must not touch pidFile at all — else a caller that catches that
484
+ // sync throw and retries with the same pidFile would have its replacement
485
+ // daemon's pidfile deleted. (A numeric-but-invalid pid never reaches here:
486
+ // child_process yields a positive int or undefined, and either way the
487
+ // claim-then-verify reap only removes a sidecar recording that exact pid.)
488
+ if (typeof child.pid === "number") {
489
+ _reapOwnStalePidfile(pidFile, child.pid);
490
+ }
389
491
  _safeAuditEmit("daemon.spawn_failed", "failure", {
390
492
  pidFile: pidFile,
391
493
  command: opts.command,
@@ -408,9 +510,76 @@ function start(opts) {
408
510
  // Write the child's PID via atomic temp+rename so a concurrent
409
511
  // observer never sees a half-written pidFile.
410
512
  atomicFile.ensureDir(nodePath.dirname(pidFile));
513
+ // Clear any STALE stop marker before claiming this pidfile: a stopper that was
514
+ // SIGKILLed mid-stop leaves `<pidFile>.stopping` behind, and if the OS later
515
+ // reuses that stopped pid for THIS fresh child, the stale marker would
516
+ // wrongly suppress a genuine boot-death audit. A fresh start means no stop is
517
+ // in flight, so the marker can only be stale.
518
+ try { nodeFs.unlinkSync(_stoppingMarkerPath(pidFile)); } catch (_sm) { /* best-effort — usually absent */ }
411
519
  var pidStr = String(child.pid) + "\n";
412
520
  atomicFile.writeSync(pidFile, pidStr, { fileMode: 0o600 });
413
- // Detach so the child survives parent exit.
521
+ // A detached child can spawn cleanly (valid pid above) yet DIE AT BOOT —
522
+ // exit before it ever serves. The synchronous success handle is already
523
+ // committed (detached mode returns immediately, so the sync return contract
524
+ // stands), so recover asynchronously: a one-shot 'exit' handler reaps the
525
+ // sidecar we just wrote — but ONLY when the pidFile still records THIS
526
+ // child's pid, so a fast operator restart that rewrote it is never
527
+ // clobbered — and audits the boot death so it leaves a trail instead of a
528
+ // silently-stranded pidfile that daemon.stop would misread as running.
529
+ // bootWatch (installed after this handler) holds the parent loop open through
530
+ // the boot window so a short-lived launcher can't exit before observing the
531
+ // death; the handler clears it the instant the child exits.
532
+ var bootWatch = null;
533
+ child.on("exit", function (code, signal) {
534
+ if (bootWatch) { clearTimeout(bootWatch); bootWatch = null; } // death observed — release the loop
535
+ // Was the sidecar still OURS at exit? Atomically claim-then-verify (see
536
+ // _reapOwnStalePidfile) so a fast operator restart that rewrote the pidfile
537
+ // can't make us delete the NEW daemon's sidecar, and so the boot-death
538
+ // signal survives a swallowed unlink failure. A stop() or a restart that
539
+ // rewrote/cleared it means someone else owns it now — not a boot death.
540
+ var wasOurs = _reapOwnStalePidfile(pidFile, child.pid);
541
+ // Only a BOOT DEATH is a spawn failure: an ABNORMAL exit (non-zero code or
542
+ // a terminating signal) SHORTLY after spawn, while the sidecar was still
543
+ // ours (nobody stop()'d it). A clean exit (code 0), a later run/crash, or
544
+ // an operator stop() is NOT a spawn failure — auditing those as one emits a
545
+ // contradictory failure alongside the daemon.stopped record.
546
+ var abnormal = (typeof code === "number" && code !== 0) || signal != null;
547
+ var withinBoot = (Date.now() - spawnedAt) <= bootWindowMs;
548
+ // stop() (this process OR another) sends SIGTERM but unlinks the pidfile
549
+ // only after observing the exit, so wasOurs is still true here. A
550
+ // `<pidFile>.stopping` marker holding THIS child's pid means an operator
551
+ // stop is in flight, so the exit is intentional — not a boot death that
552
+ // should emit spawn_failed right before daemon.stopped.
553
+ // Read the marker through the HARDENED pid-sidecar reader (1 KiB cap,
554
+ // refuse-symlink, positive-int parse, null on any failure) — the marker
555
+ // lives in the pidfile directory, so a raw readFileSync here would follow a
556
+ // planted symlink or buffer an unbounded file (CWE-59 / DoS), the exact
557
+ // threat _readPidFile hardens the pid read against. null (no/garbage marker)
558
+ // !== child.pid, so a missing marker correctly reads as "not being stopped".
559
+ var beingStopped = _readPidFile(_stoppingMarkerPath(pidFile)) === child.pid;
560
+ if (wasOurs && abnormal && withinBoot && !beingStopped) {
561
+ _safeAuditEmit("daemon.spawn_failed", "failure", {
562
+ pidFile: pidFile,
563
+ command: opts.command,
564
+ exitCode: code,
565
+ signal: signal || null,
566
+ });
567
+ }
568
+ });
569
+ // Keep the parent event loop alive through the boot-death window so the exit
570
+ // handler above can actually observe a child that dies at boot. A short-lived
571
+ // launcher (e.g. `blamejs daemon start`) would otherwise reach child.unref()
572
+ // and exit before the child dies — stranding the pidfile for a later stop()
573
+ // to misread, the very failure the handler exists to prevent. The timer is
574
+ // ref'd (holds the loop), is cleared the instant the child exits, and
575
+ // otherwise fires a no-op once the window elapses (boot succeeded → release
576
+ // the loop, which child.unref() no longer holds). bootDeathWindowMs:0 opts
577
+ // out entirely: no monitor, immediate exit (historical fire-and-forget).
578
+ if (bootWindowMs > 0) {
579
+ bootWatch = setTimeout(function () { bootWatch = null; }, bootWindowMs);
580
+ }
581
+ // Detach so a HEALTHY long-running child never holds the parent open past the
582
+ // boot window (bootWatch is the only remaining ref, and it self-clears).
414
583
  try { child.unref(); } catch (_u) { /* best-effort */ }
415
584
  if (typeof logFd === "number") {
416
585
  // Parent doesn't need its handle to the log; child inherited it.
@@ -446,6 +615,7 @@ function start(opts) {
446
615
  logFdForeground = _openLogFd(logFile);
447
616
  _redirectStdio(logFdForeground);
448
617
  } catch (e) {
618
+ /* c8 ignore next -- pidLock.release() swallows its own fs errors, so this guard never catches */
449
619
  try { lock.release(); } catch (_r) { /* best-effort */ }
450
620
  throw new DaemonError("daemon/log-open-failed",
451
621
  "daemon.start: failed to open logFile '" + logFile + "': " +
@@ -469,7 +639,9 @@ function start(opts) {
469
639
  {
470
640
  name: "pidLock-release",
471
641
  run: function () {
642
+ /* c8 ignore next -- close() delegates to _stopPolling, which self-catches, so stopWatcher.close never throws */
472
643
  if (stopWatcher) { try { stopWatcher.close(); } catch (_w) { /* best-effort */ } }
644
+ /* c8 ignore next -- pidLock.release() swallows its own fs errors, so this guard never catches */
473
645
  try { lock.release(); } catch (_e) { /* best-effort */ }
474
646
  if (logFdForeground !== null) {
475
647
  try { nodeFs.closeSync(logFdForeground); } catch (_c) { /* best-effort */ }
@@ -555,6 +727,23 @@ async function stop(opts) {
555
727
  return { stopped: false, pid: pid, reason: "stale" };
556
728
  }
557
729
 
730
+ // Publish a `<pidFile>.stopping` marker (holding the pid we are stopping) so the
731
+ // boot-death exit handler — in THIS process or the still-alive starter of a
732
+ // cross-process stop — treats the SIGTERM-induced exit as intentional, not a
733
+ // boot death. The finally removes it on every exit (return OR a kill-failed
734
+ // throw) so a later start() at the same path is never suppressed.
735
+ var stopMarker = _stoppingMarkerPath(pidFile);
736
+ try { atomicFile.writeSync(stopMarker, String(pid), { fileMode: 0o600 }); } catch (_w) { /* best-effort hint */ }
737
+ try {
738
+ return await _stopLivePid(pidFile, pid, signal, timeoutMs, pollMs, opts);
739
+ } finally {
740
+ try { nodeFs.unlinkSync(stopMarker); } catch (_u) { /* best-effort */ }
741
+ }
742
+ }
743
+
744
+ // Signal a confirmed-live pid and wait for exit, escalating SIGTERM -> SIGKILL.
745
+ // Extracted from stop() so the .stopping marker wraps every exit via try/finally.
746
+ async function _stopLivePid(pidFile, pid, signal, timeoutMs, pollMs, opts) {
558
747
  var t0 = Date.now();
559
748
 
560
749
  // Windows has no cooperative signal: process.kill(pid, "SIGTERM") maps to
@@ -733,4 +922,5 @@ module.exports = {
733
922
  DEFAULT_STOP_SIGNAL: DEFAULT_STOP_SIGNAL,
734
923
  DEFAULT_STOP_TIMEOUT_MS: DEFAULT_STOP_TIMEOUT_MS,
735
924
  _resetForTest: _resetForTest,
925
+ _reapOwnStalePidfile: _reapOwnStalePidfile,
736
926
  };
@@ -27,6 +27,7 @@
27
27
  var { defineClass } = require("./framework-error");
28
28
  var gateContract = require("./gate-contract");
29
29
  var codepointClass = require("./codepoint-class");
30
+ var pick = require("./pick");
30
31
 
31
32
  var GuardTenantIdError = defineClass("GuardTenantIdError", { alwaysPermanent: true });
32
33
 
@@ -78,10 +79,20 @@ function validate(tenantId, opts) {
78
79
  throw new GuardTenantIdError("tenant-id/oversize",
79
80
  "guardTenantId.validate: tenantId exceeds maxBytes=" + profile.maxBytes);
80
81
  }
81
- if (RESERVED[tenantId]) {
82
+ if (Object.prototype.hasOwnProperty.call(RESERVED, tenantId)) {
82
83
  throw new GuardTenantIdError("tenant-id/reserved",
83
84
  "guardTenantId.validate: tenantId '" + tenantId + "' is framework-reserved");
84
85
  }
86
+ // Refuse the prototype-pollution key names outright via the framework's single
87
+ // poisoned-key predicate (lib/pick.js). The own-property RESERVED check above
88
+ // (deliberately) will not match these as inherited keys, and a tenant id used to
89
+ // key a plain-object store must never be __proto__ / constructor / prototype (or
90
+ // an operator-registered dangerous name), which would pollute the prototype
91
+ // chain instead of isolating the tenant.
92
+ if (pick.isPoisonedKey(tenantId)) {
93
+ throw new GuardTenantIdError("tenant-id/reserved",
94
+ "guardTenantId.validate: tenantId '" + tenantId + "' is a prototype-pollution key name");
95
+ }
85
96
  if (tenantId.charAt(0) === ".") {
86
97
  throw new GuardTenantIdError("tenant-id/hidden",
87
98
  "guardTenantId.validate: tenantId cannot start with '.'");