@blamejs/core 0.9.9 → 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/CHANGELOG.md +5 -0
- package/lib/api-snapshot.js +4 -1
- package/lib/audit.js +29 -17
- package/lib/circuit-breaker.js +21 -6
- package/lib/crypto.js +145 -0
- package/lib/dsr.js +22 -15
- package/lib/inbox.js +21 -15
- package/lib/metrics.js +247 -0
- package/lib/middleware/idempotency-key.js +150 -0
- package/lib/pqc-agent.js +116 -26
- package/lib/retry.js +50 -0
- package/lib/self-update-standalone-verifier.js +280 -0
- package/lib/self-update.js +14 -8
- package/lib/vault/rotate.js +6 -2
- package/package.json +5 -3
- package/sbom.cdx.json +6 -6
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
|
-
*
|
|
3
|
+
* @module b.pqcAgent
|
|
4
|
+
* @nav Production
|
|
5
|
+
* @title PQC Agent
|
|
6
|
+
* @order 630
|
|
4
7
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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,
|