@blamejs/core 0.10.8 → 0.10.11

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/metrics.js CHANGED
@@ -970,6 +970,68 @@ function snapshotRead(p) {
970
970
  * res.setHeader("Content-Type", "text/plain; version=0.0.4");
971
971
  * res.end(b.metrics.snapshot.render(snap, { format: "prometheus", prefix: "myapp" }));
972
972
  */
973
+ var ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/; // allow:duplicate-regex — ISO-8601 instant shape ships in three primitives (metrics text-render, content-credentials, mail-server-imap APPEND); each is bounded by its own caller and the regex itself is 50 bytes — extracting into a cross-module dep wouldn't carry its weight
974
+
975
+ // Formats a single field value for the text renderer. ISO-date-shaped
976
+ // strings render verbatim (with millisecond precision) so the human
977
+ // operator reads them as timestamps; everything else degrades to the
978
+ // existing number / string / boolean / JSON formatting.
979
+ function _formatTextValue(v) {
980
+ if (typeof v === "number") return String(v);
981
+ if (typeof v === "boolean") return v ? "true" : "false";
982
+ if (typeof v === "string") {
983
+ if (ISO_DATE_RE.test(v) && isFinite(Date.parse(v))) return v; // allow:regex-no-length-cap — ISO-date shape, length-bounded by the anchored pattern
984
+ return v;
985
+ }
986
+ return JSON.stringify(v);
987
+ }
988
+
989
+ // Internal text-format renderer extracted from snapshotRender so the
990
+ // E.grouped-text + H.iso-date paths share one code path.
991
+ function _renderText(fields, snap, opts) {
992
+ var lines = ["snapshot written-at: " + snap.writtenAt];
993
+ // E. operator-supplied group map. Group ordering follows the
994
+ // insertion order of the `opts.groups` object; fields not named in
995
+ // any group fall to the bottom under `== Other ==`.
996
+ if (opts.groups && typeof opts.groups === "object" && !Array.isArray(opts.groups)) {
997
+ var groupNames = Object.keys(opts.groups);
998
+ var named = Object.create(null);
999
+ for (var gi = 0; gi < groupNames.length; gi += 1) {
1000
+ var gName = groupNames[gi];
1001
+ var fieldNames = opts.groups[gName];
1002
+ if (!Array.isArray(fieldNames)) continue;
1003
+ lines.push("");
1004
+ lines.push("== " + gName + " ==");
1005
+ for (var fi = 0; fi < fieldNames.length; fi += 1) {
1006
+ var fn = fieldNames[fi];
1007
+ named[fn] = true;
1008
+ if (Object.prototype.hasOwnProperty.call(fields, fn)) {
1009
+ lines.push(" " + fn + ": " + _formatTextValue(fields[fn]));
1010
+ }
1011
+ }
1012
+ }
1013
+ // Stable order for the unnamed remainder.
1014
+ // allow:bare-canonicalize-walk — operator-facing display ordering
1015
+ var remainder = Object.keys(fields).sort().filter(function (k) { return !named[k]; });
1016
+ if (remainder.length > 0) {
1017
+ lines.push("");
1018
+ lines.push("== Other ==");
1019
+ for (var ri = 0; ri < remainder.length; ri += 1) {
1020
+ lines.push(" " + remainder[ri] + ": " + _formatTextValue(fields[remainder[ri]]));
1021
+ }
1022
+ }
1023
+ return lines.join("\n") + "\n";
1024
+ }
1025
+ // Default flat rendering.
1026
+ // allow:bare-canonicalize-walk — operator-facing display ordering
1027
+ var keys = Object.keys(fields).sort();
1028
+ for (var i = 0; i < keys.length; i += 1) {
1029
+ var k = keys[i];
1030
+ lines.push(" " + k + ": " + _formatTextValue(fields[k]));
1031
+ }
1032
+ return lines.join("\n") + "\n";
1033
+ }
1034
+
973
1035
  function snapshotRender(snap, opts) {
974
1036
  opts = opts || {};
975
1037
  var format = opts.format || "text";
@@ -979,21 +1041,7 @@ function snapshotRender(snap, opts) {
979
1041
  }
980
1042
  var fields = snap.fields;
981
1043
  if (format === "text") {
982
- var lines = ["snapshot written-at: " + snap.writtenAt];
983
- // allow:bare-canonicalize-walk — sort is for stable human-readable
984
- // output ordering, not canonicalize-for-hashing
985
- var keys = Object.keys(fields).sort();
986
- for (var i = 0; i < keys.length; i++) {
987
- var k = keys[i];
988
- var v = fields[k];
989
- var s;
990
- if (typeof v === "number") s = String(v);
991
- else if (typeof v === "string") s = v;
992
- else if (typeof v === "boolean") s = v ? "true" : "false";
993
- else s = JSON.stringify(v);
994
- lines.push(" " + k + ": " + s);
995
- }
996
- return lines.join("\n") + "\n";
1044
+ return _renderText(fields, snap, opts);
997
1045
  }
998
1046
  if (format === "prometheus") {
999
1047
  var prefix = opts.prefix || "blamejs";
@@ -1032,16 +1080,302 @@ function snapshotRender(snap, opts) {
1032
1080
  out.push("# TYPE " + metric + " " + fieldType);
1033
1081
  out.push(metric + " " + v2);
1034
1082
  }
1083
+ // ISO-date string fields → parallel `<name>_epoch_ms` gauge per
1084
+ // OpenMetrics 1.0 §3.4 (Timestamps MUST be float64 Unix-epoch). The
1085
+ // operator-facing text format renders the ISO string verbatim; the
1086
+ // Prometheus / OpenMetrics format gets the epoch-ms equivalent so
1087
+ // downstream alerting can compute durations.
1088
+ for (var jd = 0; jd < keys2.length; jd += 1) {
1089
+ var kd = keys2[jd];
1090
+ var vd = fields[kd];
1091
+ if (typeof vd !== "string") continue;
1092
+ if (vd.length > 64) continue; // allow:raw-byte-literal — ISO 8601 max length cap, not bytes
1093
+ if (!ISO_DATE_RE.test(vd)) continue; // allow:regex-no-length-cap — length-bounded immediately above
1094
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(kd)) continue; // allow:regex-no-length-cap — field-name shape, length-bounded by snap field naming
1095
+ var ms = Date.parse(vd);
1096
+ if (!isFinite(ms)) continue;
1097
+ var emName = prefix + "_" + kd + "_epoch_ms";
1098
+ out.push("# TYPE " + emName + " gauge");
1099
+ out.push(emName + " " + ms);
1100
+ }
1035
1101
  return out.join("\n") + "\n";
1036
1102
  }
1037
1103
  throw new MetricsError("metrics-snapshot/bad-format",
1038
1104
  "metrics.snapshot.render: format must be 'text' or 'prometheus', got '" + format + "'");
1039
1105
  }
