@blamejs/core 0.8.52 → 0.8.58
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 +6 -0
- package/index.js +8 -0
- package/lib/audit.js +4 -0
- package/lib/auth/fido-mds3.js +624 -0
- package/lib/auth/passkey.js +214 -2
- package/lib/auth-bot-challenge.js +1 -1
- package/lib/credential-hash.js +2 -2
- package/lib/db-collection.js +290 -0
- package/lib/db-query.js +245 -0
- package/lib/db.js +173 -67
- package/lib/framework-error.js +55 -0
- package/lib/guard-cidr.js +2 -1
- package/lib/guard-jwt.js +2 -2
- package/lib/guard-oauth.js +2 -2
- package/lib/http-client-cache.js +916 -0
- package/lib/http-client.js +242 -0
- package/lib/mail-arf.js +343 -0
- package/lib/mail-auth.js +265 -40
- package/lib/mail-bimi.js +948 -33
- package/lib/mail-bounce.js +386 -4
- package/lib/mail-mdn.js +424 -0
- package/lib/mail-unsubscribe.js +265 -25
- package/lib/mail.js +403 -21
- package/lib/middleware/bearer-auth.js +1 -1
- package/lib/middleware/clear-site-data.js +122 -0
- package/lib/middleware/dpop.js +1 -1
- package/lib/middleware/index.js +9 -0
- package/lib/middleware/nel.js +214 -0
- package/lib/middleware/security-headers.js +56 -4
- package/lib/middleware/speculation-rules.js +323 -0
- package/lib/mime-parse.js +198 -0
- package/lib/mtls-ca.js +15 -5
- package/lib/network-dns.js +890 -27
- package/lib/network-tls.js +745 -0
- package/lib/object-store/sigv4.js +54 -0
- package/lib/public-suffix.js +414 -0
- package/lib/safe-buffer.js +7 -0
- package/lib/safe-json.js +1 -1
- package/lib/static.js +120 -0
- package/lib/storage.js +11 -0
- package/lib/vendor/MANIFEST.json +33 -0
- package/lib/vendor/bimi-trust-anchors.pem +33 -0
- package/lib/vendor/public-suffix-list.dat +16376 -0
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
package/lib/db-query.js
CHANGED
|
@@ -484,6 +484,175 @@ class Query {
|
|
|
484
484
|
return this._delete(false);
|
|
485
485
|
}
|
|
486
486
|
|
|
487
|
+
// Atomic counter increment.
|
|
488
|
+
//
|
|
489
|
+
// `from(table).where(filter).increment("col", 1)` emits
|
|
490
|
+
// `UPDATE table SET col = col + ? WHERE ...` so concurrent writers
|
|
491
|
+
// can't collide on a fetch/mutate/store sequence (which would lose
|
|
492
|
+
// increments under racing transactions). Pass a negative delta to
|
|
493
|
+
// decrement.
|
|
494
|
+
//
|
|
495
|
+
// Returns the number of rows changed (matches updateMany shape).
|
|
496
|
+
increment(column, delta) {
|
|
497
|
+
if (typeof column !== "string" || column.length === 0) {
|
|
498
|
+
throw new Error("increment(column, delta): column must be a non-empty string");
|
|
499
|
+
}
|
|
500
|
+
_validateField(column);
|
|
501
|
+
if (delta === undefined) delta = 1;
|
|
502
|
+
if (typeof delta !== "number" || !Number.isFinite(delta) || !Number.isInteger(delta)) {
|
|
503
|
+
throw new Error("increment(column, delta): delta must be a finite integer (default 1)");
|
|
504
|
+
}
|
|
505
|
+
if (this._where.length === 0) {
|
|
506
|
+
throw new Error("refusing unconditional increment — call where(...) first");
|
|
507
|
+
}
|
|
508
|
+
var whereSql = this._where.join(" AND ");
|
|
509
|
+
var qt = this._quotedTable();
|
|
510
|
+
var qc = '"' + column + '"';
|
|
511
|
+
// Use COALESCE so a NULL counter starts at 0 instead of producing
|
|
512
|
+
// NULL + delta = NULL silently (which would silently drop the
|
|
513
|
+
// operation under SQLite's NULL-arithmetic rules).
|
|
514
|
+
var sql = "UPDATE " + qt + " SET " + qc + " = COALESCE(" + qc + ", 0) + ? WHERE " + whereSql;
|
|
515
|
+
var allParams = [delta].concat(this._whereParams);
|
|
516
|
+
var stmt = this._db.prepare(sql);
|
|
517
|
+
var info = stmt.run.apply(stmt, allParams);
|
|
518
|
+
return info.changes;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// `.where(closure)` for grouped expressions, including OR
|
|
522
|
+
// composition. Pass a function `(qb) => qb.eq(col, val).orEq(...)`;
|
|
523
|
+
// the inner closure builds an expression that becomes a single
|
|
524
|
+
// parenthesised AND-leaf in the outer where chain.
|
|
525
|
+
//
|
|
526
|
+
// The closure receives a `WhereBuilder` exposing `.eq` / `.neq` /
|
|
527
|
+
// `.gt` / `.gte` / `.lt` / `.lte` / `.in` / `.like` plus `.orEq`,
|
|
528
|
+
// `.orNeq`, `.orGt`, `.orGte`, `.orLt`, `.orLte`, `.orIn`,
|
|
529
|
+
// `.orLike`, and `.raw(sql, params)`. Each non-`or` call ANDs the
|
|
530
|
+
// expression; each `or*` call ORs it.
|
|
531
|
+
whereGroup(closure) {
|
|
532
|
+
if (typeof closure !== "function") {
|
|
533
|
+
throw new Error("whereGroup(closure): expected function (qb) => ...");
|
|
534
|
+
}
|
|
535
|
+
var sub = new WhereBuilder();
|
|
536
|
+
closure(sub);
|
|
537
|
+
var built = sub.build();
|
|
538
|
+
if (!built.sql) return this;
|
|
539
|
+
this._where.push("(" + built.sql + ")");
|
|
540
|
+
for (var i = 0; i < built.params.length; i++) this._whereParams.push(built.params[i]);
|
|
541
|
+
return this;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Top-level OR — extends the existing where-chain so
|
|
545
|
+
// `.where(a).orWhere(b)` produces `WHERE (a) OR (b)` rather than
|
|
546
|
+
// `WHERE (a) AND (b)`. Accepts the same arg shapes as `.where`:
|
|
547
|
+
// object-literal map, `(field, value)`, `(field, op, value)`, or a
|
|
548
|
+
// `(qb) => ...` closure.
|
|
549
|
+
orWhere(fieldOrObjOrFn, op, value) {
|
|
550
|
+
if (this._where.length === 0) {
|
|
551
|
+
throw new Error("orWhere(...): no prior where(...) — start the chain with where(...)");
|
|
552
|
+
}
|
|
553
|
+
if (typeof fieldOrObjOrFn === "function") {
|
|
554
|
+
var sub = new WhereBuilder();
|
|
555
|
+
fieldOrObjOrFn(sub);
|
|
556
|
+
var built = sub.build();
|
|
557
|
+
if (!built.sql) return this;
|
|
558
|
+
var prev = this._where.pop();
|
|
559
|
+
this._where.push("(" + prev + " OR (" + built.sql + "))");
|
|
560
|
+
for (var i = 0; i < built.params.length; i++) this._whereParams.push(built.params[i]);
|
|
561
|
+
return this;
|
|
562
|
+
}
|
|
563
|
+
// For non-closure shapes, build a transient single-leaf Query and
|
|
564
|
+
// splice it. We compile to a `WhereBuilder` for symmetry.
|
|
565
|
+
var sub2 = new WhereBuilder();
|
|
566
|
+
if (fieldOrObjOrFn !== null && typeof fieldOrObjOrFn === "object" && !Array.isArray(fieldOrObjOrFn)) {
|
|
567
|
+
Object.keys(fieldOrObjOrFn).forEach(function (k) { sub2.eq(k, fieldOrObjOrFn[k]); });
|
|
568
|
+
} else if (op === undefined) {
|
|
569
|
+
sub2.eq(fieldOrObjOrFn, /* value */ arguments[1]);
|
|
570
|
+
} else {
|
|
571
|
+
sub2._push("AND", fieldOrObjOrFn, op, value);
|
|
572
|
+
}
|
|
573
|
+
var built2 = sub2.build();
|
|
574
|
+
if (!built2.sql) return this;
|
|
575
|
+
var prev2 = this._where.pop();
|
|
576
|
+
this._where.push("(" + prev2 + " OR (" + built2.sql + "))");
|
|
577
|
+
for (var j = 0; j < built2.params.length; j++) this._whereParams.push(built2.params[j]);
|
|
578
|
+
return this;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// `.search(fields, term)` — chainable LIKE-OR helper. Adds
|
|
582
|
+
// `(field1 LIKE ? OR field2 LIKE ? ...)` ANDed onto the existing
|
|
583
|
+
// where-chain. Empty term is a no-op (so `?search=` from a query-
|
|
584
|
+
// string flows through cleanly).
|
|
585
|
+
//
|
|
586
|
+
// `term` is wrapped with `%` on both sides for substring match by
|
|
587
|
+
// default; pass `{ match: "prefix" }` for `term%` only or
|
|
588
|
+
// `{ match: "exact" }` to LIKE the term verbatim (for operators
|
|
589
|
+
// who need to keep `%`/`_` in the user-supplied query).
|
|
590
|
+
search(fields, term, opts) {
|
|
591
|
+
if (!Array.isArray(fields) || fields.length === 0) {
|
|
592
|
+
throw new Error("search(fields, term): fields must be a non-empty array of column names");
|
|
593
|
+
}
|
|
594
|
+
fields.forEach(_validateField);
|
|
595
|
+
if (term === undefined || term === null) return this;
|
|
596
|
+
if (typeof term !== "string") {
|
|
597
|
+
throw new Error("search(fields, term): term must be a string");
|
|
598
|
+
}
|
|
599
|
+
if (term.length === 0) return this;
|
|
600
|
+
var match = (opts && opts.match) || "substring";
|
|
601
|
+
// Escape the operator's term so SQL LIKE wildcards in user input
|
|
602
|
+
// don't widen the match. Use `~` as the ESCAPE char (SQLite's
|
|
603
|
+
// ESCAPE clause requires a single character — picking `~` rather
|
|
604
|
+
// than `\` avoids JS-string-literal escaping headaches; `~` rarely
|
|
605
|
+
// appears in user-supplied search terms).
|
|
606
|
+
var escaped = String(term).replace(/[~%_]/g, function (c) { return "~" + c; });
|
|
607
|
+
var pattern;
|
|
608
|
+
if (match === "exact") pattern = escaped;
|
|
609
|
+
else if (match === "prefix") pattern = escaped + "%";
|
|
610
|
+
else if (match === "substring") pattern = "%" + escaped + "%";
|
|
611
|
+
else throw new Error("search: opts.match must be 'substring' | 'prefix' | 'exact'");
|
|
612
|
+
var clauses = fields.map(function (f) { return '"' + f + '" LIKE ? ESCAPE \'~\''; });
|
|
613
|
+
var sql = "(" + clauses.join(" OR ") + ")";
|
|
614
|
+
var params = fields.map(function () { return pattern; });
|
|
615
|
+
this._where.push(sql);
|
|
616
|
+
for (var i = 0; i < params.length; i++) this._whereParams.push(params[i]);
|
|
617
|
+
return this;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// `.paginate(opts)` — page envelope. Composes the existing
|
|
621
|
+
// `.orderBy().limit().offset().all()` + a separate `.count()` so
|
|
622
|
+
// operators get `{ items, total, limit, offset, page, totalPages }`
|
|
623
|
+
// in one call.
|
|
624
|
+
//
|
|
625
|
+
// Defaults: `limit = 25`, `offset = 0`. `orderBy` is required when
|
|
626
|
+
// the underlying query has no order — otherwise SQLite returns
|
|
627
|
+
// rows in storage order (not stable across page calls).
|
|
628
|
+
paginate(opts) {
|
|
629
|
+
opts = opts || {};
|
|
630
|
+
var limit = opts.limit === undefined ? 25 : opts.limit;
|
|
631
|
+
var offset = opts.offset === undefined ? 0 : opts.offset;
|
|
632
|
+
if (!Number.isInteger(limit) || limit <= 0 || limit > 1000) { // allow:raw-byte-literal — paginate page-size cap, not bytes
|
|
633
|
+
throw new Error("paginate: limit must be a positive integer ≤ 1000 (default 25)");
|
|
634
|
+
}
|
|
635
|
+
if (!Number.isInteger(offset) || offset < 0) {
|
|
636
|
+
throw new Error("paginate: offset must be a non-negative integer");
|
|
637
|
+
}
|
|
638
|
+
if (opts.orderBy) {
|
|
639
|
+
var dir = opts.orderDir || (opts.orderDirection || "asc");
|
|
640
|
+
this.orderBy(opts.orderBy, dir);
|
|
641
|
+
}
|
|
642
|
+
var total = this.count();
|
|
643
|
+
var items = this.limit(limit).offset(offset).all();
|
|
644
|
+
var totalPages = Math.max(1, Math.ceil(total / limit));
|
|
645
|
+
var page = Math.floor(offset / limit) + 1;
|
|
646
|
+
return {
|
|
647
|
+
items: items,
|
|
648
|
+
total: total,
|
|
649
|
+
limit: limit,
|
|
650
|
+
offset: offset,
|
|
651
|
+
page: page,
|
|
652
|
+
totalPages: totalPages,
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
|
|
487
656
|
_delete(single) {
|
|
488
657
|
if (this._where.length === 0) {
|
|
489
658
|
throw new Error("refusing unconditional delete — call where(...) first");
|
|
@@ -503,6 +672,82 @@ class Query {
|
|
|
503
672
|
}
|
|
504
673
|
}
|
|
505
674
|
|
|
675
|
+
// WhereBuilder — sub-expression builder used by Query.whereGroup() and
|
|
676
|
+
// Query.orWhere((qb) => ...) to compose grouped AND/OR predicates that
|
|
677
|
+
// the bare .where() chain (which only ANDs) can't express.
|
|
678
|
+
//
|
|
679
|
+
// Each `.eq` / `.neq` / `.gt` / `.gte` / `.lt` / `.lte` / `.in` /
|
|
680
|
+
// `.like` call ANDs an expression; `.orEq` / `.orNeq` / `.orGt` /
|
|
681
|
+
// `.orGte` / `.orLt` / `.orLte` / `.orIn` / `.orLike` ORs an
|
|
682
|
+
// expression. `.raw(sql, params)` AND's an arbitrary fragment.
|
|
683
|
+
//
|
|
684
|
+
// `.build()` returns `{ sql, params }`. Empty builder → `{ sql: "",
|
|
685
|
+
// params: [] }`.
|
|
686
|
+
class WhereBuilder {
|
|
687
|
+
constructor() {
|
|
688
|
+
this._parts = []; // [{ joiner: "AND"|"OR", sql: "...", params: [...] }]
|
|
689
|
+
}
|
|
690
|
+
_push(joiner, field, op, value) {
|
|
691
|
+
if (typeof field !== "string" || field.length === 0) {
|
|
692
|
+
throw new Error("WhereBuilder: field must be a non-empty string");
|
|
693
|
+
}
|
|
694
|
+
_validateField(field);
|
|
695
|
+
var qf = '"' + field + '"';
|
|
696
|
+
if (op === "IN" || op === "NOT IN") {
|
|
697
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
698
|
+
throw new Error("WhereBuilder: " + op + " requires a non-empty array of values");
|
|
699
|
+
}
|
|
700
|
+
var placeholders = value.map(function () { return "?"; }).join(", ");
|
|
701
|
+
this._parts.push({ joiner: joiner, sql: qf + " " + op + " (" + placeholders + ")", params: value.slice() });
|
|
702
|
+
return this;
|
|
703
|
+
}
|
|
704
|
+
if (!ALLOWED_OPS.has(op)) {
|
|
705
|
+
throw new Error("WhereBuilder: invalid operator '" + op + "'");
|
|
706
|
+
}
|
|
707
|
+
this._parts.push({ joiner: joiner, sql: qf + " " + op + " ?", params: [value] });
|
|
708
|
+
return this;
|
|
709
|
+
}
|
|
710
|
+
eq(f, v) { return this._push("AND", f, "=", v); }
|
|
711
|
+
neq(f, v) { return this._push("AND", f, "!=", v); }
|
|
712
|
+
gt(f, v) { return this._push("AND", f, ">", v); }
|
|
713
|
+
gte(f, v) { return this._push("AND", f, ">=", v); }
|
|
714
|
+
lt(f, v) { return this._push("AND", f, "<", v); }
|
|
715
|
+
lte(f, v) { return this._push("AND", f, "<=", v); }
|
|
716
|
+
in(f, vs) { return this._push("AND", f, "IN", vs); }
|
|
717
|
+
like(f, v) { return this._push("AND", f, "LIKE", v); }
|
|
718
|
+
orEq(f, v) { return this._push("OR", f, "=", v); }
|
|
719
|
+
orNeq(f, v) { return this._push("OR", f, "!=", v); }
|
|
720
|
+
orGt(f, v) { return this._push("OR", f, ">", v); }
|
|
721
|
+
orGte(f, v) { return this._push("OR", f, ">=", v); }
|
|
722
|
+
orLt(f, v) { return this._push("OR", f, "<", v); }
|
|
723
|
+
orLte(f, v) { return this._push("OR", f, "<=", v); }
|
|
724
|
+
orIn(f, vs) { return this._push("OR", f, "IN", vs); }
|
|
725
|
+
orLike(f, v) { return this._push("OR", f, "LIKE", v); }
|
|
726
|
+
raw(sql, params) {
|
|
727
|
+
if (typeof sql !== "string" || sql.length === 0) {
|
|
728
|
+
throw new Error("WhereBuilder.raw: sql must be a non-empty string");
|
|
729
|
+
}
|
|
730
|
+
var p = Array.isArray(params) ? params : (params == null ? [] : [params]);
|
|
731
|
+
if (_countPlaceholders(sql) !== p.length) {
|
|
732
|
+
throw new Error("WhereBuilder.raw: placeholder count mismatch");
|
|
733
|
+
}
|
|
734
|
+
this._parts.push({ joiner: "AND", sql: "(" + sql + ")", params: p });
|
|
735
|
+
return this;
|
|
736
|
+
}
|
|
737
|
+
build() {
|
|
738
|
+
if (this._parts.length === 0) return { sql: "", params: [] };
|
|
739
|
+
var sql = this._parts[0].sql;
|
|
740
|
+
var params = this._parts[0].params.slice();
|
|
741
|
+
for (var i = 1; i < this._parts.length; i += 1) {
|
|
742
|
+
sql = sql + " " + this._parts[i].joiner + " " + this._parts[i].sql;
|
|
743
|
+
for (var j = 0; j < this._parts[i].params.length; j += 1) {
|
|
744
|
+
params.push(this._parts[i].params[j]);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
return { sql: sql, params: params };
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
506
751
|
// Count `?` placeholders outside string literals + comments.
|
|
507
752
|
// Tracks SQL single-quoted, double-quoted, line-comment, and block-
|
|
508
753
|
// comment state to avoid counting `?` characters that are part of
|
package/lib/db.js
CHANGED
|
@@ -645,8 +645,12 @@ function resolveTmpDir(optsTmpDir) {
|
|
|
645
645
|
|
|
646
646
|
// ---- DB encryption key management ----
|
|
647
647
|
|
|
648
|
-
function loadOrCreateDbKey(dataDirPath) {
|
|
649
|
-
|
|
648
|
+
function loadOrCreateDbKey(dataDirPath, keyPathOverride) {
|
|
649
|
+
// Operator opt: `opts.dbKeyPath` — useful when the encryption key
|
|
650
|
+
// needs to live outside `dataDir` (e.g. a separate volume mounted
|
|
651
|
+
// from a KMS-fronted secret store). Default places it next to the
|
|
652
|
+
// encrypted DB so backup capture is one-tarball.
|
|
653
|
+
var keyPath = keyPathOverride || path.join(dataDirPath, "db.key.enc");
|
|
650
654
|
if (fs.existsSync(keyPath)) {
|
|
651
655
|
var sealed = atomicFile.readSync(keyPath, { encoding: "utf8" }).trim();
|
|
652
656
|
var b64 = vault.unseal(sealed);
|
|
@@ -703,6 +707,50 @@ function encryptToDisk() {
|
|
|
703
707
|
atomicFile.writeSync(encPath, encryptPacked(fs.readFileSync(dbPath), encKey, _dbEncAad(dataDir)));
|
|
704
708
|
}
|
|
705
709
|
|
|
710
|
+
/**
|
|
711
|
+
* @primitive b.db.snapshot
|
|
712
|
+
* @signature b.db.snapshot()
|
|
713
|
+
* @since 0.8.58
|
|
714
|
+
* @status stable
|
|
715
|
+
* @related b.db.flushToDisk, b.backup
|
|
716
|
+
*
|
|
717
|
+
* In-memory encrypted snapshot — same envelope shape that
|
|
718
|
+
* `flushToDisk` writes, just held in memory. Operators capturing a
|
|
719
|
+
* backup mid-flight (`b.backup` wrapping a hot DB) get a Buffer they
|
|
720
|
+
* can stream onward to object storage without touching the on-disk
|
|
721
|
+
* encPath. Forces a WAL checkpoint first so the snapshot reflects
|
|
722
|
+
* committed state, not pre-WAL pages.
|
|
723
|
+
*
|
|
724
|
+
* Under `atRest: 'plain'` returns the raw plaintext SQLite file as a
|
|
725
|
+
* Buffer (no envelope), since there's no encryption key to apply —
|
|
726
|
+
* operators wanting an encrypted snapshot under plain mode wrap with
|
|
727
|
+
* their own `b.crypto.encryptPacked` at the call site.
|
|
728
|
+
*
|
|
729
|
+
* @example
|
|
730
|
+
* var b = require("@blamejs/core");
|
|
731
|
+
* var snap = b.db.snapshot();
|
|
732
|
+
* await b.objectStore.put("backups/" + Date.now() + ".enc", snap);
|
|
733
|
+
*/
|
|
734
|
+
function snapshot() {
|
|
735
|
+
_requireInit();
|
|
736
|
+
// WAL checkpoint flushes committed transactions into the main DB file
|
|
737
|
+
// so the snapshot reflects the current logical state, not just the
|
|
738
|
+
// pre-WAL pages.
|
|
739
|
+
try { runSql(database, "PRAGMA wal_checkpoint(TRUNCATE)"); } catch (_e) { /* best effort */ }
|
|
740
|
+
if (!fs.existsSync(dbPath)) {
|
|
741
|
+
throw _dbErr("db/snapshot-no-source",
|
|
742
|
+
"snapshot: plaintext DB at " + dbPath + " is missing — did init complete?");
|
|
743
|
+
}
|
|
744
|
+
var plain = fs.readFileSync(dbPath);
|
|
745
|
+
if (!encPath || !encKey) {
|
|
746
|
+
// atRest: 'plain' — return the raw bytes. Operators wanting an
|
|
747
|
+
// encrypted snapshot under plain mode wrap with their own
|
|
748
|
+
// b.crypto.encryptPacked at the call site.
|
|
749
|
+
return plain;
|
|
750
|
+
}
|
|
751
|
+
return encryptPacked(plain, encKey, _dbEncAad(dataDir));
|
|
752
|
+
}
|
|
753
|
+
|
|
706
754
|
// Remove the plaintext DB + WAL/SHM sidecar files. On Windows these can't be
|
|
707
755
|
// unlinked while the SQLite handle is open, so this MUST be called after
|
|
708
756
|
// database.close().
|
|
@@ -846,9 +894,14 @@ async function init(opts) {
|
|
|
846
894
|
}
|
|
847
895
|
}
|
|
848
896
|
|
|
849
|
-
|
|
897
|
+
// Operator overrides for the encrypted-DB on-disk path. `opts.encryptedDbPath`
|
|
898
|
+
// takes a fully-qualified path; `opts.encryptedDbName` overrides
|
|
899
|
+
// just the basename under `dataDir` (default "db.enc"). Helps when
|
|
900
|
+
// multiple framework-shaped instances share a dataDir.
|
|
901
|
+
encPath = opts.encryptedDbPath ||
|
|
902
|
+
path.join(dataDir, opts.encryptedDbName || "db.enc");
|
|
850
903
|
dbPath = path.join(tmpDir, "blamejs-" + generateToken(C.BYTES.bytes(16)) + ".db");
|
|
851
|
-
encKey = loadOrCreateDbKey(dataDir);
|
|
904
|
+
encKey = loadOrCreateDbKey(dataDir, opts.dbKeyPath);
|
|
852
905
|
|
|
853
906
|
cleanStaleTmpDbs(tmpDir);
|
|
854
907
|
decryptToTmp();
|
|
@@ -945,14 +998,27 @@ async function init(opts) {
|
|
|
945
998
|
// framework would silently provision it next to the reserved
|
|
946
999
|
// namespace, allowing a row-by-row look-alike attack against audit
|
|
947
1000
|
// archive tooling.
|
|
1001
|
+
// Under `frameworkTables: false` the framework's own audit_log /
|
|
1002
|
+
// consent_log are NOT provisioned, so an operator naming a table
|
|
1003
|
+
// `audit_log` (or `consent_log`) doesn't collide. The `_blamejs_*`
|
|
1004
|
+
// prefix stays reserved unconditionally — those names are
|
|
1005
|
+
// hard-claimed by other framework primitives (sessions, jobs,
|
|
1006
|
+
// migrations, rate-limit-counters, …) which still get provisioned
|
|
1007
|
+
// by their respective subsystems.
|
|
1008
|
+
var frameworkTablesEarly = opts.frameworkTables !== false;
|
|
1009
|
+
var FRAMEWORK_NAMED_RESERVED = frameworkTablesEarly
|
|
1010
|
+
? RESERVED_TABLE_NAMES
|
|
1011
|
+
: new Set(); // empty — fall back to the prefix check only
|
|
948
1012
|
for (var ri = 0; ri < opts.schema.length; ri++) {
|
|
949
1013
|
var appName = opts.schema[ri].name;
|
|
950
|
-
if (
|
|
1014
|
+
if (FRAMEWORK_NAMED_RESERVED.has(appName) ||
|
|
951
1015
|
(typeof appName === "string" && appName.indexOf("_blamejs_") === 0)) {
|
|
952
1016
|
throw new DbError("db/reserved-table-name",
|
|
953
1017
|
"table name '" + appName + "' is reserved by the framework. " +
|
|
954
1018
|
"Pick a different name (the framework provisions audit_log, consent_log, " +
|
|
955
|
-
"and any '_blamejs_*'-prefixed tables automatically)."
|
|
1019
|
+
"and any '_blamejs_*'-prefixed tables automatically). " +
|
|
1020
|
+
"Pass opts.frameworkTables: false to skip provisioning audit_log/consent_log " +
|
|
1021
|
+
"when the host application owns its own audit chain.");
|
|
956
1022
|
}
|
|
957
1023
|
}
|
|
958
1024
|
|
|
@@ -1019,10 +1085,31 @@ async function init(opts) {
|
|
|
1019
1085
|
}
|
|
1020
1086
|
}
|
|
1021
1087
|
|
|
1088
|
+
// Operator opt-out for the framework's own tables + audit/consent
|
|
1089
|
+
// chain machinery + WORM assertion + audit-signing bootstrap. Set
|
|
1090
|
+
// `frameworkTables: false` when the host application maintains its
|
|
1091
|
+
// own audit/consent semantics and just wants the framework's
|
|
1092
|
+
// primitives (vault / db / cryptoField / etc.) without the bundled
|
|
1093
|
+
// chain tables. When OFF, every framework-table-dependent step
|
|
1094
|
+
// below is a no-op. Append-only triggers are scoped to the
|
|
1095
|
+
// framework tables only, so they're skipped too.
|
|
1096
|
+
//
|
|
1097
|
+
// `auditSigning: false` is a finer-grained gate — keep the
|
|
1098
|
+
// framework tables but skip the audit-signing-key bootstrap (HS-
|
|
1099
|
+
// shape deployments that already manage their own signing key).
|
|
1100
|
+
//
|
|
1101
|
+
// Defaults match v0.8.57 behavior: both ON.
|
|
1102
|
+
var frameworkTablesEnabled = opts.frameworkTables !== false;
|
|
1103
|
+
var auditSigningEnabled = opts.auditSigning !== false;
|
|
1104
|
+
|
|
1022
1105
|
// Build the full schema = framework-baked tables + app tables.
|
|
1023
1106
|
// Framework tables come FIRST so audit_log/consent_log exist before any
|
|
1024
|
-
// app migration can reference them.
|
|
1025
|
-
|
|
1107
|
+
// app migration can reference them. When `frameworkTables: false`,
|
|
1108
|
+
// skip the concat so the operator's own `audit_log` (or whatever
|
|
1109
|
+
// shape) doesn't collide with the framework's.
|
|
1110
|
+
var fullSchema = frameworkTablesEnabled
|
|
1111
|
+
? FRAMEWORK_SCHEMA.concat(opts.schema)
|
|
1112
|
+
: opts.schema.slice();
|
|
1026
1113
|
|
|
1027
1114
|
// Register schema with field-crypto + capture table metadata snapshot
|
|
1028
1115
|
// (framework tables included so getTableMetadata covers everything).
|
|
@@ -1055,8 +1142,8 @@ async function init(opts) {
|
|
|
1055
1142
|
// or malicious tampering — independent of the API surface's discipline.
|
|
1056
1143
|
// Operator-driven retention purge (when implemented) must drop these
|
|
1057
1144
|
// triggers explicitly inside a transaction, perform the purge, and
|
|
1058
|
-
// recreate them.
|
|
1059
|
-
_installAppendOnlyTriggers(database);
|
|
1145
|
+
// recreate them. Skipped under `frameworkTables: false`.
|
|
1146
|
+
if (frameworkTablesEnabled) _installAppendOnlyTriggers(database);
|
|
1060
1147
|
|
|
1061
1148
|
// Imperative migrations (run once each, in order)
|
|
1062
1149
|
if (opts.migrationDir) {
|
|
@@ -1081,29 +1168,33 @@ async function init(opts) {
|
|
|
1081
1168
|
// means tamper-evidence has been compromised — the framework refuses
|
|
1082
1169
|
// to continue under any circumstances. Recovery is operator-driven
|
|
1083
1170
|
// (restore from backup or manual chain rebuild); the framework only
|
|
1084
|
-
// detects-and-fails.
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1171
|
+
// detects-and-fails. Skipped under `frameworkTables: false` (the
|
|
1172
|
+
// framework's audit_log / consent_log don't exist for an operator
|
|
1173
|
+
// running their own audit subsystem).
|
|
1174
|
+
if (frameworkTablesEnabled) {
|
|
1175
|
+
var auditResult = await audit.verify();
|
|
1176
|
+
if (!auditResult.ok) {
|
|
1177
|
+
// Fire the breach event BEFORE throwing so operator listeners
|
|
1178
|
+
// get a chance at sync I/O (file flag, console alert) before
|
|
1179
|
+
// init unwinds.
|
|
1180
|
+
events.emit(events.EVENTS.AUDIT_CHAIN_BREAK, { table: "audit_log", result: auditResult });
|
|
1181
|
+
throw _dbErr("db/audit-chain-break",
|
|
1182
|
+
"FATAL: audit_log chain integrity broken at row " + auditResult.breakAt +
|
|
1183
|
+
" (" + auditResult.reason + "); break row _id: " + auditResult.breakRowId +
|
|
1184
|
+
"; expected: " + auditResult.expected + "; actual: " + auditResult.actual +
|
|
1185
|
+
". Refusing to boot. Compliance requires that any tamper-detection signal halt service. " +
|
|
1186
|
+
"Recovery is manual: restore from backup, or rebuild the audit chain from a verified earlier snapshot.");
|
|
1187
|
+
}
|
|
1188
|
+
var consentResult = await consent.verify();
|
|
1189
|
+
if (!consentResult.ok) {
|
|
1190
|
+
events.emit(events.EVENTS.AUDIT_CHAIN_BREAK, { table: "consent_log", result: consentResult });
|
|
1191
|
+
throw _dbErr("db/consent-chain-break",
|
|
1192
|
+
"FATAL: consent_log chain integrity broken at row " + consentResult.breakAt +
|
|
1193
|
+
" (" + consentResult.reason + "); break row _id: " + consentResult.breakRowId +
|
|
1194
|
+
". Refusing to boot.");
|
|
1195
|
+
}
|
|
1196
|
+
log("audit chain ok (" + auditResult.rowsVerified + " rows), consent chain ok (" + consentResult.rowsVerified + " rows)");
|
|
1197
|
+
}
|
|
1107
1198
|
|
|
1108
1199
|
// ---- Rollback detection (audit.tip sidecar) ----
|
|
1109
1200
|
// The framework writes <dataDir>/audit.tip on each checkpoint. At boot we
|
|
@@ -1116,11 +1207,15 @@ async function init(opts) {
|
|
|
1116
1207
|
// MUST have declared row-level WORM on at least one business-record
|
|
1117
1208
|
// table. Refuse boot otherwise so missing-declaration drift is
|
|
1118
1209
|
// surfaced at start-up, not on the first delete.
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1210
|
+
// Skipped under `frameworkTables: false` — WORM declarations are
|
|
1211
|
+
// an operator-side concern when the framework isn't owning audit.
|
|
1212
|
+
if (frameworkTablesEnabled) {
|
|
1213
|
+
try { _assertWormUnderPosture(); }
|
|
1214
|
+
catch (e) {
|
|
1215
|
+
// The assertion throws under regulated postures; let it
|
|
1216
|
+
// propagate. Outside regulated postures it's a no-op.
|
|
1217
|
+
throw e;
|
|
1218
|
+
}
|
|
1124
1219
|
}
|
|
1125
1220
|
|
|
1126
1221
|
// ---- Audit-signing key + checkpoint subsystem ----
|
|
@@ -1132,37 +1227,46 @@ async function init(opts) {
|
|
|
1132
1227
|
// SHAKE-family hash posture); ML-DSA-87 is the throughput-focused
|
|
1133
1228
|
// opt-in. Existing key files take their algorithm from disk; this
|
|
1134
1229
|
// option only matters on first generation.
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
if (!ckptResult.ok) {
|
|
1154
|
-
events.emit(events.EVENTS.AUDIT_CHECKPOINT_BREAK, { result: ckptResult });
|
|
1155
|
-
throw _dbErr("db/audit-checkpoint-break",
|
|
1156
|
-
"FATAL: audit checkpoint verification failed at row " +
|
|
1157
|
-
ckptResult.breakAt + " (" + ckptResult.reason + "); checkpoint _id: " +
|
|
1158
|
-
ckptResult.checkpointId + ". Refusing to boot. Either the audit-signing key " +
|
|
1159
|
-
"was rotated without retaining the prior pubkey, or a forged checkpoint was inserted.");
|
|
1230
|
+
// Operator opt-out via `auditSigning: false` skips the signing
|
|
1231
|
+
// bootstrap entirely. Also implicitly skipped when frameworkTables
|
|
1232
|
+
// are off (no audit_log to sign checkpoints over).
|
|
1233
|
+
if (auditSigningEnabled && frameworkTablesEnabled) {
|
|
1234
|
+
var auditSigningMode = (opts.auditSigning && opts.auditSigning.mode)
|
|
1235
|
+
? opts.auditSigning.mode
|
|
1236
|
+
: safeEnv.readVar("BLAMEJS_AUDIT_SIGNING_MODE", {
|
|
1237
|
+
default: "wrapped",
|
|
1238
|
+
enum: ["wrapped", "plaintext"],
|
|
1239
|
+
});
|
|
1240
|
+
var auditSigningAlg = opts.auditSigning && opts.auditSigning.algorithm
|
|
1241
|
+
? opts.auditSigning.algorithm
|
|
1242
|
+
: null;
|
|
1243
|
+
await auditSign.init({
|
|
1244
|
+
dataDir: dataDir,
|
|
1245
|
+
mode: auditSigningMode,
|
|
1246
|
+
algorithm: auditSigningAlg || undefined,
|
|
1247
|
+
});
|
|
1160
1248
|
}
|
|
1161
|
-
log("audit checkpoints ok (" + ckptResult.checkpointsVerified + " signed)");
|
|
1162
1249
|
|
|
1163
|
-
//
|
|
1164
|
-
//
|
|
1165
|
-
|
|
1250
|
+
// Verify all existing checkpoint signatures (defense against
|
|
1251
|
+
// signature forgery attempt + key-rotation gone wrong). Refuse to
|
|
1252
|
+
// boot on failure. Skipped under `frameworkTables: false` /
|
|
1253
|
+
// `auditSigning: false`.
|
|
1254
|
+
if (frameworkTablesEnabled && auditSigningEnabled) {
|
|
1255
|
+
var ckptResult = await audit.verifyCheckpoints();
|
|
1256
|
+
if (!ckptResult.ok) {
|
|
1257
|
+
events.emit(events.EVENTS.AUDIT_CHECKPOINT_BREAK, { result: ckptResult });
|
|
1258
|
+
throw _dbErr("db/audit-checkpoint-break",
|
|
1259
|
+
"FATAL: audit checkpoint verification failed at row " +
|
|
1260
|
+
ckptResult.breakAt + " (" + ckptResult.reason + "); checkpoint _id: " +
|
|
1261
|
+
ckptResult.checkpointId + ". Refusing to boot. Either the audit-signing key " +
|
|
1262
|
+
"was rotated without retaining the prior pubkey, or a forged checkpoint was inserted.");
|
|
1263
|
+
}
|
|
1264
|
+
log("audit checkpoints ok (" + ckptResult.checkpointsVerified + " signed)");
|
|
1265
|
+
|
|
1266
|
+
// Anchor a fresh checkpoint at boot if there's any new audit
|
|
1267
|
+
// activity since the last checkpoint (else no-op).
|
|
1268
|
+
await audit.checkpoint({ skipIfUnchanged: true });
|
|
1269
|
+
}
|
|
1166
1270
|
|
|
1167
1271
|
// ---- NTP drift check ----
|
|
1168
1272
|
// Best-effort; unreachable NTP doesn't fail boot, but >= 1hr drift does
|
|
@@ -2761,6 +2865,7 @@ module.exports = {
|
|
|
2761
2865
|
getActivePosture: getActivePosture,
|
|
2762
2866
|
vacuumAfterErase: vacuumAfterErase,
|
|
2763
2867
|
from: from,
|
|
2868
|
+
collection: require("./db-collection").collection, // allow:inline-require — db-collection lazy-requires db.js back; the inline require here breaks the cycle without needing a stub
|
|
2764
2869
|
prepare: prepare,
|
|
2765
2870
|
stream: stream,
|
|
2766
2871
|
// D-M5 — runtime read-only accessor so Query.stream picks up the
|
|
@@ -2782,6 +2887,7 @@ module.exports = {
|
|
|
2782
2887
|
// the snapshot source. Safe to call any time; no-op when no encPath
|
|
2783
2888
|
// (plain mode) or when the plaintext DB doesn't exist.
|
|
2784
2889
|
flushToDisk: encryptToDisk,
|
|
2890
|
+
snapshot: snapshot,
|
|
2785
2891
|
// integrityCheck — runs PRAGMA integrity_check against the live db
|
|
2786
2892
|
// and returns "ok" on success, an array of corruption lines
|
|
2787
2893
|
// otherwise. Operators wire this into a periodic monitor or a
|