@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.
Files changed (45) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/index.js +8 -0
  3. package/lib/audit.js +4 -0
  4. package/lib/auth/fido-mds3.js +624 -0
  5. package/lib/auth/passkey.js +214 -2
  6. package/lib/auth-bot-challenge.js +1 -1
  7. package/lib/credential-hash.js +2 -2
  8. package/lib/db-collection.js +290 -0
  9. package/lib/db-query.js +245 -0
  10. package/lib/db.js +173 -67
  11. package/lib/framework-error.js +55 -0
  12. package/lib/guard-cidr.js +2 -1
  13. package/lib/guard-jwt.js +2 -2
  14. package/lib/guard-oauth.js +2 -2
  15. package/lib/http-client-cache.js +916 -0
  16. package/lib/http-client.js +242 -0
  17. package/lib/mail-arf.js +343 -0
  18. package/lib/mail-auth.js +265 -40
  19. package/lib/mail-bimi.js +948 -33
  20. package/lib/mail-bounce.js +386 -4
  21. package/lib/mail-mdn.js +424 -0
  22. package/lib/mail-unsubscribe.js +265 -25
  23. package/lib/mail.js +403 -21
  24. package/lib/middleware/bearer-auth.js +1 -1
  25. package/lib/middleware/clear-site-data.js +122 -0
  26. package/lib/middleware/dpop.js +1 -1
  27. package/lib/middleware/index.js +9 -0
  28. package/lib/middleware/nel.js +214 -0
  29. package/lib/middleware/security-headers.js +56 -4
  30. package/lib/middleware/speculation-rules.js +323 -0
  31. package/lib/mime-parse.js +198 -0
  32. package/lib/mtls-ca.js +15 -5
  33. package/lib/network-dns.js +890 -27
  34. package/lib/network-tls.js +745 -0
  35. package/lib/object-store/sigv4.js +54 -0
  36. package/lib/public-suffix.js +414 -0
  37. package/lib/safe-buffer.js +7 -0
  38. package/lib/safe-json.js +1 -1
  39. package/lib/static.js +120 -0
  40. package/lib/storage.js +11 -0
  41. package/lib/vendor/MANIFEST.json +33 -0
  42. package/lib/vendor/bimi-trust-anchors.pem +33 -0
  43. package/lib/vendor/public-suffix-list.dat +16376 -0
  44. package/package.json +1 -1
  45. 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
- var keyPath = path.join(dataDirPath, "db.key.enc");
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
- encPath = path.join(dataDir, "db.enc");
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 (RESERVED_TABLE_NAMES.has(appName) ||
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
- var fullSchema = FRAMEWORK_SCHEMA.concat(opts.schema);
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
- var auditResult = await audit.verify();
1086
- if (!auditResult.ok) {
1087
- // Fire the breach event BEFORE throwing so operator listeners get
1088
- // a chance at sync I/O (file flag, console alert) before init
1089
- // unwinds.
1090
- events.emit(events.EVENTS.AUDIT_CHAIN_BREAK, { table: "audit_log", result: auditResult });
1091
- throw _dbErr("db/audit-chain-break",
1092
- "FATAL: audit_log chain integrity broken at row " + auditResult.breakAt +
1093
- " (" + auditResult.reason + "); break row _id: " + auditResult.breakRowId +
1094
- "; expected: " + auditResult.expected + "; actual: " + auditResult.actual +
1095
- ". Refusing to boot. Compliance requires that any tamper-detection signal halt service. " +
1096
- "Recovery is manual: restore from backup, or rebuild the audit chain from a verified earlier snapshot.");
1097
- }
1098
- var consentResult = await consent.verify();
1099
- if (!consentResult.ok) {
1100
- events.emit(events.EVENTS.AUDIT_CHAIN_BREAK, { table: "consent_log", result: consentResult });
1101
- throw _dbErr("db/consent-chain-break",
1102
- "FATAL: consent_log chain integrity broken at row " + consentResult.breakAt +
1103
- " (" + consentResult.reason + "); break row _id: " + consentResult.breakRowId +
1104
- ". Refusing to boot.");
1105
- }
1106
- log("audit chain ok (" + auditResult.rowsVerified + " rows), consent chain ok (" + consentResult.rowsVerified + " rows)");
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
- try { _assertWormUnderPosture(); }
1120
- catch (e) {
1121
- // The assertion throws under regulated postures; let it
1122
- // propagate. Outside regulated postures it's a no-op.
1123
- throw e;
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
- var auditSigningMode = (opts.auditSigning && opts.auditSigning.mode)
1136
- ? opts.auditSigning.mode
1137
- : safeEnv.readVar("BLAMEJS_AUDIT_SIGNING_MODE", {
1138
- default: "wrapped",
1139
- enum: ["wrapped", "plaintext"],
1140
- });
1141
- var auditSigningAlg = opts.auditSigning && opts.auditSigning.algorithm
1142
- ? opts.auditSigning.algorithm
1143
- : null;
1144
- await auditSign.init({
1145
- dataDir: dataDir,
1146
- mode: auditSigningMode,
1147
- algorithm: auditSigningAlg || undefined,
1148
- });
1149
-
1150
- // Verify all existing checkpoint signatures (defense against signature
1151
- // forgery attempt + key-rotation gone wrong). Refuse to boot on failure.
1152
- var ckptResult = await audit.verifyCheckpoints();
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
- // Anchor a fresh checkpoint at boot if there's any new audit activity
1164
- // since the last checkpoint (else no-op).
1165
- await audit.checkpoint({ skipIfUnchanged: true });
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