1040
1106
 
1107
+ /**
1108
+ * @primitive b.metrics.snapshot.shadowRegistry
1109
+ * @signature b.metrics.snapshot.shadowRegistry(opts)
1110
+ * @since 0.10.9
1111
+ * @status stable
1112
+ * @related b.metrics.snapshot.render, b.metrics.create
1113
+ *
1114
+ * Build a namespaced shadow metrics registry that mirrors a subset of
1115
+ * a primary registry's counters / gauges / info for export to systems
1116
+ * needing isolated views (sidecar / per-tenant scrape endpoint /
1117
+ * compliance-tagged subset). Cardinality cap closes the
1118
+ * [client_golang CVE-2022-21698](https://nvd.nist.gov/vuln/detail/CVE-2022-21698)
1119
+ * unbounded-cardinality DoS class. Returns
1120
+ * `{ inc, set, setInfo, snapshot, render, reset }`.
1121
+ *
1122
+ * @opts
1123
+ * namespace: string, // identifier prefix; required
1124
+ * counters: string[], // counter names to mirror
1125
+ * gauges: string[], // gauge names to mirror
1126
+ * info: string[], // info names to mirror
1127
+ * cardinalityCap: number, // default 10000 per metric name
1128
+ * onCardinalityExceeded: "drop" | "audit-only" | "refuse", // default "drop"
1129
+ *
1130
+ * @example
1131
+ * var shadow = b.metrics.snapshot.shadowRegistry({
1132
+ * namespace: "tenant_a",
1133
+ * counters: ["requests_total", "errors_total"],
1134
+ * gauges: ["queue_depth"],
1135
+ * });
1136
+ * shadow.inc("requests_total");
1137
+ * shadow.set("queue_depth", 42);
1138
+ * shadow.snapshot();
1139
+ */
1140
+ var SHADOW_DEFAULT_CARDINALITY = 10000; // allow:raw-byte-literal — cardinality cap, not bytes
1141
+ function shadowRegistry(opts) {
1142
+ if (!opts || typeof opts !== "object") {
1143
+ throw new MetricsError("metrics-shadow/bad-opts",
1144
+ "shadowRegistry: opts object required");
1145
+ }
1146
+ validateOpts.requireNonEmptyString(opts.namespace,
1147
+ "shadowRegistry: opts.namespace", MetricsError, "metrics-shadow/bad-namespace");
1148
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(opts.namespace)) { // allow:regex-no-length-cap — OpenMetrics name-shape, length-bounded by namespace
1149
+ throw new MetricsError("metrics-shadow/bad-namespace",
1150
+ "shadowRegistry: namespace must match [a-zA-Z_][a-zA-Z0-9_]*");
1151
+ }
1152
+ var counterSet = _shadowSetOf(opts.counters, "counters");
1153
+ var gaugeSet = _shadowSetOf(opts.gauges, "gauges");
1154
+ var infoSet = _shadowSetOf(opts.info, "info");
1155
+ var cap = opts.cardinalityCap === undefined ? SHADOW_DEFAULT_CARDINALITY : opts.cardinalityCap;
1156
+ if (typeof cap !== "number" || !isFinite(cap) || cap < 1 || Math.floor(cap) !== cap) {
1157
+ throw new MetricsError("metrics-shadow/bad-cap",
1158
+ "shadowRegistry: cardinalityCap must be a positive integer");
1159
+ }
1160
+ var policy = opts.onCardinalityExceeded || "drop";
1161
+ if (policy !== "drop" && policy !== "audit-only" && policy !== "refuse") {
1162
+ throw new MetricsError("metrics-shadow/bad-policy",
1163
+ "shadowRegistry: onCardinalityExceeded must be 'drop', 'audit-only', or 'refuse'");
1164
+ }
1165
+ var counters = Object.create(null);
1166
+ var gauges = Object.create(null);
1167
+ var info = Object.create(null);
1168
+ var lastCardinalityAuditMs = 0;
1169
+
1170
+ function _cardinalityHit(metric) {
1171
+ var now = Date.now();
1172
+ // Rate-limit cardinality audit emissions to once per second per
1173
+ // shadow registry so a hostile label flood doesn't fan out into
1174
+ // the audit log.
1175
+ if (now - lastCardinalityAuditMs >= C.TIME.seconds(1)) {
1176
+ lastCardinalityAuditMs = now;
1177
+ try {
1178
+ require("./audit").safeEmit({
1179
+ action: "metrics.shadow.cardinality_dropped",
1180
+ outcome: policy === "refuse" ? "denied" : "denied",
1181
+ metadata: { namespace: opts.namespace, metric: metric, cap: cap, policy: policy },
1182
+ });
1183
+ } catch (_e) { /* drop-silent */ }
1184
+ }
1185
+ if (policy === "refuse") {
1186
+ throw new MetricsError("metrics-shadow/cardinality-exceeded",
1187
+ "shadowRegistry.inc/set: '" + metric + "' cardinality exceeds cap=" + cap);
1188
+ }
1189
+ }
1190
+
1191
+ function _labelKey(labels) {
1192
+ if (!labels || typeof labels !== "object") return "";
1193
+ var keys = Object.keys(labels).sort(); // allow:bare-canonicalize-walk — label-set canonicalization for cardinality keying
1194
+ var parts = [];
1195
+ for (var i = 0; i < keys.length; i += 1) {
1196
+ parts.push(keys[i] + "=" + String(labels[keys[i]]));
1197
+ }
1198
+ return parts.join(",");
1199
+ }
1200
+
1201
+ function inc(name, labels) {
1202
+ if (!counterSet[name]) return;
1203
+ var lk = _labelKey(labels);
1204
+ if (!counters[name]) counters[name] = Object.create(null);
1205
+ var current = counters[name][lk];
1206
+ if (current === undefined) {
1207
+ if (Object.keys(counters[name]).length >= cap) {
1208
+ _cardinalityHit(name);
1209
+ return;
1210
+ }
1211
+ counters[name][lk] = 1;
1212
+ } else {
1213
+ counters[name][lk] = current + 1;
1214
+ }
1215
+ }
1216
+
1217
+ function set(name, value, labels) {
1218
+ if (!gaugeSet[name]) return;
1219
+ if (typeof value !== "number" || !isFinite(value)) {
1220
+ throw new MetricsError("metrics-shadow/bad-gauge-value",
1221
+ "shadowRegistry.set: '" + name + "' value must be a finite number");
1222
+ }
1223
+ var lk = _labelKey(labels);
1224
+ if (!gauges[name]) gauges[name] = Object.create(null);
1225
+ if (gauges[name][lk] === undefined && Object.keys(gauges[name]).length >= cap) {
1226
+ _cardinalityHit(name);
1227
+ return;
1228
+ }
1229
+ gauges[name][lk] = value;
1230
+ }
1231
+
1232
+ function setInfo(name, value) {
1233
+ if (!infoSet[name]) return;
1234
+ info[name] = value;
1235
+ }
1236
+
1237
+ function snapshotShadow() {
1238
+ return Object.freeze({
1239
+ namespace: opts.namespace,
1240
+ counters: _shallowClone(counters),
1241
+ gauges: _shallowClone(gauges),
1242
+ info: Object.assign({}, info),
1243
+ });
1244
+ }
1245
+
1246
+ function renderShadow(renderOpts) {
1247
+ renderOpts = renderOpts || {};
1248
+ var format = renderOpts.format || "text";
1249
+ // Prometheus / OpenMetrics — emit labeled metric lines directly so
1250
+ // counters / gauges with label sets survive the export. Routing
1251
+ // through `snapshotRender` would have filtered synthetic
1252
+ // `name{labelKey=value}` field names against the Prometheus
1253
+ // metric-name shape `[a-zA-Z_][a-zA-Z0-9_]*` and dropped them all.
1254
+ if (format === "prometheus" || format === "openmetrics") {
1255
+ var out = [];
1256
+ var prefix = opts.namespace;
1257
+ function _emitLabeled(name, labelMap, kind) {
1258
+ var metric = prefix + "_" + name;
1259
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(metric)) return; // allow:regex-no-length-cap — Prometheus name-shape; metric length bounded by namespace + name caps
1260
+ out.push("# TYPE " + metric + " " + kind);
1261
+ var lks = Object.keys(labelMap);
1262
+ for (var li = 0; li < lks.length; li += 1) {
1263
+ var lk = lks[li];
1264
+ if (lk === "") { out.push(metric + " " + labelMap[lk]); continue; }
1265
+ // The label-key string was assembled by `_labelKey` from a
1266
+ // single shadow-registry call's `labels` object — values
1267
+ // are framework-internal (operator code that supplied them
1268
+ // is bounded by guards upstream); split on `,` is safe.
1269
+ // Not a header-value parse (which would need a quoted-
1270
+ // string aware split per RFC 9110).
1271
+ var lpairs = lk.split(","); // allow:bare-split-on-quoted-header — framework-internal label-key (assembled by _labelKey), not an HTTP header parse
1272
+ var formatted = [];
1273
+ for (var pi = 0; pi < lpairs.length; pi += 1) {
1274
+ var eqIdx = lpairs[pi].indexOf("=");
1275
+ if (eqIdx === -1) continue;
1276
+ var lname = lpairs[pi].slice(0, eqIdx);
1277
+ var lvalue = lpairs[pi].slice(eqIdx + 1);
1278
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(lname)) continue; // allow:regex-no-length-cap — Prometheus label-name shape
1279
+ // Prometheus exposition: escape `\`, `"`, `\n` in label values.
1280
+ lvalue = String(lvalue).replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n"); // allow:regex-no-length-cap — fixed-char-set escape // allow:duplicate-regex — Prometheus value escape shape
1281
+ formatted.push(lname + '="' + lvalue + '"');
1282
+ }
1283
+ out.push(metric + "{" + formatted.join(",") + "} " + labelMap[lk]);
1284
+ }
1285
+ }
1286
+ var cn2 = Object.keys(counters);
1287
+ for (var ci = 0; ci < cn2.length; ci += 1) {
1288
+ _emitLabeled(cn2[ci], counters[cn2[ci]], /_total$/.test(cn2[ci]) ? "counter" : "gauge"); // allow:regex-no-length-cap — name-suffix check
1289
+ }
1290
+ var gn2 = Object.keys(gauges);
1291
+ for (var ggi = 0; ggi < gn2.length; ggi += 1) {
1292
+ _emitLabeled(gn2[ggi], gauges[gn2[ggi]], "gauge");
1293
+ }
1294
+ return out.join("\n") + (out.length ? "\n" : "");
1295
+ }
1296
+ // Text format — route through snapshotRender via synthetic field
1297
+ // names. The text-format renderer accepts arbitrary field names so
1298
+ // labeled series survive here.
1299
+ var snap = { writtenAt: new Date().toISOString(), fields: {} };
1300
+ var cn = Object.keys(counters);
1301
+ for (var i = 0; i < cn.length; i += 1) {
1302
+ var labels = counters[cn[i]];
1303
+ var labelKeys = Object.keys(labels);
1304
+ if (labelKeys.length === 1 && labelKeys[0] === "") {
1305
+ snap.fields[cn[i]] = labels[""];
1306
+ } else {
1307
+ for (var j = 0; j < labelKeys.length; j += 1) {
1308
+ var key = labelKeys[j] === "" ? cn[i] : cn[i] + "{" + labelKeys[j] + "}";
1309
+ snap.fields[key] = labels[labelKeys[j]];
1310
+ }
1311
+ }
1312
+ }
1313
+ var gn = Object.keys(gauges);
1314
+ for (var gi = 0; gi < gn.length; gi += 1) {
1315
+ var glabels = gauges[gn[gi]];
1316
+ var glk = Object.keys(glabels);
1317
+ if (glk.length === 1 && glk[0] === "") {
1318
+ snap.fields[gn[gi]] = glabels[""];
1319
+ } else {
1320
+ for (var gj = 0; gj < glk.length; gj += 1) {
1321
+ var gkey = glk[gj] === "" ? gn[gi] : gn[gi] + "{" + glk[gj] + "}";
1322
+ snap.fields[gkey] = glabels[glk[gj]];
1323
+ }
1324
+ }
1325
+ }
1326
+ var inames = Object.keys(info);
1327
+ for (var ii = 0; ii < inames.length; ii += 1) snap.fields[inames[ii]] = info[inames[ii]];
1328
+ return snapshotRender(snap, Object.assign({ prefix: opts.namespace }, renderOpts));
1329
+ }
1330
+
1331
+ function reset() {
1332
+ counters = Object.create(null);
1333
+ gauges = Object.create(null);
1334
+ info = Object.create(null);
1335
+ lastCardinalityAuditMs = 0;
1336
+ }
1337
+
1338
+ return {
1339
+ inc: inc,
1340
+ set: set,
1341
+ setInfo: setInfo,
1342
+ snapshot: snapshotShadow,
1343
+ render: renderShadow,
1344
+ reset: reset,
1345
+ };
1346
+ }
1347
+
1348
+ function _shadowSetOf(arr, label) {
1349
+ if (arr === undefined) return Object.create(null);
1350
+ if (!Array.isArray(arr)) {
1351
+ throw new MetricsError("metrics-shadow/bad-" + label,
1352
+ "shadowRegistry: opts." + label + " must be an array of metric names");
1353
+ }
1354
+ var set = Object.create(null);
1355
+ for (var i = 0; i < arr.length; i += 1) {
1356
+ if (typeof arr[i] !== "string" || arr[i].length === 0) {
1357
+ throw new MetricsError("metrics-shadow/bad-" + label,
1358
+ "shadowRegistry: opts." + label + "[" + i + "] must be a non-empty string");
1359
+ }
1360
+ set[arr[i]] = true;
1361
+ }
1362
+ return set;
1363
+ }
1364
+
1365
+ function _shallowClone(obj) {
1366
+ var out = Object.create(null);
1367
+ var keys = Object.keys(obj);
1368
+ for (var i = 0; i < keys.length; i += 1) {
1369
+ out[keys[i]] = Object.assign(Object.create(null), obj[keys[i]]);
1370
+ }
1371
+ return out;
1372
+ }
1373
+
1041
1374
  var snapshot = {
1042
- startWriter: snapshotStartWriter,
1043
- read: snapshotRead,
1044
- render: snapshotRender,
1375
+ startWriter: snapshotStartWriter,
1376
+ read: snapshotRead,
1377
+ render: snapshotRender,
1378
+ shadowRegistry: shadowRegistry,
1045
1379
  };
