@blamejs/core 0.14.27 → 0.15.1

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 (134) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +2 -2
  3. package/index.js +4 -0
  4. package/lib/ai-content-detect.js +9 -10
  5. package/lib/api-key.js +158 -77
  6. package/lib/atomic-file.js +29 -1
  7. package/lib/audit-chain.js +47 -11
  8. package/lib/audit-sign.js +77 -2
  9. package/lib/audit-tools.js +79 -51
  10. package/lib/audit.js +228 -100
  11. package/lib/backup/index.js +13 -10
  12. package/lib/break-glass.js +202 -144
  13. package/lib/cache.js +174 -105
  14. package/lib/chain-writer.js +38 -16
  15. package/lib/cli.js +19 -14
  16. package/lib/cluster-provider-db.js +130 -104
  17. package/lib/cluster-storage.js +119 -22
  18. package/lib/cluster.js +119 -71
  19. package/lib/compliance.js +22 -0
  20. package/lib/consent.js +82 -29
  21. package/lib/constants.js +16 -11
  22. package/lib/crypto-field.js +387 -91
  23. package/lib/db-declare-row-policy.js +35 -22
  24. package/lib/db-file-lifecycle.js +3 -2
  25. package/lib/db-query.js +517 -256
  26. package/lib/db-schema.js +209 -44
  27. package/lib/db.js +202 -95
  28. package/lib/external-db-migrate.js +229 -139
  29. package/lib/external-db.js +25 -15
  30. package/lib/framework-error.js +11 -0
  31. package/lib/framework-files.js +73 -0
  32. package/lib/framework-schema.js +695 -394
  33. package/lib/gate-contract.js +596 -1
  34. package/lib/guard-agent-registry.js +26 -44
  35. package/lib/guard-all.js +1 -0
  36. package/lib/guard-auth.js +42 -112
  37. package/lib/guard-cidr.js +33 -154
  38. package/lib/guard-csv.js +46 -113
  39. package/lib/guard-domain.js +34 -157
  40. package/lib/guard-dsn.js +27 -43
  41. package/lib/guard-email.js +47 -69
  42. package/lib/guard-envelope.js +19 -32
  43. package/lib/guard-event-bus-payload.js +24 -42
  44. package/lib/guard-event-bus-topic.js +25 -43
  45. package/lib/guard-filename.js +42 -106
  46. package/lib/guard-graphql.js +42 -123
  47. package/lib/guard-html.js +53 -108
  48. package/lib/guard-idempotency-key.js +24 -42
  49. package/lib/guard-image.js +46 -103
  50. package/lib/guard-imap-command.js +18 -32
  51. package/lib/guard-jmap.js +16 -30
  52. package/lib/guard-json.js +38 -108
  53. package/lib/guard-jsonpath.js +38 -171
  54. package/lib/guard-jwt.js +49 -179
  55. package/lib/guard-list-id.js +25 -41
  56. package/lib/guard-list-unsubscribe.js +27 -43
  57. package/lib/guard-mail-compose.js +24 -42
  58. package/lib/guard-mail-move.js +26 -44
  59. package/lib/guard-mail-query.js +28 -46
  60. package/lib/guard-mail-reply.js +24 -42
  61. package/lib/guard-mail-sieve.js +24 -42
  62. package/lib/guard-managesieve-command.js +17 -31
  63. package/lib/guard-markdown.js +37 -104
  64. package/lib/guard-message-id.js +26 -45
  65. package/lib/guard-mime.js +39 -151
  66. package/lib/guard-oauth.js +54 -135
  67. package/lib/guard-pdf.js +45 -101
  68. package/lib/guard-pop3-command.js +21 -31
  69. package/lib/guard-posture-chain.js +24 -42
  70. package/lib/guard-regex.js +33 -107
  71. package/lib/guard-saga-config.js +24 -42
  72. package/lib/guard-shell.js +42 -172
  73. package/lib/guard-smtp-command.js +48 -54
  74. package/lib/guard-snapshot-envelope.js +24 -42
  75. package/lib/guard-sql.js +1491 -0
  76. package/lib/guard-stream-args.js +24 -43
  77. package/lib/guard-svg.js +47 -65
  78. package/lib/guard-template.js +35 -172
  79. package/lib/guard-tenant-id.js +26 -45
  80. package/lib/guard-time.js +32 -154
  81. package/lib/guard-trace-context.js +25 -44
  82. package/lib/guard-uuid.js +32 -153
  83. package/lib/guard-xml.js +38 -113
  84. package/lib/guard-yaml.js +51 -163
  85. package/lib/http-client.js +14 -0
  86. package/lib/inbox.js +120 -107
  87. package/lib/legal-hold.js +107 -50
  88. package/lib/log-stream-cloudwatch.js +47 -31
  89. package/lib/log-stream-otlp.js +32 -18
  90. package/lib/mail-crypto-smime.js +2 -6
  91. package/lib/mail-greylist.js +2 -6
  92. package/lib/mail-helo.js +2 -6
  93. package/lib/mail-journal.js +85 -64
  94. package/lib/mail-rbl.js +2 -6
  95. package/lib/mail-scan.js +2 -6
  96. package/lib/mail-spam-score.js +2 -6
  97. package/lib/mail-store.js +293 -154
  98. package/lib/middleware/fetch-metadata.js +17 -7
  99. package/lib/middleware/idempotency-key.js +54 -38
  100. package/lib/middleware/rate-limit.js +102 -32
  101. package/lib/middleware/security-headers.js +21 -5
  102. package/lib/migrations.js +108 -66
  103. package/lib/network-heartbeat.js +7 -0
  104. package/lib/nonce-store.js +31 -9
  105. package/lib/object-store/azure-blob-bucket-ops.js +9 -4
  106. package/lib/object-store/azure-blob.js +31 -3
  107. package/lib/object-store/sigv4.js +10 -0
  108. package/lib/outbox.js +136 -82
  109. package/lib/pqc-agent.js +44 -0
  110. package/lib/pubsub-cluster.js +42 -20
  111. package/lib/queue-local.js +202 -139
  112. package/lib/queue-redis.js +9 -1
  113. package/lib/queue-sqs.js +6 -0
  114. package/lib/retention.js +82 -39
  115. package/lib/safe-dns.js +29 -45
  116. package/lib/safe-ical.js +18 -33
  117. package/lib/safe-icap.js +27 -43
  118. package/lib/safe-sieve.js +21 -40
  119. package/lib/safe-sql.js +124 -3
  120. package/lib/safe-vcard.js +18 -33
  121. package/lib/scheduler.js +35 -12
  122. package/lib/seeders.js +122 -74
  123. package/lib/session-stores.js +42 -14
  124. package/lib/session.js +116 -72
  125. package/lib/sql.js +3885 -0
  126. package/lib/static.js +45 -7
  127. package/lib/subject.js +89 -49
  128. package/lib/vault/index.js +3 -2
  129. package/lib/vault/passphrase-ops.js +3 -2
  130. package/lib/vault/rotate.js +104 -64
  131. package/lib/vendor-data.js +2 -0
  132. package/lib/websocket.js +16 -0
  133. package/package.json +1 -1
  134. package/sbom.cdx.json +6 -6
