@blamejs/core 0.9.12 → 0.9.14

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
@@ -39,6 +39,9 @@
39
39
 
40
40
  var C = require("./constants");
41
41
  var canonicalJson = require("./canonical-json");
42
+ var nodeFs = require("node:fs");
43
+ var atomicFile = require("./atomic-file");
44
+ var safeJson = require("./safe-json");
42
45
  var { defineClass } = require("./framework-error");
43
46
  var { boot } = require("./log");
44
47
  var nb = require("./numeric-bounds");
@@ -678,9 +681,253 @@ function _resetForTest() {
678
681
  _activeTap = null;
679
682
  }
680
683
 
684
+ // ---- Snapshot writer/reader ----
685
+ //
686
+ // Out-of-process metrics export pattern for long-running daemons:
687
+ // the daemon writes a JSON snapshot atomically every N seconds; a
688
+ // separate CLI process reads + renders. Bypasses the HTTP-port +
689
+ // Unix-socket coupling that the regular Prometheus exposition
690
+ // handler requires. Useful for systemd daemons that don't want to
691
+ // bind a stats port at all (operator runs `daemon stats` and the
692
+ // CLI just reads the file).
693
+ //
694
+ // The writer is atomic — every write goes through atomic-file's
695
+ // writeSync (temp-file + rename + fsync) so a reader that lands
696
+ // between rename and fsync sees the previous complete snapshot
697
+ // rather than a partially-written one.
698
+ //
699
+ // Surface:
700
+ //
701
+ // var stop = b.metrics.snapshot.startWriter({
702
+ // path: "/run/blamejs-daemon/metrics.json",
703
+ // intervalMs: 5000,
704
+ // fields: function () { return { uptimeMs: ..., counters: {...} }; },
705
+ // });
706
+ // // ...later:
707
+ // stop(); // clears timer; runs one final fields() flush before returning
708
+ //
709
+ // var snap = b.metrics.snapshot.read("/run/blamejs-daemon/metrics.json");
710
+ // process.stdout.write(b.metrics.snapshot.render(snap, { format: "text" }));
711
+
712
+ /**
713
+ * @primitive b.metrics.snapshot.startWriter
714
+ * @signature b.metrics.snapshot.startWriter(opts)
715
+ * @since 0.9.13
716
+ * @status stable
717
+ * @related b.metrics.snapshot.read, b.metrics.snapshot.render
718
+ *
719
+ * Start a periodic writer that calls `opts.fields()` every
720
+ * `opts.intervalMs` and writes the returned object as JSON to
721
+ * `opts.path` atomically. Returns a `stop()` function that clears
722
+ * the timer + performs one final flush before resolving.
723
+ *
724
+ * @opts
725
+ * path: string, // absolute path to write the snapshot
726
+ * intervalMs: number, // milliseconds between flushes (>=100)
727
+ * fields: Function, // returns an object — written as JSON
728
+ *
729
+ * @example
730
+ * var stop = b.metrics.snapshot.startWriter({
731
+ * path: "/run/blamejs/metrics.json",
732
+ * intervalMs: 5000,
733
+ * fields: function () {
734
+ * return {
735
+ * uptimeMs: process.uptime() * 1000,
736
+ * queueDepth: myQueue.size,
737
+ * lastSyncAt: lastSyncAt,
738
+ * };
739
+ * },
740
+ * });
741
+ * // ... on SIGTERM:
742
+ * stop();
743
+ */
744
+ function snapshotStartWriter(opts) {
745
+ opts = opts || {};
746
+ validateOpts.requireNonEmptyString(opts.path,
747
+ "metrics.snapshot.startWriter: opts.path",
748
+ MetricsError, "metrics-snapshot/bad-path");
749
+ if (typeof opts.intervalMs !== "number" || !isFinite(opts.intervalMs) || opts.intervalMs < 100) {
750
+ throw new MetricsError("metrics-snapshot/bad-interval",
751
+ "metrics.snapshot.startWriter: opts.intervalMs must be a finite number >= 100, got " + opts.intervalMs);
752
+ }
753
+ if (typeof opts.fields !== "function") {
754
+ throw new MetricsError("metrics-snapshot/bad-fields",
755
+ "metrics.snapshot.startWriter: opts.fields must be a function returning the snapshot object");
756
+ }
757
+ var p = opts.path;
758
+ var fieldsFn = opts.fields;
759
+ var intervalMs = opts.intervalMs;
760
+
761
+ var doFlush = function () {
762
+ var snap;
763
+ try {
764
+ snap = fieldsFn();
765
+ } catch (e) {
766
+ log("snapshot.fields() threw: " + (e && e.message ? e.message : String(e)));
767
+ return;
768
+ }
769
+ if (!snap || typeof snap !== "object") {
770
+ log("snapshot.fields() returned non-object; skipping flush");
771
+ return;
772
+ }
773
+ var payload = {
774
+ writtenAt: new Date().toISOString(),
775
+ fields: snap,
776
+ };
777
+ try {
778
+ atomicFile.writeSync(p, JSON.stringify(payload) + "\n", { fileMode: 0o644 });
779
+ } catch (e) {
780
+ log("snapshot.writeSync failed: " + (e && e.message ? e.message : String(e)));
781
+ }
782
+ };
783
+
784
+ // First flush is synchronous so the file exists by the time
785
+ // startWriter returns. Subsequent flushes run on the interval.
786
+ doFlush();
787
+ var timer = setInterval(doFlush, intervalMs);
788
+ if (typeof timer.unref === "function") timer.unref();
789
+
790
+ return function stop() {
791
+ clearInterval(timer);
792
+ doFlush(); // final flush captures last state before the daemon exits
793
+ };
794
+ }
795
+
796
+ /**
797
+ * @primitive b.metrics.snapshot.read
798
+ * @signature b.metrics.snapshot.read(path)
799
+ * @since 0.9.13
800
+ * @status stable
801
+ * @related b.metrics.snapshot.startWriter, b.metrics.snapshot.render
802
+ *
803
+ * Read + parse a snapshot file written by `startWriter`. Returns
804
+ * `{ writtenAt, fields }`. Throws `MetricsError` with code
805
+ * `metrics-snapshot/...` on missing file, parse failure, or
806
+ * shape mismatch.
807
+ *
808
+ * @example
809
+ * var snap = b.metrics.snapshot.read("/run/blamejs/metrics.json");
810
+ * console.log("uptime:", snap.fields.uptimeMs);
811
+ * console.log("written at:", snap.writtenAt);
812
+ */
813
+ function snapshotRead(p) {
814
+ validateOpts.requireNonEmptyString(p,
815
+ "metrics.snapshot.read: path",
816
+ MetricsError, "metrics-snapshot/bad-path");
817
+ var raw;
818
+ try {
819
+ raw = nodeFs.readFileSync(p, "utf8");
820
+ } catch (e) {
821
+ throw new MetricsError("metrics-snapshot/not-found",
822
+ "metrics.snapshot.read: " + p + " — " + (e && e.message ? e.message : String(e)));
823
+ }
824
+ var parsed;
825
+ // safeJson.parse with bounded maxBytes — the snapshot file is read
826
+ // by a separate CLI / sidecar process from where it's written, and a
827
+ // hostile actor with write access to the snapshot path could replace
828
+ // it with a multi-GB file that would OOM the reader. 4 MiB ceiling
829
+ // is well above the framework's expected snapshot size (~5-50 KiB)
830
+ // and the safeJson absolute cap stays within reach.
831
+ try {
832
+ parsed = safeJson.parse(raw, { maxBytes: 4 * 1024 * 1024 }); // allow:raw-byte-literal — 4 MiB snapshot-file ceiling
833
+ } catch (e) {
834
+ throw new MetricsError("metrics-snapshot/bad-json",
835
+ "metrics.snapshot.read: " + p + " contains invalid JSON: " + (e && e.message ? e.message : String(e)));
836
+ }
837
+ if (!parsed || typeof parsed !== "object" ||
838
+ typeof parsed.writtenAt !== "string" || !parsed.fields ||
839
+ typeof parsed.fields !== "object") {
840
+ throw new MetricsError("metrics-snapshot/bad-shape",
841
+ "metrics.snapshot.read: " + p + " is not a startWriter-produced snapshot (missing writtenAt or fields)");
842
+ }
843
+ return parsed;
844
+ }
845
+
846
+ /**
847
+ * @primitive b.metrics.snapshot.render
848
+ * @signature b.metrics.snapshot.render(snap, opts)
849
+ * @since 0.9.13
850
+ * @status stable
851
+ * @related b.metrics.snapshot.read
852
+ *
853
+ * Format a snapshot object for human or machine consumption.
854
+ *
855
+ * format: "text" — operator-readable lines, one field per row (default)
856
+ * format: "prometheus" — Prometheus 0.0.4 text format, gauge metrics
857
+ * named with a configurable prefix; only top-level
858
+ * numeric fields under `snap.fields` are emitted
859
+ *
860
+ * @opts
861
+ * format: "text" | "prometheus", // default: "text"
862
+ * prefix: string, // prometheus-only; default: "blamejs"
863
+ *
864
+ * @example
865
+ * var snap = b.metrics.snapshot.read("/run/blamejs/metrics.json");
866
+ * process.stdout.write(b.metrics.snapshot.render(snap));
867
+ * // or for Prometheus scraping:
868
+ * res.setHeader("Content-Type", "text/plain; version=0.0.4");
869
+ * res.end(b.metrics.snapshot.render(snap, { format: "prometheus", prefix: "myapp" }));
870
+ */
871
+ function snapshotRender(snap, opts) {
872
+ opts = opts || {};
873
+ var format = opts.format || "text";
874
+ if (!snap || typeof snap !== "object" || !snap.fields) {
875
+ throw new MetricsError("metrics-snapshot/bad-snap",
876
+ "metrics.snapshot.render: snap must be a startWriter-produced object (got " + typeof snap + ")");
877
+ }
878
+ var fields = snap.fields;
879
+ if (format === "text") {
880
+ var lines = ["snapshot written-at: " + snap.writtenAt];
881
+ // allow:bare-canonicalize-walk — sort is for stable human-readable
882
+ // output ordering, not canonicalize-for-hashing
883
+ var keys = Object.keys(fields).sort();
884
+ for (var i = 0; i < keys.length; i++) {
885
+ var k = keys[i];
886
+ var v = fields[k];
887
+ var s;
888
+ if (typeof v === "number") s = String(v);
889
+ else if (typeof v === "string") s = v;
890
+ else if (typeof v === "boolean") s = v ? "true" : "false";
891
+ else s = JSON.stringify(v);
892
+ lines.push(" " + k + ": " + s);
893
+ }
894
+ return lines.join("\n") + "\n";
895
+ }
896
+ if (format === "prometheus") {
897
+ var prefix = opts.prefix || "blamejs";
898
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(prefix)) {
899
+ throw new MetricsError("metrics-snapshot/bad-prefix",
900
+ "metrics.snapshot.render: prometheus prefix must match [a-zA-Z_][a-zA-Z0-9_]*, got '" + prefix + "'");
901
+ }
902
+ var out = [];
903
+ // allow:bare-canonicalize-walk — sort is for stable Prometheus
904
+ // exposition output ordering, not canonicalize-for-hashing
905
+ var keys2 = Object.keys(fields).sort();
906
+ for (var j = 0; j < keys2.length; j++) {
907
+ var k2 = keys2[j];
908
+ var v2 = fields[k2];
909
+ if (typeof v2 !== "number" || !isFinite(v2)) continue; // only numeric scalars
910
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(k2)) continue; // skip prom-incompatible names
911
+ var metric = prefix + "_" + k2;
912
+ out.push("# TYPE " + metric + " gauge");
913
+ out.push(metric + " " + v2);
914
+ }
915
+ return out.join("\n") + "\n";
916
+ }
917
+ throw new MetricsError("metrics-snapshot/bad-format",
918
+ "metrics.snapshot.render: format must be 'text' or 'prometheus', got '" + format + "'");
919
+ }
920
+
921
+ var snapshot = {
922
+ startWriter: snapshotStartWriter,
923
+ read: snapshotRead,
924
+ render: snapshotRender,
925
+ };
926
+
681
927
  module.exports = {
682
928
  create: create,
683
929
  tap: tap,
930
+ snapshot: snapshot,
684
931
  MetricsError: MetricsError,
685
932
  DEFAULT_HTTP_BUCKETS: DEFAULT_HTTP_BUCKETS,
686
933
  DEFAULT_CARDINALITY_CAP: DEFAULT_CARDINALITY_CAP,
@@ -44,6 +44,8 @@ var nodeCrypto = require("node:crypto");
44
44
  var lazyRequire = require("../lazy-require");
45
45
  var numericBounds = require("../numeric-bounds");
46
46
  var safeBuffer = require("../safe-buffer");
47
+ var safeJson = require("../safe-json");
48
+ var safeSql = require("../safe-sql");
47
49
  var { defineClass } = require("../framework-error");
48
50
 
49
51
  var audit = lazyRequire(function () { return require("../audit"); });
@@ -124,6 +126,153 @@ function memoryStore(opts) {
124
126
  };
125
127
  }