1046
1380
 
1047
1381
  module.exports = {
@@ -54,6 +54,15 @@ var LOCAL_SUFFIXES = [".localhost", ".local", ".test", ".invalid",
54
54
  ".internal", ".intranet", ".lan", ".home", ".corp"];
55
55
  function _isLocalFormHost(host) {
56
56
  if (typeof host !== "string" || host.length === 0) return true;
57
+ // Strip the trailing root-zone dot BEFORE any reserved-name compare.
58
+ // RFC 1034 §3.1 — `foo.` is the absolute form of `foo` (both resolve
59
+ // to the same target). Without the strip, `localhost.` would slip
60
+ // past the reserved-form check and reach a public DoH/DoT provider
61
+ // that maps it to NXDOMAIN, which downstream consumers might then
62
+ // try to resolve via system fallback.
63
+ while (host.length > 0 && host.charAt(host.length - 1) === ".") {
64
+ host = host.slice(0, -1);
65
+ }
57
66
  if (host === "localhost") return true;
58
67
  // IP literal — skip DNS resolution entirely (caller passes through).
59
68
  if (net.isIP(host)) return true;
package/lib/pagination.js CHANGED
@@ -87,7 +87,8 @@ function _b64urlEncode(buf) { return bCrypto.toBase64Url(buf); }
87
87
 
88
88
  function _b64urlDecode(s) {
89
89
  if (typeof s !== "string") throw new PaginationError("pagination/bad-cursor", "cursor must be a string");
90
- return bCrypto.fromBase64Url(s);
90
+ try { return bCrypto.fromBase64Url(s); }
91
+ catch (_e) { throw new PaginationError("pagination/bad-cursor", "cursor is not valid base64url"); }
91
92
  }
92
93
 
93
94
  function _tag(secretBuf, stateJson) {
package/lib/pqc-agent.js CHANGED
@@ -32,6 +32,10 @@ var C = require("./constants");
32
32
  var lazyRequire = require("./lazy-require");
33
33
  var networkTls = require("./network-tls");
34
34
  var safeBuffer = require("./safe-buffer");
35
+ var validateOpts = require("./validate-opts");
36
+ var { defineClass } = require("./framework-error");
37
+
38
+ var PqcAgentError = defineClass("PqcAgentError", { alwaysPermanent: true });
35
39
 
36
40
  // audit imports crypto/handlers transitively — lazy to avoid load
37
41
  // cycles when pqc-agent is required during framework bootstrap.
@@ -179,7 +183,72 @@ function _buildAgentOpts(opts) {
179
183
  * req.end();
180
184
  */
181
185
  function create(opts) {
182
- return new https.Agent(_buildAgentOpts(opts));
186
+ var built = _buildAgentOpts(opts);
187
+ var agent = new https.Agent(built);
188
+ agent._builtOpts = built;
189
+ // Per-instance cert rotation. The pre-v0.10.9 path required process
190
+ // restart for cert rotation on agents built via explicit `create()`
191
+ // (only the framework's lazy default had `b.pqcAgent.reload()`).
192
+ // Attach `reloadCerts` so long-running daemons can pivot in place.
193
+ agent.reloadCerts = function (newMaterial) {
194
+ return _reloadCertsOnAgent(agent, opts, newMaterial);
195
+ };
196
+ return agent;
197
+ }
198
+
199
+ function _reloadCertsOnAgent(agent, originalOpts, newMaterial) {
200
+ validateOpts.requireObject(newMaterial, "agent.reloadCerts",
201
+ PqcAgentError, "pqcagent/reload-bad-opts");
202
+ if (typeof newMaterial.cert !== "string" || newMaterial.cert.length === 0 ||
203
+ typeof newMaterial.key !== "string" || newMaterial.key.length === 0) {
204
+ throw new PqcAgentError("pqcagent/reload-missing-material",
205
+ "agent.reloadCerts: both cert and key are required (non-empty PEM strings)");
206
+ }
207
+ // Compound on the AGENT's last-known-good builtOpts (which start as
208
+ // the create-time opts but are updated on each successful reload).
209
+ // A sequence like "reload with new ca once, then reload only
210
+ // cert/key" preserves the new ca because the previous successful
211
+ // reload wrote it into agent._builtOpts.
212
+ var nextOpts = Object.assign({}, agent._builtOpts, {
213
+ cert: newMaterial.cert,
214
+ key: newMaterial.key,
215
+ });
216
+ if (newMaterial.ca !== undefined) nextOpts.ca = newMaterial.ca;
217
+ var t0 = Date.now();
218
+ try {
219
+ // tls.createSecureContext throws on mismatched cert/key — surface
220
+ // as a typed framework error with the underlying OpenSSL chain.
221
+ require("node:tls").createSecureContext({ // allow:inline-require — node:tls only needed during cert rotation (a non-hot path); a top-level require would pull TLS into the boot graph of every process that never reaches reloadCerts
222
+ cert: nextOpts.cert,
223
+ key: nextOpts.key,
224
+ ca: nextOpts.ca,
225
+ });
226
+ } catch (e) {
227
+ var errMsg = (e && e.message) ? e.message : String(e);
228
+ if (/ca\b/i.test(errMsg)) { // allow:regex-no-length-cap — error-message shape match; error text owned by Node, not adversarial input
229
+ throw new PqcAgentError("pqcagent/reload-bad-ca",
230
+ "agent.reloadCerts: ca bundle failed to parse: " + errMsg);
231
+ }
232
+ throw new PqcAgentError("pqcagent/reload-mismatch",
233
+ "agent.reloadCerts: cert/key mismatch or malformed PEM (" + errMsg + ")");
234
+ }
235
+ agent.options = Object.assign({}, agent.options, {
236
+ cert: nextOpts.cert,
237
+ key: nextOpts.key,
238
+ ca: nextOpts.ca,
239
+ });
240
+ agent._builtOpts = nextOpts;
241
+ // Close idle keep-alive sockets so the next request uses the new
242
+ // material. In-flight sockets complete naturally.
243
+ try { agent.destroy(); } catch (_e) { /* best-effort */ }
244
+ try {
245
+ audit.safeEmit({
246
+ action: "pqcagent.reloadCerts",
247
+ outcome: "success",
248
+ metadata: { durationMs: Date.now() - t0 },
249
+ });
250
+ } catch (_e2) { /* drop-silent */ }
251
+ return { reloaded: true, durationMs: Date.now() - t0 };
183
252
  }
184
253
 
185
254
  /**