package/lib/static.js CHANGED
@@ -126,12 +126,32 @@ var DEFAULTS = Object.freeze({
126
126
  // serve event is the audit-worthy act, not a precursor.
127
127
  auditSuccess: true,
128
128
  auditFailures: true,
129
+ // mountType — declares what KIND of content this mount serves, so
130
+ // the stored-XSS-relevant defaults follow the typing instead of being
131
+ // hand-flipped per mount (v0.15.0):
132
+ // "curated" (default) — operator-controlled assets (CSS / JS
133
+ // bundles / fonts / images). Inline render is required
134
+ // and safe because the operator authored the bytes;
135
+ // forceAttachmentForNonText defaults OFF.
136
+ // "user-content" — files written by end users / untrusted uploaders.
137
+ // A served .html / .js / .svg here is a stored-XSS
138
+ // vector, so forceAttachmentForNonText defaults ON —
139
+ // risky inline MIMEs are forced to download unless a
140
+ // sanitizer gate vouches for them (see
141
+ // `_shouldForceAttachment`). This is the conditional
142
+ // flip: a curated asset dir is never blindly forced to
143
+ // download; only a mount the operator TYPED as
144
+ // user-content gets the strict default.
145
+ // An explicit forceAttachmentForNonText always overrides the
146
+ // mountType-derived default.
147
+ mountType: "curated",
129
148
  // forceAttachmentForNonText — stored-XSS defense for user-upload
130
- // directories. Default OFF because operator-curated asset dirs
131
- // (CSS / JS bundles / fonts) need inline render. Opt in for
132
- // user-upload-backed mounts so HTML / JS / SVG without sanitizer
133
- // / PDF / archives are forced to download. See
134
- // `_shouldForceAttachment` below for the safe-render allowlist.
149
+ // directories. Default follows mountType: OFF for "curated" mounts
150
+ // (operator-curated CSS / JS bundles / fonts need inline render), ON for
151
+ // "user-content" mounts so HTML / JS / SVG without a sanitizer / PDF /
152
+ // archives are forced to download. Set explicitly to override the
153
+ // mountType-derived default either way. See `_shouldForceAttachment`
154
+ // below for the safe-render allowlist.
135
155
  forceAttachmentForNonText: false,
136
156
  // Companion knobs — when forceAttachmentForNonText is on, allow
137
157
  // image/svg+xml inline render IF an SVG sanitizer gate is wired
@@ -547,6 +567,15 @@ function _validateCreateOpts(opts) {
547
567
  validateOpts.optionalBoolean(opts.auditFailures, "staticServe.create: auditFailures", StaticServeError);
548
568
  validateOpts.optionalBoolean(opts.safeAttachmentForRiskyMimes,
549
569
  "staticServe.create: safeAttachmentForRiskyMimes", StaticServeError);
570
+ // mountType — config-time enum. A typo ("usercontent", "uploads")
571
+ // would silently fall back to the curated default and serve untrusted
572
+ // HTML inline, so THROW at boot rather than mis-type the mount.
573
+ if (opts.mountType !== undefined &&
574
+ opts.mountType !== "curated" && opts.mountType !== "user-content") {
575
+ throw _err("BAD_OPT",
576
+ "staticServe.create: mountType must be 'curated' (default) or " +
577
+ "'user-content'; got " + JSON.stringify(opts.mountType));
578
+ }
550
579
  validateOpts.optionalBoolean(opts.forceAttachmentForNonText,
551
580
  "staticServe.create: forceAttachmentForNonText", StaticServeError);
552
581
  validateOpts.optionalBoolean(opts.safeRenderSvg,
@@ -684,7 +713,7 @@ function create(opts) {
684
713
  "maxBytesPerActorPerWindowMs", "maxBytesAllActorsPerWindowMs",
685
714
  "bandwidthWindowMs", "maxConcurrentDownloadsPerActor", "maxIdleMs",
686
715
  "contentSafety", "contentSafetyDisabledReason",
687
- "forceAttachmentForNonText", "safeRenderSvg", "safeRenderPdf",
716
+ "mountType", "forceAttachmentForNonText", "safeRenderSvg", "safeRenderPdf",
688
717
  ], "staticServe.create");
689
718
  _validateCreateOpts(opts);
690
719
  var cfg = validateOpts.applyDefaults(opts, DEFAULTS);
@@ -738,7 +767,16 @@ function create(opts) {
738
767
  var auditFailures = cfg.auditFailures;
739
768
  var acceptRanges = cfg.acceptRanges;
740
769
  var safeAttachment = !!cfg.safeAttachmentForRiskyMimes;
741
- var forceAttachmentForNonText = !!cfg.forceAttachmentForNonText;
770
+ // forceAttachmentForNonText default follows mountType (v0.15.0): a
771
+ // mount TYPED "user-content" forces risky inline MIMEs to download by
772
+ // default (stored-XSS defense for untrusted uploads); a "curated" mount
773
+ // keeps inline render. An explicit forceAttachmentForNonText overrides
774
+ // the mountType-derived default either way. The conditional flip never
775
+ // blindly force-attaches a curated asset dir.
776
+ var mountType = opts.mountType || "curated";
777
+ var forceAttachmentForNonText = opts.forceAttachmentForNonText !== undefined
778
+ ? !!opts.forceAttachmentForNonText
779
+ : (mountType === "user-content");
742
780
  var allowSvgRender = cfg.safeRenderSvg !== false;
743
781
  var allowPdfRender = !!cfg.safeRenderPdf;
744
782
  var perActorCap = cfg.maxBytesPerActorPerWindowMs;
package/lib/subject.js CHANGED
@@ -40,11 +40,22 @@ var { sha3Hash } = require("./crypto");
40
40
  var cryptoField = require("./crypto-field");
41
41
  var audit = require("./audit");
42
42
  var cluster = require("./cluster");
43
+ var safeSql = require("./safe-sql");
44
+ var sql = require("./sql");
43
45
  var lazyRequire = require("./lazy-require");
44
46
 
45
47
  var db = lazyRequire(function () { return require("./db"); });
46
48
  var legalHold = lazyRequire(function () { return require("./legal-hold"); });
47
49
 
50
+ // Local-SQLite framework tables for the Art. 18 restriction flag + the
51
+ // erasure marker. These run against the b.db() handle directly, so the
52
+ // b.sql builders carry { quoteName: true } to emit the quoted local name
53
+ // (no clusterStorage prefix rewrite on this path). The names are literals
54
+ // for the same reason db.js declares them as literals — they ARE the
55
+ // canonical local table identifiers.
56
+ var RESTRICTIONS_TABLE = "_blamejs_subject_restrictions"; // allow:hand-rolled-sql — canonical local table-name; passed to b.sql with quoteName
57
+ var ERASURES_TABLE = "_blamejs_subject_erasures"; // allow:hand-rolled-sql — canonical local table-name; passed to b.sql with quoteName
58
+
48
59
  // Required acknowledgements before subject.erase will run. Operator must
49
60
  // explicitly attest each one to confirm no statutory retention or active
50
61
  // litigation hold blocks the deletion.
@@ -138,15 +149,13 @@ function exportData(subjectId, opts) {
138
149
  }
139
150
 
140
151
  function _findRowsForSubject(tableName, subjectField, subjectId) {
141
- var hash = db().hashFor(tableName, subjectField, subjectId);
142
- if (hash) {
143
- // The schema has a derived hash for the subjectField — look up via that
144
- var derivedFieldName = _getDerivedFieldName(tableName, subjectField);
145
- if (derivedFieldName) {
146
- var pred = {};
147
- pred[derivedFieldName] = hash;
148
- return db().from(tableName).where(pred).all();
149
- }
152
+ var cand = db().hashCandidatesFor(tableName, subjectField, subjectId);
153
+ if (cand) {
154
+ // The schema has a derived hash for the subjectField — look up via it,
155
+ // dual-reading across the keyed-MAC flip (whereIn matches both the active
156
+ // keyed-MAC digest and the legacy salted-sha3 digest a pre-flip row
157
+ // carries) so the subject's pre-flip rows are not silently skipped.
158
+ return db().from(tableName).whereIn(cand.field, cand.values).all();
150
159
  }
151
160
  // No derived hash — assume subjectField is raw, do direct equality
152
161
  var rawPred = {};
@@ -211,7 +220,7 @@ function rectify(subjectId, opts) {
211
220
  rowId: opts.id,
212
221
  requestReason: opts.reason,
213
222
  });
214
- throw new Error("subject.rectify: row not found in '" + opts.table + "' with _id '" + opts.id + "'");
223
+ throw new Error("subject.rectify: row not found in '" + opts.table + "' for _id '" + opts.id + "'");
215
224
  }
216
225
 
217
226
  var changedKeys = Object.keys(opts.changes);
@@ -330,19 +339,18 @@ function erase(subjectId, opts) {
330
339
 
331
340
  for (var t = 0; t < tables.length; t++) {
332
341
  var spec = tables[t];
333
- var hash = db().hashFor(spec.name, spec.subjectField, subjectId);
334
- var pred;
335
- if (hash) {
336
- var derivedField = _getDerivedFieldName(spec.name, spec.subjectField);
337
- if (derivedField) {
338
- pred = {}; pred[derivedField] = hash;
339
- } else {
340
- pred = {}; pred[spec.subjectField] = subjectId;
341
- }
342
+ var cand = db().hashCandidatesFor(spec.name, spec.subjectField, subjectId);
343
+ var delQb = db().from(spec.name);
344
+ if (cand) {
345
+ // Dual-read across the keyed-MAC flip so erasure matches (and deletes)
346
+ // the subject's pre-flip rows carrying the legacy salted-sha3 digest —
347
+ // a GDPR erasure that skips un-migrated rows would leave PII behind.
348
+ delQb.whereIn(cand.field, cand.values);
342
349
  } else {
343
- pred = {}; pred[spec.subjectField] = subjectId;
350
+ var delPred = {}; delPred[spec.subjectField] = subjectId;
351
+ delQb.where(delPred);
344
352
  }
345
- var deleted = db().from(spec.name).where(pred).deleteMany();
353
+ var deleted = delQb.deleteMany();
346
354
  totalDeleted += deleted;
347
355
  perTable[spec.name] = deleted;
348
356
  }
@@ -450,20 +458,18 @@ function eraseHard(subjectId, opts) {
450
458
  db().transaction(function () {
451
459
  for (var t = 0; t < tables.length; t++) {
452
460
  var spec = tables[t];
453
- var hash = db().hashFor(spec.name, spec.subjectField, subjectId);
454
- var pred;
455
- if (hash) {
456
- var derivedField = _getDerivedFieldName(spec.name, spec.subjectField);
457
- if (derivedField) {
458
- pred = {}; pred[derivedField] = hash;
459
- } else {
460
- pred = {}; pred[spec.subjectField] = subjectId;
461
- }
461
+ var cand = db().hashCandidatesFor(spec.name, spec.subjectField, subjectId);
462
+ var findQb = db().from(spec.name);
463
+ if (cand) {
464
+ // Dual-read across the keyed-MAC flip so per-row-key destruction +
465
+ // erasure covers the subject's pre-flip (legacy salted-sha3) rows too.
466
+ findQb.whereIn(cand.field, cand.values);
462
467
  } else {
463
- pred = {}; pred[spec.subjectField] = subjectId;
468
+ var rawPred = {}; rawPred[spec.subjectField] = subjectId;
469
+ findQb.where(rawPred);
464
470
  }
465
471
  // Find rows so we can destroy their per-row keys before delete.
466
- var rows = db().from(spec.name).where(pred).all();
472
+ var rows = findQb.all();
467
473
  if (cryptoField.hasPerRowKey(spec.name)) {
468
474
  for (var r = 0; r < rows.length; r++) {
469
475
  var rowId = rows[r]._id;
@@ -473,12 +479,22 @@ function eraseHard(subjectId, opts) {
473
479
  }
474
480
  }
475
481
  }
476
- var deleted = db().from(spec.name).where(pred).deleteMany();
482
+ var delQb2 = db().from(spec.name);
483
+ if (cand) {
484
+ delQb2.whereIn(cand.field, cand.values);
485
+ } else {
486
+ var delPred3 = {}; delPred3[spec.subjectField] = subjectId;
487
+ delQb2.where(delPred3);
488
+ }
489
+ var deleted = delQb2.deleteMany();
477
490
  totalDeleted += deleted;
478
491
  perTable[spec.name] = deleted;
479
492
  // REINDEX the table so B-tree pages holding the deleted row's
480
493
  // index entries are rebuilt — closes the erase-vacuum residual class.
481
- try { db().runSql('REINDEX "' + spec.name + '"'); } // table name comes from FRAMEWORK_SCHEMA
494
+ // REINDEX is a sqlite maintenance verb with no b.sql builder; the
495
+ // table identifier is quoted through b.safeSql so the name is safe by
496
+ // construction (it comes from FRAMEWORK_SCHEMA / the subject-table set).
497
+ try { db().runSql("REINDEX " + safeSql.quoteIdentifier(spec.name, "sqlite", { allowReserved: true })); }
482
498
  catch (_e) { /* cluster mode / unsupported dialect */ }
483
499
  }
484
500
  _markErased(subjectId);
@@ -536,20 +552,31 @@ function restrict(subjectId, opts) {
536
552
  if (!opts || typeof opts.on !== "boolean") {
537
553
  throw new Error("subject.restrict requires { on: true|false }");
538
554
  }
539
- var existing = db().prepare(
540
- "SELECT subjectIdHash FROM _blamejs_subject_restrictions WHERE subjectIdHash = ?"
541
- ).get(_subjectHash(subjectId));
555
+ var restrictSelBuilt = sql.select(RESTRICTIONS_TABLE, { dialect: "sqlite", quoteName: true })
556
+ .columns(["subjectIdHash"])
557
+ .where("subjectIdHash", _subjectHash(subjectId))
558
+ .toSql();
559
+ var restrictSelStmt = db().prepare(restrictSelBuilt.sql);
560
+ var existing = restrictSelStmt.get.apply(restrictSelStmt, restrictSelBuilt.params);
542
561
 
543
562
  if (opts.on) {
544
563
  if (!existing) {
545
- db().prepare(
546
- "INSERT INTO _blamejs_subject_restrictions (subjectIdHash, since, reason) VALUES (?, ?, ?)"
547
- ).run(_subjectHash(subjectId), Date.now(), opts.reason || null);
564
+ var restrictInsBuilt = sql.insert(RESTRICTIONS_TABLE, { dialect: "sqlite", quoteName: true })
565
+ .values({
566
+ subjectIdHash: _subjectHash(subjectId),
567
+ since: Date.now(),
568
+ reason: opts.reason || null,
569
+ })
570
+ .toSql();
571
+ var restrictInsStmt = db().prepare(restrictInsBuilt.sql);
572
+ restrictInsStmt.run.apply(restrictInsStmt, restrictInsBuilt.params);
548
573
  }
549
574
  } else if (existing) {
550
- db().prepare(
551
- "DELETE FROM _blamejs_subject_restrictions WHERE subjectIdHash = ?"
552
- ).run(_subjectHash(subjectId));
575
+ var restrictDelBuilt = sql.delete(RESTRICTIONS_TABLE, { dialect: "sqlite", quoteName: true })
576
+ .where("subjectIdHash", _subjectHash(subjectId))
577
+ .toSql();
578
+ var restrictDelStmt = db().prepare(restrictDelBuilt.sql);
579
+ restrictDelStmt.run.apply(restrictDelStmt, restrictDelBuilt.params);
553
580
  }
554
581
 
555
582
  _writeAudit("subject.restrict", subjectId, "success", {
@@ -581,9 +608,15 @@ function restrict(subjectId, opts) {
581
608
  */
582
609
  function isRestricted(subjectId) {
583
610
  if (!subjectId) return false;
584
- var row = db().prepare(
585
- "SELECT 1 FROM _blamejs_subject_restrictions WHERE subjectIdHash = ?"
586
- ).get(_subjectHash(subjectId));
611
+ // Presence check — project the PK column (b.sql columns must be real
612
+ // identifiers, not a `SELECT 1` literal); a matched row is truthy.
613
+ var built = sql.select(RESTRICTIONS_TABLE, { dialect: "sqlite", quoteName: true })
614
+ .columns(["subjectIdHash"])
615
+ .where("subjectIdHash", _subjectHash(subjectId))
616
+ .limit(1)
617
+ .toSql();
618
+ var stmt = db().prepare(built.sql);
619
+ var row = stmt.get.apply(stmt, built.params);
587
620
  return !!row;
588
621
  }
589
622
 
@@ -629,9 +662,16 @@ function recordObjection(subjectId, opts) {
629
662
  // ---- Internal helpers ----
630
663
 
631
664
  function _markErased(subjectId) {
632
- db().prepare(
633
- "INSERT OR REPLACE INTO _blamejs_subject_erasures (subjectIdHash, erasedAt) VALUES (?, ?)"
634
- ).run(_subjectHash(subjectId), Date.now());
665
+ // "INSERT OR REPLACE" is the sqlite upsert idiom — express it portably as
666
+ // INSERT ON CONFLICT(subjectIdHash) DO UPDATE SET erasedAt = EXCLUDED.erasedAt
667
+ // (the row is keyed by subjectIdHash; a re-erase just refreshes the timestamp).
668
+ var built = sql.upsert(ERASURES_TABLE, { dialect: "sqlite", quoteName: true })
669
+ .values({ subjectIdHash: _subjectHash(subjectId), erasedAt: Date.now() })
670
+ .onConflict(["subjectIdHash"])
671
+ .doUpdateFromExcluded(["erasedAt"])
672
+ .toSql();
673
+ var stmt = db().prepare(built.sql);
674
+ stmt.run.apply(stmt, built.params);
635
675
  }
636
676
 
637
677
  function _subjectHash(subjectId) {
@@ -71,6 +71,7 @@ var { boot } = require("../log");
71
71
  var safeBuffer = require("../safe-buffer");
72
72
  var safeJson = require("../safe-json");
73
73
  var observability = require("../observability");
74
+ var frameworkFiles = require("../framework-files");
74
75
  var vaultPassphraseSource = require("./passphrase-source");
75
76
  var vaultWrap = require("./wrap");
76
77
  var { defineClass } = require("../framework-error");
@@ -99,8 +100,8 @@ var log = boot("vault");
99
100
  function resolvePaths(dataDir) {
100
101
  return {
101
102
  dataDir: dataDir,
102
- plaintext: nodePath.join(dataDir, "vault.key"),
103
- sealed: nodePath.join(dataDir, "vault.key.sealed"),
103
+ plaintext: nodePath.join(dataDir, frameworkFiles.fileName("vaultKey")),
104
+ sealed: nodePath.join(dataDir, frameworkFiles.fileName("vaultKey") + ".sealed"),
104
105
  derivedHashSalt: nodePath.join(dataDir, "vault.derived-hash-salt"),
105
106
  derivedHashMacKey: nodePath.join(dataDir, "vault.derived-hash-mac.sealed"),
106
107
  };
@@ -38,13 +38,14 @@
38
38
  var nodeFs = require("node:fs");
39
39
  var nodePath = require("node:path");
40
40
  var atomicFile = require("../atomic-file");
41
+ var frameworkFiles = require("../framework-files");
41
42
  var vaultWrap = require("./wrap");
42
43
  var { defineClass } = require("../framework-error");
43
44
 
44
45
  var VaultPassphraseError = defineClass("VaultPassphraseError", { alwaysPermanent: true });
45
46
 
46
- var PLAINTEXT_NAME = "vault.key";
47
- var SEALED_NAME = "vault.key.sealed";
47
+ var PLAINTEXT_NAME = frameworkFiles.fileName("vaultKey");
48
+ var SEALED_NAME = frameworkFiles.fileName("vaultKey") + ".sealed";
48
49
 
49
50
  function _paths(dataDir) {
50
51
  return {
@@ -52,12 +52,13 @@ var nodeFs = require("node:fs");
52
52
  var nodePath = require("node:path");
53
53
  var { DatabaseSync } = require("node:sqlite");
54
54
  var atomicFile = require("../atomic-file");
55
- var safeSql = require("../safe-sql");
55
+ var sql = require("../sql");
56
56
  var C = require("../constants");
57
57
  var cryptoField = require("../crypto-field");
58
58
  var bCrypto = require("../crypto");
59
59
  var vaultAad = require("../vault-aad");
60
60
  var dbSchema = require("../db-schema");
61
+ var frameworkFiles = require("../framework-files");
61
62
  var lazyRequire = require("../lazy-require");
62
63
  var { boot } = require("../log");
63
64
  var numericBounds = require("../numeric-bounds");
@@ -97,18 +98,30 @@ var DEFAULT_DRIFT_SAMPLE_LIMIT = 100;
97
98
  var DEFAULT_VERIFY_SAMPLE_MIN = 5;
98
99
  var DEFAULT_VERIFY_SAMPLE_FRAC = 0.01;
99
100
 
101
+ // The catalog/PRAGMA statements all compose through b.sql's narrow audited
102
+ // catalog sub-API (b.sql.catalog / b.sql.pragma) - the only path that emits
103
+ // an sqlite_master reference or a PRAGMA verb, allowlisting exactly the
104
+ // statements the key-rotation walk needs and refusing every other internal
105
+ // identifier / PRAGMA verb. Each returns { sql, params }; the node:sqlite
106
+ // handle takes the params positionally.
107
+ function _all(db, built) {
108
+ var stmt = db.prepare(built.sql);
109
+ return built.params.length > 0 ? stmt.all.apply(stmt, built.params) : stmt.all();
110
+ }
111
+ function _get(db, built) {
112
+ var stmt = db.prepare(built.sql);
113
+ return built.params.length > 0 ? stmt.get.apply(stmt, built.params) : stmt.get();
114
+ }
115
+
100
116
  function _listLiveTables(db) {
101
- return db.prepare(
102
- "SELECT name FROM sqlite_master " +
103
- "WHERE type='table' AND name NOT LIKE 'sqlite_%'"
104
- ).all().map(function (r) { return r.name; });
117
+ return _all(db, sql.catalog.listTables()).map(function (r) { return r.name; });
105
118
  }
106
119
 
107
120
  function _listLiveColumns(db, table) {
108
121
  // PRAGMA table_info — table name comes from sqlite_master so it's
109
- // already validated as an existing identifier.
110
- return db.prepare("PRAGMA table_info(\"" + table.replace(/"/g, '""') + "\")").all()
111
- .map(function (c) { return c.name; });
122
+ // already validated as an existing identifier; b.sql.catalog.tableInfo
123
+ // quotes it by construction.
124
+ return _all(db, sql.catalog.tableInfo(table)).map(function (c) { return c.name; });
112
125
  }
113
126
 
114
127
  function _knownColumnsFor(schema, infraColumns) {
@@ -201,12 +214,13 @@ function validateSchemaMatch(db, opts) {
201
214
  }
202
215
  if (unknown.length === 0) continue;
203
216
 
204
- var quotedCols = unknown.map(function (n) { return '"' + n.replace(/"/g, '""') + '"'; }).join(", ");
205
- var sampleSql = "SELECT " + quotedCols +
206
- " FROM \"" + table.replace(/"/g, '""') + "\" LIMIT " + sampleLimit;
217
+ var sampleBuilt = sql.select(table, { dialect: "sqlite", quoteName: true })
218
+ .columns(unknown)
219
+ .limit(sampleLimit)
220
+ .toSql();
207
221
  var sampled;
208
222
  try {
209
- sampled = db.prepare(sampleSql).all();
223
+ sampled = _all(db, sampleBuilt);
210
224
  } catch (e) {
211
225
  warnings.push({
212
226
  kind: "sample_failed",
@@ -312,7 +326,8 @@ function verify(opts) {
312
326
  var schema = cryptoField.getSchema(table);
313
327
  if (!schema || !Array.isArray(schema.sealedFields) || schema.sealedFields.length === 0) continue;
314
328
 
315
- var totalRow = db.prepare('SELECT COUNT(*) AS n FROM "' + table.replace(/"/g, '""') + '"').get();
329
+ var totalRow = _get(db, sql.select(table, { dialect: "sqlite", quoteName: true })
330
+ .count("*", "n").toSql());
316
331
  var total = totalRow ? totalRow.n : 0;
317
332
  if (total === 0) continue;
318
333
 
@@ -320,10 +335,10 @@ function verify(opts) {
320
335
  if (sampleN > total) sampleN = total;
321
336
 
322
337
  // RANDOM() is fine for a sampler — we're picking representative rows,
323
- // not building cryptographic randomness.
324
- var sampled = db.prepare(
325
- 'SELECT * FROM "' + table.replace(/"/g, '""') + '" ORDER BY RANDOM() LIMIT ?'
326
- ).all(sampleN);
338
+ // not building cryptographic randomness. b.sql.catalog.sampleRandom is
339
+ // the audited ORDER BY RANDOM() form (the general builder has no random-
340
+ // order clause); columns omitted -> `*`.
341
+ var sampled = _all(db, sql.catalog.sampleRandom(table, null, { limit: sampleN }));
327
342
 
328
343
  var foundOldFail = !oldKeys; // when no oldKeys supplied, this check is N/A
329
344
  var verifiedRows = 0;
@@ -529,15 +544,19 @@ function _walkAndReSeal(node, oldKeys, newKeys) {
529
544
  return { value: node, changed: false };
530
545
  }
531
546
 
532
- function _runStmt(db, sql) { db.prepare(sql).run(); }
547
+ // Transaction-control statements only (BEGIN / COMMIT / ROLLBACK) - fixed
548
+ // keywords, no identifier / value, so they stay verbatim rather than route
549
+ // through b.sql (the builder has no transaction-control verb). The param is
550
+ // named `stmtText` so it does not shadow the module-level `sql` builder.
551
+ function _runStmt(db, stmtText) { db.prepare(stmtText).run(); }
533
552
 
534
553
  function _rotateColumn(db, table, column, schema, roots, batchSize, progress) {
535
- // Identifiers reach SQL through safeSql.quoteIdentifier runs
536
- // validateIdentifier (rejects bad shape / reserved words /
537
- // sqlite_-prefix) + emits the dialect-correct quoted form.
538
- var qt = safeSql.quoteIdentifier(table, "sqlite");
539
- var qc = safeSql.quoteIdentifier(column, "sqlite");
540
- var total = db.prepare("SELECT COUNT(*) AS n FROM " + qt + " WHERE " + qc + " IS NOT NULL").get().n;
554
+ // Every statement composes through b.sql (sqlite dialect, quoteName so
555
+ // the concrete handle's table is quoted, not left bare for a cluster
556
+ // rewrite that does not apply here). Identifiers are validated + quoted
557
+ // by construction; the cursor bound (_id) + LIMIT bind as ? placeholders.
558
+ var total = _get(db, sql.select(table, { dialect: "sqlite", quoteName: true })
559
+ .count("*", "n").whereNotNull(column).toSql()).n;
541
560
  if (total === 0) return 0;
542
561
 
543
562
  // AAD-bound tables (registerTable({aad:true})) seal each cell under a
@@ -548,36 +567,54 @@ function _rotateColumn(db, table, column, schema, roots, batchSize, progress) {
548
567
  var aadMode = !!(schema && schema.aad);
549
568
  var rowIdField = aadMode ? schema.rowIdField : null;
550
569
  var needRid = aadMode && rowIdField && rowIdField !== "_id";
551
- var qrid = needRid ? safeSql.quoteIdentifier(rowIdField, "sqlite") : null;
552
570
 
553
- var sel = db.prepare(
554
- "SELECT _id, " + qc + " AS v" + (qrid ? ", " + qrid + " AS rid" : "") + " FROM " + qt +
555
- " WHERE " + qc + " IS NOT NULL AND _id > ? ORDER BY _id LIMIT ?"
556
- );
557
- var upd = db.prepare("UPDATE " + qt + " SET " + qc + " = ? WHERE _id = ?");
571
+ // Keyset-cursor page over (_id) ascending. The projected columns are read
572
+ // by their REAL names off the result row (no AS alias) - the column value
573
+ // is row[column], the row-id value is row[rowIdField]. The SQL text is
574
+ // constant across the loop (only the bound _id-cursor changes; LIMIT is a
575
+ // builder-inlined integer literal, validated non-negative), so prepare
576
+ // once + re-run with the fresh cursor param positionally. The SELECT
577
+ // carries exactly one `?` (the _id cursor); the UPDATE carries two (the
578
+ // resealed value + the _id).
579
+ var selCols = ["_id", column];
580
+ if (needRid) selCols.push(rowIdField);
581
+ var selBuilt = sql.select(table, { dialect: "sqlite", quoteName: true })
582
+ .columns(selCols)
583
+ .whereNotNull(column)
584
+ .whereOp("_id", ">", "")
585
+ .orderBy("_id")
586
+ .limit(batchSize)
587
+ .toSql();
588
+ var sel = db.prepare(selBuilt.sql);
589
+ var updBuilt = sql.update(table, { dialect: "sqlite", quoteName: true })
590
+ .set(column, "")
591
+ .where("_id", "")
592
+ .toSql();
593
+ var upd = db.prepare(updBuilt.sql);
558
594
 
559
595
  var processed = 0;
560
596
  var lastId = "";
561
597
  while (true) {
562
- var rows = sel.all(lastId, batchSize);
598
+ var rows = sel.all(lastId);
563
599
  if (rows.length === 0) break;
564
600
 
565
601
  dbSchema.runInTransaction(db, function () {
566
602
  for (var i = 0; i < rows.length; i++) {
567
603
  var row = rows[i];
568
- if (typeof row.v !== "string") continue;
569
- if (aadMode && vaultAad.isAadSealed(row.v)) {
604
+ var cellVal = row[column];
605
+ if (typeof cellVal !== "string") continue;
606
+ if (aadMode && vaultAad.isAadSealed(cellVal)) {
570
607
  // Rebuild the exact AAD the seal side used. cryptoField._aadParts
571
608
  // reads row[schema.rowIdField]; feed it the rowIdField value we
572
- // selected (rid, or _id when rowIdField IS _id).
609
+ // selected (row[rowIdField], or _id when rowIdField IS _id).
573
610
  var rowForAad = {};
574
- rowForAad[rowIdField] = needRid ? row.rid : row._id;
611
+ rowForAad[rowIdField] = needRid ? row[rowIdField] : row._id;
575
612
  var aad = cryptoField._aadParts(schema, table, column, rowForAad);
576
- upd.run(vaultAad.resealRoot(row.v, aad, roots.oldRootJson, roots.newRootJson), row._id);
577
- } else if (row.v.indexOf(C.VAULT_PREFIX) === 0) {
613
+ upd.run(vaultAad.resealRoot(cellVal, aad, roots.oldRootJson, roots.newRootJson), row._id);
614
+ } else if (cellVal.indexOf(C.VAULT_PREFIX) === 0) {
578
615
  // Plain vault: cell (non-AAD table, or a legacy pre-AAD cell in
579
616
  // an AAD table that the next sealRow upgrades).
580
- upd.run(_reSealValue(row.v, roots.oldKeys, roots.newKeys), row._id);
617
+ upd.run(_reSealValue(cellVal, roots.oldKeys, roots.newKeys), row._id);
581
618
  }
582
619
  }
583
620
  });
@@ -589,23 +626,33 @@ function _rotateColumn(db, table, column, schema, roots, batchSize, progress) {
589
626
  }
590
627
 
591
628
  function _rotateOverflow(db, table, oldKeys, newKeys, batchSize, progress, warnings) {
592
- var qt = '"' + table.replace(/"/g, '""') + '"';
593
- var cols = db.prepare("PRAGMA table_info(" + qt + ")").all();
629
+ var cols = _all(db, sql.catalog.tableInfo(table));
594
630
  if (!cols.some(function (c) { return c.name === "data"; })) return 0;
595
631
 
596
- var total = db.prepare("SELECT COUNT(*) AS n FROM " + qt + " WHERE data IS NOT NULL").get().n;
632
+ var total = _get(db, sql.select(table, { dialect: "sqlite", quoteName: true })
633
+ .count("*", "n").whereNotNull("data").toSql()).n;
597
634
  if (total === 0) return 0;
598
635
 
599
- var sel = db.prepare(
600
- "SELECT _id, data FROM " + qt +
601
- " WHERE data IS NOT NULL AND _id > ? ORDER BY _id LIMIT ?"
602
- );
603
- var upd = db.prepare("UPDATE " + qt + " SET data = ? WHERE _id = ?");
636
+ // Same keyset cursor as _rotateColumn over the overflow `data` JSON
637
+ // column: one bound `?` (the _id cursor), builder-inlined LIMIT literal.
638
+ var selBuilt = sql.select(table, { dialect: "sqlite", quoteName: true })
639
+ .columns(["_id", "data"])
640
+ .whereNotNull("data")
641
+ .whereOp("_id", ">", "")
642
+ .orderBy("_id")
643
+ .limit(batchSize)
644
+ .toSql();
645
+ var sel = db.prepare(selBuilt.sql);
646
+ var updBuilt = sql.update(table, { dialect: "sqlite", quoteName: true })
647
+ .set("data", "")
648
+ .where("_id", "")
649
+ .toSql();
650
+ var upd = db.prepare(updBuilt.sql);
604
651
 
605
652
  var processed = 0;
606
653
  var lastId = "";
607
654
  while (true) {
608
- var rows = sel.all(lastId, batchSize);
655
+ var rows = sel.all(lastId);
609
656
  if (rows.length === 0) break;
610
657
 
611
658
  _runStmt(db, "BEGIN");
@@ -689,10 +736,10 @@ async function rotate(opts) {
689
736
  var progress = opts.progressCallback;
690
737
  var warnings = [];
691
738
  var paths = Object.assign({
692
- encryptedDb: "db.enc",
693
- dbKeySealed: "db.key.enc",
694
- vaultKeyPlain: "vault.key",
695
- vaultKeySealed: "vault.key.sealed",
739
+ encryptedDb: frameworkFiles.fileName("dbEnc"),
740
+ dbKeySealed: frameworkFiles.fileName("dbKeyEnc"),
741
+ vaultKeyPlain: frameworkFiles.fileName("vaultKey"),
742
+ vaultKeySealed: frameworkFiles.fileName("vaultKey") + ".sealed",
696
743
  additionalSealed: [],
697
744
  verbatimFiles: [],
698
745
  verbatimDirs: [],
@@ -849,18 +896,15 @@ async function rotate(opts) {
849
896
 
850
897
  var db = new DatabaseSync(tmpDbPath);
851
898
  try {
852
- _runStmt(db, "PRAGMA journal_mode=WAL");
853
- _runStmt(db, "PRAGMA synchronous=NORMAL");
899
+ db.prepare(sql.pragma("journal_mode", "WAL").sql).run();
900
+ db.prepare(sql.pragma("synchronous", "NORMAL").sql).run();
854
901
 
855
902
  // Walk tables. For each, re-seal every column declared sealed
856
903
  // by the field-crypto registry, plus the overflow `data` JSON
857
904
  // column if present.
858
905
  var tablesToRotate = Array.isArray(opts.tables) && opts.tables.length > 0
859
906
  ? opts.tables.slice()
860
- : db.prepare(
861
- "SELECT name FROM sqlite_master " +
862
- "WHERE type='table' AND name NOT LIKE 'sqlite_%'"
863
- ).all().map(function (r) { return r.name; });
907
+ : _listLiveTables(db);
864
908
 
865
909
  // Serialized roots threaded to the AAD reseal path; oldRootJson /
866
910
  // newRootJson match b.vault.getKeysJson() so rotated AAD cells unseal
@@ -869,15 +913,11 @@ async function rotate(opts) {
869
913
 
870
914
  for (var ti = 0; ti < tablesToRotate.length; ti++) {
871
915
  var table = tablesToRotate[ti];
872
- var tableExists = db.prepare(
873
- "SELECT name FROM sqlite_master WHERE type='table' AND name = ?"
874
- ).get(table);
916
+ var tableExists = _get(db, sql.catalog.tableExists(table));
875
917
  if (!tableExists) continue;
876
918
 
877
919
  var schema = cryptoField.getSchema(table);
878
- var liveCols = db.prepare(
879
- 'PRAGMA table_info("' + table.replace(/"/g, '""') + '")'
880
- ).all().map(function (c) { return c.name; });
920
+ var liveCols = _listLiveColumns(db, table);
881
921
  var liveColSet = Object.create(null);
882
922
  for (var lc = 0; lc < liveCols.length; lc++) liveColSet[liveCols[lc]] = true;
883
923
 
@@ -894,7 +934,7 @@ async function rotate(opts) {
894
934
  if (tableRows > 0) { tablesProcessed++; totalRowsProcessed += tableRows; }
895
935
  }
896
936
 
897
- _runStmt(db, "PRAGMA wal_checkpoint(TRUNCATE)");
937
+ db.prepare(sql.pragma("wal_checkpoint", "TRUNCATE").sql).run();
898
938
  } finally {
899
939
  db.close();
900
940
  }