126
128
 
129
+ // Operator-supplied table name is validated via b.safeSql.validateIdentifier
130
+ // — single source of truth for the framework's SQL-identifier shape
131
+ // (ASCII identifier chars only, 63-char cap, no reserved words). Direct
132
+ // interpolation is safe once the validator throws on bad input.
133
+
134
+ /**
135
+ * @primitive b.middleware.idempotencyKey.dbStore
136
+ * @signature b.middleware.idempotencyKey.dbStore(opts)
137
+ * @since 0.9.14
138
+ * @status stable
139
+ * @related b.middleware.idempotencyKey, b.middleware.idempotencyKey.memoryStore, b.db
140
+ *
141
+ * Persistent-backed store for `idempotencyKey` middleware. Implements
142
+ * the same three-method interface as `memoryStore` (`get` / `set` /
143
+ * `delete`) but stores records in a SQLite-shaped database — the
144
+ * framework's internal `b.db`, an operator-supplied better-sqlite3
145
+ * instance, or any object exposing `prepare(sql) → { run, get, all }`.
146
+ *
147
+ * Use `dbStore` instead of `memoryStore` when:
148
+ *
149
+ * - multiple processes share the request-handling fleet (forks
150
+ * behind a load balancer, multi-instance K8s deployment) and a
151
+ * retry can land on a different process than the original
152
+ * request — only a shared store satisfies the §2 replay
153
+ * semantics across the fleet;
154
+ * - the daemon may restart between the original request and the
155
+ * retry (graceful rolling deploy, OOM kill, planned reboot) —
156
+ * `memoryStore` is volatile, `dbStore` survives the restart;
157
+ * - audit / compliance review needs to walk historic
158
+ * idempotency cache decisions — `dbStore` is queryable with
159
+ * `SELECT * FROM <tableName>`, `memoryStore` is opaque.
160
+ *
161
+ * Lazily-expired: `get(key)` returns `null` for any row whose
162
+ * `expires_at` has passed (the row is deleted on the same call).
163
+ * `set(key, value, ttlMs)` upserts on conflict so a concurrent retry
164
+ * landing on a different process doesn't error out. `delete(key)` is
165
+ * idempotent (no-op when absent).
166
+ *
167
+ * @opts
168
+ * db: object, // required — sqlite-shaped: { prepare(sql) → { run, get, all } }
169
+ * tableName?: string, // default "blamejs_idempotency_keys"; validated via b.safeSql.validateIdentifier
170
+ * init?: boolean, // default true — run CREATE TABLE IF NOT EXISTS at construction
171
+ *
172
+ * @example
173
+ * // single-process daemon, framework's internal sqlite:
174
+ * var b = require("blamejs");
175
+ * await b.db.init({ dataDir: "/var/lib/myapp", schema: [] });
176
+ * var store = b.middleware.idempotencyKey.dbStore({ db: b.db });
177
+ * var mw = b.middleware.idempotencyKey({
178
+ * store: store,
179
+ * ttlMs: b.constants.TIME.hours(24),
180
+ * });
181
+ * app.use(mw);
182
+ *
183
+ * @example
184
+ * // multi-process fleet, shared better-sqlite3 instance over WAL:
185
+ * var Database = require("better-sqlite3");
186
+ * var db = new Database("/var/lib/myapp/idempotency.db", { fileMustExist: false });
187
+ * db.pragma("journal_mode = WAL");
188
+ * var store = b.middleware.idempotencyKey.dbStore({
189
+ * db: db,
190
+ * tableName: "request_idempotency",
191
+ * });
192
+ */
193
+ function dbStore(opts) {
194
+ opts = opts || {};
195
+ if (!opts.db || typeof opts.db !== "object" || typeof opts.db.prepare !== "function") {
196
+ throw new IdempotencyError("idempotency/bad-db",
197
+ "dbStore: opts.db must be a sqlite-shaped database with a `prepare(sql)` method", true);
198
+ }
199
+ var tableNameRaw = opts.tableName !== undefined ? opts.tableName : "blamejs_idempotency_keys";
200
+ // Quote-and-validate in one step. safeSql.quoteIdentifier runs
201
+ // validateIdentifier internally (rejects bad shape / reserved
202
+ // words / sqlite_-prefixed names) and emits the dialect-correct
203
+ // double-quoted form. Identifier ALWAYS reaches SQL through the
204
+ // quoted form — defense-in-depth so a future shape-regex bypass
205
+ // can't reach raw concatenation. Per PR #44 review.
206
+ var qTable;
207
+ try { qTable = safeSql.quoteIdentifier(tableNameRaw, "sqlite"); }
208
+ catch (sqlErr) {
209
+ throw new IdempotencyError("idempotency/bad-table-name",
210
+ "dbStore: opts.tableName is not a valid SQL identifier: " +
211
+ (sqlErr && sqlErr.message ? sqlErr.message : String(sqlErr)), true);
212
+ }
213
+ var qIndex = safeSql.quoteIdentifier(tableNameRaw + "_expires_idx", "sqlite");
214
+ var doInit = opts.init !== false;
215
+ var db = opts.db;
216
+
217
+ if (doInit) {
218
+ db.prepare("CREATE TABLE IF NOT EXISTS " + qTable + " (" +
219
+ "k TEXT PRIMARY KEY, " +
220
+ "v TEXT NOT NULL, " +
221
+ "expires_at INTEGER NOT NULL)").run();
222
+ db.prepare("CREATE INDEX IF NOT EXISTS " + qIndex + " ON " +
223
+ qTable + "(expires_at)").run();
224
+ }
225
+
226
+ // Prepare once; reused on every store call. Statements cache to the
227
+ // framework's bounded prepare-cache automatically when db = b.db.
228
+ var stmtGet = db.prepare("SELECT v, expires_at FROM " + qTable + " WHERE k = ?");
229
+ var stmtUpsert = db.prepare("INSERT INTO " + qTable + "(k, v, expires_at) VALUES (?, ?, ?) " +
230
+ "ON CONFLICT(k) DO UPDATE SET v = excluded.v, expires_at = excluded.expires_at");
231
+ var stmtDelete = db.prepare("DELETE FROM " + qTable + " WHERE k = ?");
232
+ // Conditional delete — only removes the row when expires_at still
233
+ // matches the version we observed. In a multi-process deployment
234
+ // another process can upsert the same key between our SELECT and
235
+ // DELETE; an unconditional `DELETE WHERE k = ?` would erase the
236
+ // FRESH replacement and turn a valid cached response into a miss
237
+ // (idempotency replay broken under concurrent retries). Per Codex
238
+ // P1 on PR #44.
239
+ var stmtDeleteStale = db.prepare("DELETE FROM " + qTable +
240
+ " WHERE k = ? AND expires_at <= ?");
241
+
242
+ return {
243
+ get: function (key) {
244
+ var row = stmtGet.get(key);
245
+ if (!row) return null;
246
+ if (row.expires_at < Date.now()) {
247
+ // Scoped delete by the observed expires_at so a concurrent
248
+ // upsert that wrote a fresher row isn't clobbered.
249
+ stmtDeleteStale.run(key, row.expires_at);
250
+ return null;
251
+ }
252
+ // safeJson.parse with bounded maxBytes — even though row.v is
253
+ // written by our own .set() below, a multi-process deployment
254
+ // shares the table across processes; a misbehaving sibling
255
+ // could write a huge value that OOMs the reader. The default
256
+ // maxBodyBytes (1 MiB) bounds what the middleware captures, so
257
+ // a 4 MiB ceiling here gives headroom for JSON-envelope overhead.
258
+ try { return safeJson.parse(row.v, { maxBytes: 4 * 1024 * 1024 }); } // allow:raw-byte-literal — 4 MiB row-value ceiling
259
+ catch (_e) {
260
+ // Corrupt row — scope the delete by the observed expires_at so
261
+ // a concurrent upsert of valid bytes survives.
262
+ stmtDeleteStale.run(key, row.expires_at);
263
+ return null;
264
+ }
265
+ },
266
+ set: function (key, value, ttlMs) {
267
+ stmtUpsert.run(key, JSON.stringify(value), Date.now() + ttlMs);
268
+ },
269
+ delete: function (key) {
270
+ stmtDelete.run(key);
271
+ },
272
+ _tableName: tableNameRaw,
273
+ };
274
+ }
275
+
127
276
  function _validateStore(store, where) {
128
277
  if (!store || typeof store !== "object") {
129
278
  throw new IdempotencyError("idempotency/bad-store",
@@ -420,5 +569,6 @@ function _redactKey(key) {
420
569
  module.exports = create;
421
570
  module.exports.create = create;
422
571
  module.exports.memoryStore = memoryStore;
572
+ module.exports.dbStore = dbStore;
423
573
  module.exports.DEFAULT_METHODS = DEFAULT_METHODS;
424
574
  module.exports.IdempotencyError = IdempotencyError;
package/lib/pqc-agent.js CHANGED
@@ -1,31 +1,29 @@
1
1
  "use strict";
2
2
  /**
3
- * pqc-agent — outbound HTTPS agent locked to PQC group preference.
3
+ * @module b.pqcAgent
4
+ * @nav Production
5
+ * @title PQC Agent
6
+ * @order 630
4
7
  *
5
- * The framework's posture is "all outbound TLS is PQC-only". This is
6
- * the single primitive that defines what that means at the agent
7
- * level: TLSv1.3 minimum, ecdhCurve set to the framework's PQC hybrid
8
- * preference (constants.TLS_GROUP_CURVE_STR), keep-alive on.
8
+ * @intro
9
+ * Outbound HTTPS agent locked to the framework's PQC group preference.
10
+ * The framework's posture is "all outbound TLS is PQC-only"; this
11
+ * primitive defines what that means at the agent level — TLSv1.3
12
+ * minimum, `ecdhCurve` set to the framework's PQC hybrid preference
13
+ * (`constants.TLS_GROUP_CURVE_STR`), keep-alive on.
9
14
  *
10
- * Two surfaces:
15
+ * `b.pqcAgent.agent` is a process-wide default agent, lazy-built on
16
+ * first access; `b.pqcAgent.create(opts)` builds a fresh agent with
17
+ * custom pool / timeout opts (ecdhCurve and minVersion cannot be
18
+ * weakened); `b.pqcAgent.reload()` tears down the default agent so
19
+ * the next access rebuilds against current TLS posture.
11
20
  *
12
- * 1. b.pqcAgent.agent a process-wide default agent, lazy-built on
13
- * first access. Use this for one-off outbound calls that go
14
- * through node:https directly:
21
+ * `lib/http-client.js`'s transport cache uses `pqcAgent.create()` under
22
+ * the hood, so the framework's bundled HTTP client and any operator-
23
+ * direct `https.request` calls converge on the same agent posture.
15
24
  *
16
- * https.request(url, { agent: b.pqcAgent.agent }, ...);
17
- *
18
- * 2. b.pqcAgent.create(opts) — build a fresh agent with custom
19
- * pool / timeout opts. ecdhCurve and minVersion CANNOT be
20
- * weakened via opts; operator-supplied values for those are
21
- * ignored and the framework's defaults win. Operators who need
22
- * a non-PQC agent for a deliberate one-off integration with a
23
- * non-PQC server construct their own new https.Agent() directly,
24
- * outside this primitive.
25
- *
26
- * lib/http-client.js's transport cache uses pqcAgent.create() under
27
- * the hood, so the framework's bundled HTTP client and any operator-
28
- * direct https.request calls converge on the same agent posture.
25
+ * @card
26
+ * Outbound HTTPS agent locked to TLSv1.3 + framework PQC hybrid group preference.
29
27
  */
30
28
 
31
29
  var https = require("node:https");
@@ -152,14 +150,63 @@ function _buildAgentOpts(opts) {
152
150
  return merged;
153
151
  }
154
152
 
153
+ /**
154
+ * @primitive b.pqcAgent.create
155
+ * @signature b.pqcAgent.create(opts?)
156
+ * @since 0.5.0
157
+ * @status stable
158
+ * @related b.pqcAgent.reload
159
+ *
160
+ * Build a fresh https.Agent locked to the framework PQC hybrid group
161
+ * preference (TLSv1.3 minimum, ecdhCurve set to
162
+ * `C.TLS_GROUP_CURVE_STR`). Operator-supplied values for ecdhCurve
163
+ * may NARROW the framework default (drop a group) but cannot widen it
164
+ * unless `opts.allowOperatorGroups: true` is set; minVersion is fixed
165
+ * at TLSv1.3 and cannot be weakened.
166
+ *
167
+ * @opts
168
+ * keepAlive?: boolean,
169
+ * keepAliveMsecs?: number,
170
+ * maxSockets?: number,
171
+ * maxFreeSockets?: number,
172
+ * scheduling?: string,
173
+ * ecdhCurve?: string, // colon-separated group names; must subset C.TLS_GROUP_PREFERENCE
174
+ * allowOperatorGroups?: boolean, // default false; opt in to operator-supplied groups outside the framework PQC preference
175
+ *
176
+ * @example
177
+ * var agent = b.pqcAgent.create({ maxSockets: 200 });
178
+ * var req = https.request("https://api.example.com/v1/x", { agent: agent });
179
+ * req.end();
180
+ */
155
181
  function create(opts) {
156
182
  return new https.Agent(_buildAgentOpts(opts));
157
183
  }
158
184
 
159
- // http (cleartext) variant — same pool defaults but obviously no TLS
160
- // posture to enforce. Operator-side, almost no caller wants this; it
161
- // exists so http-client's h1 transport for cleartext origins (h2c
162
- // fixtures, internal services) shares the pool tuning.
185
+ /**
186
+ * @primitive b.pqcAgent.createHttp
187
+ * @signature b.pqcAgent.createHttp(opts?)
188
+ * @since 0.5.0
189
+ * @status stable
190
+ * @related b.pqcAgent.create
191
+ *
192
+ * Build a cleartext `http.Agent` with the same pool defaults as
193
+ * `b.pqcAgent.create` — no TLS posture to enforce. Exists so the
194
+ * framework's HTTP client's h1 transport for cleartext origins (h2c
195
+ * fixtures, internal services on a private network) shares the same
196
+ * pool tuning as the encrypted path.
197
+ *
198
+ * @opts
199
+ * keepAlive?: boolean,
200
+ * keepAliveMsecs?: number,
201
+ * maxSockets?: number,
202
+ * maxFreeSockets?: number,
203
+ * scheduling?: string,
204
+ *
205
+ * @example
206
+ * var agent = b.pqcAgent.createHttp({ maxSockets: 100 });
207
+ * var req = http.request("http://internal.svc/health", { agent: agent });
208
+ * req.end();
209
+ */
163
210
  function createHttp(opts) {
164
211
  return new http.Agent(Object.assign({}, DEFAULT_OPTS, opts || {}));
165
212
  }
@@ -173,11 +220,54 @@ function _getDefaultAgent() {
173
220
  return _defaultAgent;
174
221
  }
175
222
 
223
+ /**
224
+ * @primitive b.pqcAgent.reload
225
+ * @signature b.pqcAgent.reload()
226
+ * @since 0.9.14
227
+ * @status stable
228
+ * @related b.pqcAgent.create
229
+ *
230
+ * Tear down the lazily-built default agent and reset to null so the
231
+ * next `b.pqcAgent.agent` access rebuilds against current TLS posture
232
+ * + network-tls applyToContext output.
233
+ *
234
+ * Long-running daemons that rotate the framework's TLS posture (via
235
+ * `b.network.tls` config refresh, certificate-pinset reload, or a
236
+ * `C.TLS_GROUP_PREFERENCE` update behind a feature flag) need a way
237
+ * to re-source the outbound https.Agent without forking a new
238
+ * process. `reload()` calls `.destroy()` on the existing default
239
+ * agent — Node closes idle keep-alive sockets and lets in-flight
240
+ * sockets complete naturally — then nulls the cache so the next
241
+ * `agent` access builds fresh. Agents handed out via explicit
242
+ * `b.pqcAgent.create()` are unaffected; only the framework's lazy
243
+ * default is recycled.
244
+ *
245
+ * Returns `{ destroyed: boolean }` — `destroyed: true` when an agent
246
+ * was actually torn down, `false` when no default had been built
247
+ * (no callers yet asked for it).
248
+ *
249
+ * @example
250
+ * // operator's daemon picked up a refreshed TLS-pinset config:
251
+ * b.network.tls.reload();
252
+ * var res = b.pqcAgent.reload();
253
+ * logger.info("pqc-agent reloaded", res);
254
+ */
255
+ function reload() {
256
+ var hadAgent = _defaultAgent !== null;
257
+ if (hadAgent) {
258
+ try { _defaultAgent.destroy(); }
259
+ catch (_e) { /* destroy is best-effort */ }
260
+ _defaultAgent = null;
261
+ }
262
+ return { destroyed: hadAgent };
263
+ }
264
+
176
265
  module.exports = {
177
266
  // Read property — getter so the agent is built on first access.
178
267
  get agent() { return _getDefaultAgent(); },
179
268
  create: create,
180
269
  createHttp: createHttp,
270
+ reload: reload,
181
271
  DEFAULT_OPTS: DEFAULT_OPTS,
182
272
  KNOWN_TLS_GROUPS: KNOWN_TLS_GROUPS,
183
273
  enforced: true,
package/lib/retry.js CHANGED
@@ -445,8 +445,58 @@ class CircuitBreaker {
445
445
  }
446
446
  }
447
447
 
448
+ /**
449
+ * @primitive b.retry.withBreaker
450
+ * @signature b.retry.withBreaker(fn, opts)
451
+ * @since 0.9.13
452
+ * @status stable
453
+ * @related b.retry.withRetry, b.circuitBreaker.create
454
+ *
455
+ * Compose `withRetry` + a CircuitBreaker so one retry-loop invocation
456
+ * counts as exactly one breaker call. The breaker observes the
457
+ * eventual outcome of the retry loop (success or final failure), not
458
+ * each intermediate retry attempt — otherwise every retried call
459
+ * inflates the breaker's failure counter and the breaker opens far
460
+ * sooner than intended.
461
+ *
462
+ * Every downstream consumer that wants this composition writes
463
+ * `breaker.wrap(() => b.retry.withRetry(fn, retryOpts))` by hand;
464
+ * this primitive captures the pattern so the convention is uniform.
465
+ *
466
+ * @opts
467
+ * retry: Object, // forwarded to withRetry — same options
468
+ * breaker: CircuitBreaker, // existing breaker instance (required)
469
+ *
470
+ * @example
471
+ * var cb = b.circuitBreaker.create({ name: "upstream-billing", failureThreshold: 5 });
472
+ * var result = await b.retry.withBreaker(async function () {
473
+ * return await fetch("https://billing.example.com/v1/charges");
474
+ * }, {
475
+ * retry: { maxAttempts: 3, baseDelayMs: 500 },
476
+ * breaker: cb,
477
+ * });
478
+ */
479
+ function withBreaker(fn, opts) {
480
+ opts = opts || {};
481
+ if (typeof fn !== "function") {
482
+ throw new TypeError("retry.withBreaker: fn must be a function, got " + typeof fn);
483
+ }
484
+ if (!opts.breaker || typeof opts.breaker.wrap !== "function") {
485
+ throw new TypeError("retry.withBreaker: opts.breaker must be a CircuitBreaker instance (with .wrap(fn))");
486
+ }
487
+ // breaker.wrap counts ONE breaker call regardless of how many retry
488
+ // attempts withRetry makes internally. The breaker only sees the
489
+ // final outcome — success closes it (after enough probes in
490
+ // half-open), or final failure opens it after `failureThreshold`
491
+ // exhausted-retry-loop calls.
492
+ return opts.breaker.wrap(function () {
493
+ return withRetry(fn, opts.retry || {});
494
+ });
495
+ }
496
+
448
497
  module.exports = {
449
498
  withRetry: withRetry,
499
+ withBreaker: withBreaker,
450
500
  isRetryable: isRetryable,
451
501
  backoffDelay: backoffDelay,
452
502
  CircuitBreaker: CircuitBreaker,