@blamejs/core 0.14.26 → 0.15.0

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 (150) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +2 -2
  3. package/index.js +4 -0
  4. package/lib/agent-envelope-mac.js +104 -0
  5. package/lib/agent-event-bus.js +105 -4
  6. package/lib/agent-posture-chain.js +8 -42
  7. package/lib/ai-content-detect.js +9 -10
  8. package/lib/api-key.js +107 -74
  9. package/lib/atomic-file.js +62 -4
  10. package/lib/audit-chain.js +47 -11
  11. package/lib/audit-sign.js +77 -2
  12. package/lib/audit-tools.js +79 -51
  13. package/lib/audit.js +249 -123
  14. package/lib/auth/openid-federation.js +108 -47
  15. package/lib/backup/index.js +13 -10
  16. package/lib/break-glass.js +202 -144
  17. package/lib/cache.js +174 -105
  18. package/lib/chain-writer.js +38 -16
  19. package/lib/cli.js +19 -14
  20. package/lib/cluster-provider-db.js +130 -104
  21. package/lib/cluster-storage.js +119 -22
  22. package/lib/cluster.js +119 -71
  23. package/lib/compliance.js +169 -4
  24. package/lib/consent.js +73 -24
  25. package/lib/constants.js +16 -11
  26. package/lib/crypto-field.js +474 -92
  27. package/lib/db-declare-row-policy.js +35 -22
  28. package/lib/db-file-lifecycle.js +3 -2
  29. package/lib/db-query.js +497 -255
  30. package/lib/db-schema.js +209 -44
  31. package/lib/db.js +176 -95
  32. package/lib/error-page.js +14 -1
  33. package/lib/external-db-migrate.js +229 -139
  34. package/lib/external-db.js +25 -15
  35. package/lib/file-upload.js +52 -7
  36. package/lib/framework-error.js +14 -1
  37. package/lib/framework-files.js +73 -0
  38. package/lib/framework-schema.js +695 -394
  39. package/lib/gate-contract.js +649 -1
  40. package/lib/guard-agent-registry.js +26 -44
  41. package/lib/guard-all.js +1 -0
  42. package/lib/guard-auth.js +42 -112
  43. package/lib/guard-cidr.js +33 -154
  44. package/lib/guard-csv.js +46 -113
  45. package/lib/guard-domain.js +34 -157
  46. package/lib/guard-dsn.js +27 -43
  47. package/lib/guard-email.js +47 -69
  48. package/lib/guard-envelope.js +19 -32
  49. package/lib/guard-event-bus-payload.js +24 -42
  50. package/lib/guard-event-bus-topic.js +25 -43
  51. package/lib/guard-filename.js +42 -106
  52. package/lib/guard-graphql.js +42 -123
  53. package/lib/guard-html.js +53 -108
  54. package/lib/guard-idempotency-key.js +24 -42
  55. package/lib/guard-image.js +46 -103
  56. package/lib/guard-imap-command.js +18 -32
  57. package/lib/guard-jmap.js +16 -30
  58. package/lib/guard-json.js +38 -108
  59. package/lib/guard-jsonpath.js +38 -171
  60. package/lib/guard-jwt.js +49 -179
  61. package/lib/guard-list-id.js +25 -41
  62. package/lib/guard-list-unsubscribe.js +27 -43
  63. package/lib/guard-mail-compose.js +24 -42
  64. package/lib/guard-mail-move.js +26 -44
  65. package/lib/guard-mail-query.js +28 -46
  66. package/lib/guard-mail-reply.js +24 -42
  67. package/lib/guard-mail-sieve.js +24 -42
  68. package/lib/guard-managesieve-command.js +17 -31
  69. package/lib/guard-markdown.js +37 -104
  70. package/lib/guard-message-id.js +26 -45
  71. package/lib/guard-mime.js +39 -151
  72. package/lib/guard-oauth.js +54 -135
  73. package/lib/guard-pdf.js +45 -101
  74. package/lib/guard-pop3-command.js +21 -31
  75. package/lib/guard-posture-chain.js +24 -42
  76. package/lib/guard-regex.js +33 -107
  77. package/lib/guard-saga-config.js +24 -42
  78. package/lib/guard-shell.js +42 -172
  79. package/lib/guard-smtp-command.js +48 -54
  80. package/lib/guard-snapshot-envelope.js +24 -42
  81. package/lib/guard-sql.js +1491 -0
  82. package/lib/guard-stream-args.js +24 -43
  83. package/lib/guard-svg.js +47 -65
  84. package/lib/guard-template.js +35 -172
  85. package/lib/guard-tenant-id.js +26 -45
  86. package/lib/guard-time.js +32 -154
  87. package/lib/guard-trace-context.js +25 -44
  88. package/lib/guard-uuid.js +32 -153
  89. package/lib/guard-xml.js +38 -113
  90. package/lib/guard-yaml.js +51 -163
  91. package/lib/http-client.js +37 -9
  92. package/lib/inbox.js +120 -107
  93. package/lib/legal-hold.js +107 -50
  94. package/lib/log-stream-cloudwatch.js +47 -31
  95. package/lib/log-stream-otlp.js +32 -18
  96. package/lib/mail-crypto-smime.js +2 -6
  97. package/lib/mail-greylist.js +2 -6
  98. package/lib/mail-helo.js +2 -6
  99. package/lib/mail-journal.js +85 -64
  100. package/lib/mail-rbl.js +2 -6
  101. package/lib/mail-scan.js +2 -6
  102. package/lib/mail-server-jmap.js +117 -12
  103. package/lib/mail-spam-score.js +2 -6
  104. package/lib/mail-store.js +287 -154
  105. package/lib/middleware/body-parser.js +71 -25
  106. package/lib/middleware/csrf-protect.js +19 -8
  107. package/lib/middleware/fetch-metadata.js +17 -7
  108. package/lib/middleware/idempotency-key.js +54 -38
  109. package/lib/middleware/rate-limit.js +102 -32
  110. package/lib/middleware/security-headers.js +21 -5
  111. package/lib/migrations.js +108 -66
  112. package/lib/network-heartbeat.js +7 -0
  113. package/lib/nonce-store.js +31 -9
  114. package/lib/object-store/azure-blob-bucket-ops.js +9 -4
  115. package/lib/object-store/azure-blob.js +57 -3
  116. package/lib/object-store/sigv4.js +10 -0
  117. package/lib/observability.js +87 -0
  118. package/lib/otel-export.js +25 -1
  119. package/lib/outbox.js +136 -82
  120. package/lib/parsers/safe-xml.js +47 -7
  121. package/lib/pqc-agent.js +44 -0
  122. package/lib/pubsub-cluster.js +42 -20
  123. package/lib/queue-local.js +202 -139
  124. package/lib/queue-redis.js +9 -1
  125. package/lib/queue-sqs.js +6 -0
  126. package/lib/redact.js +68 -11
  127. package/lib/redis-client.js +160 -31
  128. package/lib/retention.js +82 -39
  129. package/lib/router.js +212 -5
  130. package/lib/safe-dns.js +29 -45
  131. package/lib/safe-ical.js +18 -33
  132. package/lib/safe-icap.js +27 -43
  133. package/lib/safe-sieve.js +21 -40
  134. package/lib/safe-sql.js +124 -3
  135. package/lib/safe-vcard.js +18 -33
  136. package/lib/scheduler.js +35 -12
  137. package/lib/seeders.js +122 -74
  138. package/lib/session-stores.js +42 -14
  139. package/lib/session.js +109 -72
  140. package/lib/sql.js +3885 -0
  141. package/lib/ssrf-guard.js +51 -4
  142. package/lib/static.js +177 -34
  143. package/lib/subject.js +55 -17
  144. package/lib/vault/index.js +3 -2
  145. package/lib/vault/passphrase-ops.js +3 -2
  146. package/lib/vault/rotate.js +104 -64
  147. package/lib/vendor-data.js +2 -0
  148. package/lib/websocket.js +35 -5
  149. package/package.json +1 -1
  150. package/sbom.cdx.json +6 -6
package/lib/migrations.js CHANGED
@@ -41,10 +41,13 @@
41
41
  var nodePath = require("node:path");
42
42
  var atomicFile = require("./atomic-file");
43
43
  var dbSchema = require("./db-schema");
44
+ var frameworkSchema = require("./framework-schema");
44
45
  var lazyRequire = require("./lazy-require");
45
46
  var { boot } = require("./log");
46
47
  var migrationFiles = require("./migration-files");
47
48
  var numericBounds = require("./numeric-bounds");
49
+ var safeSql = require("./safe-sql");
50
+ var sql = require("./sql");
48
51
  var db = lazyRequire(function () { return require("./db"); });
49
52
  var validateOpts = require("./validate-opts");
50
53
  var { FrameworkError } = require("./framework-error");
@@ -60,11 +63,29 @@ class MigrationError extends FrameworkError {
60
63
  }
61
64
  }
62
65
 
63
- var MIGRATIONS_TABLE = "_blamejs_migrations";
64
- // Always interpolate identifiers wrapped in `"..."` so a reserved-word
65
- // or whitespace-bearing name resolves correctly (defense-in-depth even
66
- // though our constant is bare-identifier-shaped).
67
- var Q_MIGRATIONS_TABLE = '"' + MIGRATIONS_TABLE + '"';
66
+ // Logical names; the physical names resolve through
67
+ // frameworkSchema.tableName so a configured table prefix flows here too.
68
+ // SQL is composed with b.sql (quoteName: true) so the resolved name is
69
+ // quoted by construction a reserved-word / whitespace-bearing name
70
+ // still emits a valid `"..."` identifier.
71
+ var MIGRATIONS_TABLE = "_blamejs_migrations"; // allow:hand-rolled-sql — logical name declaration; physical name + prefix resolve via frameworkSchema.tableName below
72
+ function _migrationsTable() { return frameworkSchema.tableName(MIGRATIONS_TABLE); }
73
+ // b.sql opts for the migration bookkeeping statements. db.prepare /
74
+ // runSqlOnHandle run these directly against the handle (never
75
+ // clusterStorage), so the dialect must match the handle: db.from()'s local
76
+ // node:sqlite default, or an operator's own Postgres / MySQL handle (which
77
+ // declares `handle.dialect`). The handle-dialect / opts / key-text-type
78
+ // resolution is shared with db-schema's reconciler + seeders.js, so it is
79
+ // composed from db-schema rather than re-derived here. The historical
80
+ // default (sqlite) is byte-identical for every existing local-handle caller.
81
+ var _handleDialect = dbSchema.handleDialect;
82
+ var _sqlOpts = dbSchema.sqlOpts;
83
+ var _keyTextType = dbSchema.keyTextType;
84
+ // A ms-epoch column type. Date.now() exceeds a 32-bit INTEGER, so the
85
+ // lock timestamp needs a 64-bit type on Postgres + MySQL (BIGINT) — b.sql's
86
+ // logical "int" resolves to BIGINT on both and INTEGER on SQLite, so passing
87
+ // the logical name through the handle dialect is enough.
88
+ var _MS_EPOCH_TYPE = "int";
68
89
  // Filename grammar: leading numeric prefix (any width), then '-', then a
69
90
  // non-empty body, then '.js'. Numeric prefix orders execution. Letters
70
91
  // in the body include hyphens, underscores, and alphanumerics; anything
@@ -87,31 +108,36 @@ function _isMigrationFile(name) {
87
108
  var _runSql = dbSchema.runSqlOnHandle;
88
109
 
89
110
  function _ensureTable(db) {
90
- _runSql(db,
91
- "CREATE TABLE IF NOT EXISTS " + Q_MIGRATIONS_TABLE + " (" +
92
- " name TEXT PRIMARY KEY," +
93
- " description TEXT," +
94
- " appliedAt TEXT NOT NULL" +
95
- ")"
96
- );
111
+ _runSql(db, sql.createTable(_migrationsTable(), [
112
+ { name: "name", type: _keyTextType(db), primaryKey: true },
113
+ { name: "description", type: "text" },
114
+ { name: "appliedAt", type: "text", notNull: true },
115
+ ], _sqlOpts(db)).sql);
97
116
  }
98
117
 
99
118
  // Single-row advisory-lock table. Two processes running `migrate up`
100
119
  // concurrently against the same DB race on this table: the winner of
101
120
  // the INSERT acquires the lock; the loser sees a UNIQUE violation and
102
121
  // the operator gets a clear "lock held by other process" error.
103
- var LOCK_TABLE = "_blamejs_migrations_lock";
104
- var Q_LOCK_TABLE = '"' + LOCK_TABLE + '"';
122
+ var LOCK_TABLE = "_blamejs_migrations_lock"; // allow:hand-rolled-sql — logical name declaration; physical name + prefix resolve via frameworkSchema.tableName below
123
+ function _lockTable() { return frameworkSchema.tableName(LOCK_TABLE); }
105
124
 
106
125
  function _ensureLockTable(db) {
107
- _runSql(db,
108
- "CREATE TABLE IF NOT EXISTS " + Q_LOCK_TABLE + " (" +
109
- " scope TEXT PRIMARY KEY," +
110
- " lockedAt INTEGER NOT NULL," +
111
- " lockedBy TEXT NOT NULL," +
112
- " CHECK (scope = 'lock')" +
113
- ")"
114
- );
126
+ // The single-row invariant (CHECK scope = 'lock') is a static,
127
+ // framework-controlled column constraint b.sql guards the verbatim
128
+ // fragment (allowLiterals) and quotes the column by construction. The
129
+ // CHECK references `scope` with the handle's identifier quoting (backtick
130
+ // on mysql) so the constraint parses on every dialect. lockedAt is an
131
+ // ms-epoch value (`int` BIGINT on Postgres/MySQL, INTEGER on SQLite);
132
+ // a 32-bit INTEGER would overflow Date.now() and make the lock
133
+ // unacquirable on Postgres.
134
+ var dialect = _handleDialect(db);
135
+ var scopeCheck = "CHECK (" + safeSql.quoteIdentifier("scope", dialect, { allowReserved: true }) + " = 'lock')";
136
+ _runSql(db, sql.createTable(_lockTable(), [
137
+ { name: "scope", type: _keyTextType(db), primaryKey: true, constraints: scopeCheck },
138
+ { name: "lockedAt", type: _MS_EPOCH_TYPE, notNull: true },
139
+ { name: "lockedBy", type: "text", notNull: true },
140
+ ], _sqlOpts(db)).sql);
115
141
  }
116
142
 
117
143
  function _lockHolderId() {
@@ -139,23 +165,24 @@ function _acquireLock(db, opts) {
139
165
  } else {
140
166
  staleAfterMs = opts.staleAfterMs;
141
167
  }
168
+ var insertLock = sql.insert(_lockTable(), _sqlOpts(db))
169
+ .values({ scope: "lock", lockedAt: nowMs, lockedBy: holder }).toSql();
142
170
  // Try to insert; if there's a stale lock, optionally force-replace it.
143
171
  try {
144
- db.prepare(
145
- "INSERT INTO " + Q_LOCK_TABLE + " (scope, lockedAt, lockedBy) VALUES ('lock', ?, ?)"
146
- ).run(nowMs, holder);
172
+ var insStmt = db.prepare(insertLock.sql);
173
+ insStmt.run.apply(insStmt, insertLock.params);
147
174
  return holder;
148
175
  } catch {
149
176
  // PRIMARY KEY conflict → existing lock. Inspect it.
150
- var existing = db.prepare(
151
- "SELECT lockedAt, lockedBy FROM " + Q_LOCK_TABLE + " WHERE scope = 'lock'"
152
- ).get();
177
+ var selExisting = sql.select(_lockTable(), _sqlOpts(db))
178
+ .columns(["lockedAt", "lockedBy"]).where("scope", "lock").toSql();
179
+ var selStmt = db.prepare(selExisting.sql);
180
+ var existing = selStmt.get.apply(selStmt, selExisting.params);
153
181
  if (!existing) {
154
182
  // Race window between INSERT failure and SELECT — try once more.
155
183
  try {
156
- db.prepare(
157
- "INSERT INTO " + Q_LOCK_TABLE + " (scope, lockedAt, lockedBy) VALUES ('lock', ?, ?)"
158
- ).run(nowMs, holder);
184
+ var retryStmt = db.prepare(insertLock.sql);
185
+ retryStmt.run.apply(retryStmt, insertLock.params);
159
186
  return holder;
160
187
  } catch (e2) {
161
188
  throw new MigrationError("migrations/lock-busy",
@@ -165,25 +192,32 @@ function _acquireLock(db, opts) {
165
192
  }
166
193
  var ageMs = nowMs - Number(existing.lockedAt);
167
194
  if (staleAfterMs > 0 && ageMs > staleAfterMs) {
168
- // Force-replace the stale lock. Requires DELETE + INSERT in a
169
- // single transaction so the next process can't slip in between.
170
- _runSql(db, "BEGIN IMMEDIATE");
195
+ // Force-replace the stale lock. The DELETE + INSERT run in a single
196
+ // transaction so the next process can't slip in between. The
197
+ // transaction boundary is dialect-aware: only SQLite has the
198
+ // `BEGIN IMMEDIATE` write-lock-up-front form — Postgres + MySQL
199
+ // reject the `IMMEDIATE` keyword, so the shared runInTransaction
200
+ // helper emits a plain portable `BEGIN`/`COMMIT`/`ROLLBACK` there.
201
+ var lockMode = _handleDialect(db) === "sqlite" ? "IMMEDIATE" : null;
171
202
  try {
172
- db.prepare("DELETE FROM " + Q_LOCK_TABLE + " WHERE scope = 'lock' AND lockedAt = ?")
173
- .run(existing.lockedAt);
174
- db.prepare(
175
- "INSERT INTO " + Q_LOCK_TABLE + " (scope, lockedAt, lockedBy) VALUES ('lock', ?, ?)"
176
- ).run(nowMs, holder);
177
- _runSql(db, "COMMIT");
178
- return holder;
203
+ return dbSchema.runInTransaction(db, function () {
204
+ var delStale = sql.delete(_lockTable(), _sqlOpts(db))
205
+ .where("scope", "lock").where("lockedAt", existing.lockedAt).toSql();
206
+ var delStaleStmt = db.prepare(delStale.sql);
207
+ delStaleStmt.run.apply(delStaleStmt, delStale.params);
208
+ var replStmt = db.prepare(insertLock.sql);
209
+ replStmt.run.apply(replStmt, insertLock.params);
210
+ return holder;
211
+ }, {
212
+ lockMode: lockMode,
213
+ onRollbackFail: function (rollbackErr) {
214
+ log.debug("rollback-failed", {
215
+ op: "lock-stale-replace",
216
+ error: rollbackErr && rollbackErr.message,
217
+ });
218
+ },
219
+ });
179
220
  } catch (forceErr) {
180
- try { _runSql(db, "ROLLBACK"); }
181
- catch (rollbackErr) {
182
- log.debug("rollback-failed", {
183
- op: "lock-stale-replace",
184
- error: rollbackErr && rollbackErr.message,
185
- });
186
- }
187
221
  throw new MigrationError("migrations/lock-stale-replace-failed",
188
222
  "could not replace stale lock: " + ((forceErr && forceErr.message) || String(forceErr)),
189
223
  true);
@@ -202,9 +236,10 @@ function _releaseLock(db, holder) {
202
236
  // shouldn't have its lock cleared by an unrelated next deploy unless
203
237
  // the operator explicitly used the staleAfterMs nodePath.
204
238
  try {
205
- db.prepare(
206
- "DELETE FROM " + Q_LOCK_TABLE + " WHERE scope = 'lock' AND lockedBy = ?"
207
- ).run(holder);
239
+ var rel = sql.delete(_lockTable(), _sqlOpts(db))
240
+ .where("scope", "lock").where("lockedBy", holder).toSql();
241
+ var relStmt = db.prepare(rel.sql);
242
+ relStmt.run.apply(relStmt, rel.params);
208
243
  } catch (_e) { /* best-effort release; operator can DELETE manually */ }
209
244
  }
210
245
 
@@ -271,10 +306,11 @@ function create(opts) {
271
306
  function _appliedRows() {
272
307
  var db = _resolveDb(opts);
273
308
  _ensureTable(db);
274
- return db.prepare(
275
- "SELECT name, description, appliedAt FROM " + Q_MIGRATIONS_TABLE +
276
- " ORDER BY appliedAt ASC, name ASC"
277
- ).all();
309
+ var q = sql.select(_migrationsTable(), _sqlOpts(db))
310
+ .columns(["name", "description", "appliedAt"])
311
+ .orderBy("appliedAt", "asc").orderBy("name", "asc").toSql();
312
+ var stmt = db.prepare(q.sql);
313
+ return stmt.all.apply(stmt, q.params);
278
314
  }
279
315
 
280
316
  function status() {
@@ -293,8 +329,10 @@ function create(opts) {
293
329
  var db = _resolveDb(opts);
294
330
  _ensureTable(db);
295
331
  return _withLock(db, opts, function () {
332
+ var namesQ = sql.select(_migrationsTable(), _sqlOpts(db)).columns(["name"]).toSql();
333
+ var namesStmt = db.prepare(namesQ.sql);
296
334
  var appliedSet = new Set(
297
- db.prepare("SELECT name FROM " + Q_MIGRATIONS_TABLE).all()
335
+ namesStmt.all.apply(namesStmt, namesQ.params)
298
336
  .map(function (r) { return r.name; })
299
337
  );
300
338
  var files = _list(dir);
@@ -307,10 +345,11 @@ function create(opts) {
307
345
  try {
308
346
  _txn(db, function () {
309
347
  mod.up(db);
310
- db.prepare(
311
- "INSERT INTO " + Q_MIGRATIONS_TABLE +
312
- " (name, description, appliedAt) VALUES (?, ?, ?)"
313
- ).run(file, mod.description || "", new Date().toISOString());
348
+ var insQ = sql.insert(_migrationsTable(), _sqlOpts(db))
349
+ .values({ name: file, description: mod.description || "",
350
+ appliedAt: new Date().toISOString() }).toSql();
351
+ var insStmt = db.prepare(insQ.sql);
352
+ insStmt.run.apply(insStmt, insQ.params);
314
353
  });
315
354
  } catch (e) {
316
355
  throw new MigrationError("migrations/up-failed",
@@ -336,11 +375,12 @@ function create(opts) {
336
375
  return _withLock(db, opts, function () {
337
376
  // Most-recent applied first (reverse chronological by appliedAt
338
377
  // then by name as a stable tiebreaker for fixtures with identical
339
- // timestamps).
340
- var rows = db.prepare(
341
- "SELECT name FROM " + Q_MIGRATIONS_TABLE +
342
- " ORDER BY appliedAt DESC, name DESC LIMIT ?"
343
- ).all(steps);
378
+ // timestamps). steps is a validated positive integer, so b.sql
379
+ // inlines the LIMIT.
380
+ var downQ = sql.select(_migrationsTable(), _sqlOpts(db)).columns(["name"])
381
+ .orderBy("appliedAt", "desc").orderBy("name", "desc").limit(steps).toSql();
382
+ var downStmt = db.prepare(downQ.sql);
383
+ var rows = downStmt.all.apply(downStmt, downQ.params);
344
384
 
345
385
  var reverted = [];
346
386
  for (var i = 0; i < rows.length; i++) {
@@ -355,7 +395,9 @@ function create(opts) {
355
395
  try {
356
396
  _txn(db, function () {
357
397
  mod.down(db);
358
- db.prepare("DELETE FROM " + Q_MIGRATIONS_TABLE + " WHERE name = ?").run(file);
398
+ var delQ = sql.delete(_migrationsTable(), _sqlOpts(db)).where("name", file).toSql();
399
+ var delStmt = db.prepare(delQ.sql);
400
+ delStmt.run.apply(delStmt, delQ.params);
359
401
  });
360
402
  } catch (e) {
361
403
  throw new MigrationError("migrations/down-failed",
@@ -72,6 +72,13 @@ function _probeHttp(target, timeoutMs) {
72
72
  url: target.url,
73
73
  method: target.method || "GET",
74
74
  timeoutMs: timeoutMs,
75
+ // Forward the target's protocol/host allowlists so an operator who
76
+ // opts a cleartext http:// heartbeat in (allowedProtocols:
77
+ // b.safeUrl.ALLOW_HTTP_ALL) is honoured. Left undefined, httpClient
78
+ // applies its https-only default (ALLOW_HTTP_TLS) — so an http://
79
+ // target with no opt-in is still rejected, not silently probed.
80
+ allowedProtocols: target.allowedProtocols,
81
+ allowedHosts: target.allowedHosts,
75
82
  allowInternal: target.allowInternal === true ? true : target.allowInternal,
76
83
  });
77
84
  p.then(function (res) {
@@ -43,10 +43,30 @@
43
43
 
44
44
  var clusterStorage = require("./cluster-storage");
45
45
  var C = require("./constants");
46
+ var frameworkSchema = require("./framework-schema");
46
47
  var safeAsync = require("./safe-async");
48
+ var sql = require("./sql");
47
49
  var { defineClass } = require("./framework-error");
48
50
  var { boundedMap } = require("./bounded-map");
49
51
 
52
+ // Cluster-backend table — resolved through frameworkSchema.tableName so a
53
+ // configured table prefix (b.frameworkSchema.setTablePrefix) is honored.
54
+ // The name is identity-mapped in LOCAL_TO_EXTERNAL, so clusterStorage's
55
+ // resolveTables leaves it untouched at dispatch and the resolved name is
56
+ // what reaches the backend on both sides.
57
+ var NONCE_TABLE = "_blamejs_api_encrypt_nonces"; // allow:hand-rolled-sql — canonical logical table-name declaration
58
+
59
+ // b.sql opts for every cluster-backend statement: thread the ACTIVE backend
60
+ // dialect (clusterStorage.dialect() — "sqlite" single-node, "postgres" |
61
+ // "mysql" in cluster mode) so the emitted identifier quoting and dialect
62
+ // idioms (ON CONFLICT vs ON DUPLICATE KEY) match the backend the SQL
63
+ // dispatches to. Defaulting to "sqlite" works on Postgres only by accident
64
+ // (both double-quote identifiers) and emits invalid quoting + ON CONFLICT on
65
+ // MySQL. clusterStorage.execute still rewrites table names + translates `?`
66
+ // placeholders at dispatch; this controls only the builder-side quoting +
67
+ // idiom selection.
68
+ function _nonceSqlOpts() { return { dialect: clusterStorage.dialect() }; }
69
+
50
70
  var NonceStoreError = defineClass("NonceStoreError");
51
71
 
52
72
  var DEFAULT_SWEEP_INTERVAL_MS = C.TIME.minutes(5);
@@ -148,19 +168,21 @@ function _clusterBackend(_opts) {
148
168
  // (someone else already inserted the same nonce, i.e. replay).
149
169
  // The middleware hashes the raw nonce before passing it here so
150
170
  // the table only ever sees hashes, not the originals.
151
- var result = await clusterStorage.execute(
152
- "INSERT INTO _blamejs_api_encrypt_nonces (nonceHash, expireAt) " +
153
- "VALUES (?, ?) ON CONFLICT (nonceHash) DO NOTHING",
154
- [nonce, expireAt]
155
- );
171
+ var built = sql.upsert(frameworkSchema.tableName(NONCE_TABLE), _nonceSqlOpts())
172
+ .columns(["nonceHash", "expireAt"])
173
+ .values({ nonceHash: nonce, expireAt: expireAt })
174
+ .onConflict(["nonceHash"])
175
+ .doNothing()
176
+ .toSql();
177
+ var result = await clusterStorage.execute(built.sql, built.params);
156
178
  return (result && result.rowCount > 0);
157
179
  }
158
180
 
159
181
  async function purgeExpired() {
160
- var result = await clusterStorage.execute(
161
- "DELETE FROM _blamejs_api_encrypt_nonces WHERE expireAt <= ?",
162
- [Date.now()]
163
- );
182
+ var built = sql.delete(frameworkSchema.tableName(NONCE_TABLE), _nonceSqlOpts())
183
+ .where("expireAt", "<=", Date.now())
184
+ .toSql();
185
+ var result = await clusterStorage.execute(built.sql, built.params);
164
186
  return (result && result.rowCount) || 0;
165
187
  }
166
188
 
@@ -214,6 +214,11 @@ function create(config) {
214
214
  var timeoutMs = config.timeoutMs;
215
215
  var allowedProtocols = config.allowedProtocols || safeUrl.ALLOW_HTTP_TLS;
216
216
  var allowInternal = config.allowInternal != null ? config.allowInternal : null;
217
+ // Account placement — see azure-blob.js create(). Default host-based; opt
218
+ // into path-style (Azurite / Azure Stack / private) with config.pathStyle:
219
+ // true. Default false keeps the host-based wire shape unchanged.
220
+ var pathStyle = config.pathStyle === true;
221
+ var pathPrefix = pathStyle ? ("/" + config.accountName) : "";
217
222
 
218
223
  function _sign(method, url, headers) {
219
224
  return azureBlob.signRequest({
@@ -260,7 +265,7 @@ function create(config) {
260
265
  async function createContainer(name, opts) {
261
266
  _validateContainerName(name);
262
267
  opts = opts || {};
263
- var url = _internalUrl(endpoint + "/" + name + "?restype=container", allowedProtocols);
268
+ var url = _internalUrl(endpoint + pathPrefix + "/" + name + "?restype=container", allowedProtocols);
264
269
  var headers = { "Content-Length": "0" };
265
270
  if (opts.publicAccess) {
266
271
  if (opts.publicAccess !== "blob" && opts.publicAccess !== "container") {
@@ -282,7 +287,7 @@ function create(config) {
282
287
 
283
288
  async function deleteContainer(name) {
284
289
  _validateContainerName(name);
285
- var url = _internalUrl(endpoint + "/" + name + "?restype=container", allowedProtocols);
290
+ var url = _internalUrl(endpoint + pathPrefix + "/" + name + "?restype=container", allowedProtocols);
286
291
  var signed = _sign("DELETE", url, {});
287
292
  var res = await _request("DELETE", url, signed, null, [HTTP_ACCEPTED, HTTP_NOT_FOUND]);
288
293
  return res.statusCode === HTTP_ACCEPTED;
@@ -290,7 +295,7 @@ function create(config) {
290
295
 
291
296
  async function listContainers(opts) {
292
297
  opts = opts || {};
293
- var url = _internalUrl(endpoint + "/?comp=list", allowedProtocols);
298
+ var url = _internalUrl(endpoint + pathPrefix + "/?comp=list", allowedProtocols);
294
299
  if (opts.prefix) url.searchParams.set("prefix", opts.prefix);
295
300
  if (opts.maxResults != null) url.searchParams.set("maxresults", String(opts.maxResults));
296
301
  var signed = _sign("GET", url, {});
@@ -319,7 +324,7 @@ function create(config) {
319
324
  rules.forEach(_validateCorsRule);
320
325
  var xml = _buildCorsXml(rules);
321
326
  var bodyBuf = Buffer.from(xml, "utf8");
322
- var url = _internalUrl(endpoint + "/?restype=service&comp=properties", allowedProtocols);
327
+ var url = _internalUrl(endpoint + pathPrefix + "/?restype=service&comp=properties", allowedProtocols);
323
328
  var headers = {
324
329
  "Content-Type": "application/xml",
325
330
  "Content-Length": String(bodyBuf.length),
@@ -15,6 +15,11 @@
15
15
  * accountKey: '<base64 storage key>' // required (REST shared key)
16
16
  * container: 'my-container' // required
17
17
  * endpoint: 'https://...' // optional override
18
+ * pathStyle: true // optional; account as the first
19
+ * // URL path segment (Azurite / Azure
20
+ * // Stack / private endpoints).
21
+ * // Default false = host-based
22
+ * // (<account>.blob.core.windows.net).
18
23
  * apiVersion: '2024-08-04' // x-ms-version header
19
24
  * timeoutMs: C.TIME.seconds(30)
20
25
  * }
@@ -35,6 +40,7 @@ var { URL } = require("node:url");
35
40
  var { Readable } = require("node:stream");
36
41
  var safeXml = require("../parsers/safe-xml");
37
42
  var sharedRequest = require("./http-request");
43
+ var sigv4 = require("./sigv4");
38
44
  var C = require("../constants");
39
45
  var requestHelpers = require("../request-helpers");
40
46
  var { ObjectStoreError } = require("../framework-error");
@@ -65,6 +71,26 @@ function _arrayify(value) {
65
71
  return Array.isArray(value) ? value : [value];
66
72
  }
67
73
 
74
+ // Percent-encode a hierarchical blob name for use in a URL path. Azure
75
+ // blob names are `/`-delimited virtual directories, so each segment is
76
+ // RFC 3986 percent-encoded (via the family-shared encoder used by the
77
+ // S3 / GCS backends) while the `/` separators are preserved. Without
78
+ // this, a key containing `?`, `#`, a space, or other reserved chars is
79
+ // interpolated raw into the request URL — `?`/`#` start the query /
80
+ // fragment (so the blob path is truncated, hitting the wrong object or
81
+ // the container root), and spaces / control bytes corrupt the request
82
+ // line (CWE-20 improper input → request-smuggling-adjacent). A null
83
+ // byte is refused outright (it can't appear in a valid blob name and
84
+ // indicates a malformed / hostile key), matching the S3 / GCS guards.
85
+ function _encodeBlobKey(key) {
86
+ if (key.indexOf("\0") !== -1) {
87
+ throw _err("INVALID_KEY", "null byte in blob key", true);
88
+ }
89
+ return key.split("/").map(function (s) {
90
+ return sigv4.awsUriEncode(s, true);
91
+ }).join("/");
92
+ }
93
+
68
94
  var DEFAULT_API_VERSION = "2024-08-04";
69
95
 
70
96
  // Service SAS expiry bounds. Azure doesn't enforce a hard max, but
@@ -120,6 +146,17 @@ function buildStringToSign(opts) {
120
146
  var canonicalResource = (function () {
121
147
  // /<account>/<rest of path>
122
148
  // Plus sorted query params, each "name:value\n"
149
+ // Canonicalized resource per the Shared Key spec: "/" + account + the
150
+ // request's absolute path + sorted query. Host-based endpoints
151
+ // (production <account>.blob.core.windows.net) have url.pathname
152
+ // "/<container>/<blob>", giving "/<account>/<container>/<blob>".
153
+ // Path-style endpoints (Azurite / Azure Stack / private) already carry
154
+ // "/<account>" as the first path segment, so the account appears twice
155
+ // ("/<account>/<account>/<container>/<blob>") — which is exactly what a
156
+ // path-style server expects: it prepends the account to the full request
157
+ // path it received. Verified against Azurite — the doubled form is the
158
+ // one that authenticates; the URL itself must carry the account in its
159
+ // path (see pathPrefix in create()).
123
160
  var resourcePath = "/" + opts.accountName + url.pathname;
124
161
  var paramPairs = [];
125
162
  url.searchParams.forEach(function (v, k) {
@@ -204,11 +241,24 @@ function create(config) {
204
241
  var allowedProtocols = config.allowedProtocols || safeUrl.ALLOW_HTTP_TLS;
205
242
  var allowInternal = config.allowInternal != null ? config.allowInternal : null;
206
243
  safeUrl.parse(endpoint, { allowedProtocols: allowedProtocols, errorClass: ObjectStoreError });
244
+ // Account placement. Default host-based — production Azure is
245
+ // https://<account>.blob.core.windows.net/<container>/<blob> (account in the
246
+ // host). Path-style endpoints (Azurite / Azure Stack / private) carry the
247
+ // account as the first PATH segment instead —
248
+ // https://<host>/<account>/<container>/<blob> — opt in with
249
+ // config.pathStyle:true. Default false keeps the host-based wire shape
250
+ // unchanged for existing deployments (no silent breaking change). The signed
251
+ // canonicalized resource is always "/" + account + url.pathname, so for a
252
+ // path-style URL the account appears twice — which is exactly what a
253
+ // path-style server expects (see buildStringToSign).
254
+ var pathStyle = config.pathStyle === true;
255
+ var pathPrefix = pathStyle ? ("/" + config.accountName) : "";
207
256
  var reqOpts = { timeoutMs: timeoutMs, allowedProtocols: allowedProtocols };
208
257
  if (allowInternal !== null) reqOpts.allowInternal = allowInternal;
209
258
 
210
259
  function _blobUrl(key, params) {
211
- var u = _internalUrl(endpoint + "/" + config.container + "/" + key, allowedProtocols);
260
+ var u = _internalUrl(endpoint + pathPrefix + "/" + config.container + "/" + _encodeBlobKey(key),
261
+ allowedProtocols);
212
262
  if (params) {
213
263
  Object.keys(params).forEach(function (k) { u.searchParams.set(k, params[k]); });
214
264
  }
@@ -216,7 +266,7 @@ function create(config) {
216
266
  }
217
267
 
218
268
  function _containerUrl(params) {
219
- var u = _internalUrl(endpoint + "/" + config.container, allowedProtocols);
269
+ var u = _internalUrl(endpoint + pathPrefix + "/" + config.container, allowedProtocols);
220
270
  if (params) {
221
271
  Object.keys(params).forEach(function (k) { u.searchParams.set(k, params[k]); });
222
272
  }
@@ -425,8 +475,12 @@ function create(config) {
425
475
  throw _err("INVALID_KEY", "null byte in key", true);
426
476
  }
427
477
 
478
+ // _buildSasToken signs the canonicalized resource with the RAW
479
+ // (decoded) blob name per the Azure SAS spec; the URL PATH carries the
480
+ // percent-encoded key so a key with reserved chars (`?` / `#` / space)
481
+ // doesn't truncate the path or corrupt the request line.
428
482
  var token = _buildSasToken(permissions, opts);
429
- var url = _internalUrl(endpoint + "/" + config.container + "/" + opts.key + "?" + token.sas, allowedProtocols);
483
+ var url = _internalUrl(endpoint + pathPrefix + "/" + config.container + "/" + _encodeBlobKey(opts.key) + "?" + token.sas, allowedProtocols);
430
484
 
431
485
  var clientHeaders = {};
432
486
  if (opts.contentType) clientHeaders["Content-Type"] = opts.contentType;
@@ -661,6 +661,16 @@ function create(config) {
661
661
  etag: res.headers.etag,
662
662
  lastModified: res.headers["last-modified"] ? Date.parse(res.headers["last-modified"]) : null,
663
663
  };
664
+ }, function (e) {
665
+ // A missing key surfaces as the framework NOT_FOUND code — the same
666
+ // contract local.js head() exposes and that deleteKey already maps 404
667
+ // to — so existence probes via head() (e.g. the backup objectStore
668
+ // adapter's hasKey / statKey) get the uniform missing-key signal instead
669
+ // of a raw HTTP 404 they don't recognize.
670
+ if (e && e.statusCode === 404) {
671
+ throw _err("NOT_FOUND", "key not found: " + key, true);
672
+ }
673
+ throw e;
664
674
  });
665
675
  }
666
676
 
@@ -55,6 +55,14 @@ var safeBuffer = lazyRequire(function () { return require("./safe-buffer"); });
55
55
  var tracing = lazyRequire(function () { return require("./tracing"); });
56
56
  var metrics = lazyRequire(function () { return require("./metrics"); });
57
57
 
58
+ // redact is the framework's central PII/secret scrubber. Lazy-loaded so
59
+ // the require graph stays acyclic at boot (redact lazy-pulls audit, which
60
+ // pulls observability back). Composed by the default telemetry redactor
61
+ // below so span/metric attribute VALUES are scrubbed before the OTLP
62
+ // exporter serializes them — CWE-532 (insertion of sensitive information
63
+ // into a telemetry/log egress sink).
64
+ var redact = lazyRequire(function () { return require("./redact"); });
65
+
58
66
  // Operator-installed tap handler — wired via setTap(). When non-null,
59
67
  // every observability event/tap dispatch routes here in addition to
60
68
  // the framework's metrics module. Used by b.otelExport.create() so an
@@ -62,6 +70,26 @@ var metrics = lazyRequire(function () { return require("./metrics"); });
62
70
  // emits internally.
63
71
  var _externalTap = null;
64
72
 
73
+ // Telemetry-attribute redactor seam. Span / metric attribute VALUES are
74
+ // a first-class egress surface: a span attribute holding a user email,
75
+ // bearer token, or vault-sealed ciphertext is shipped verbatim to the
76
+ // OTLP collector unless it is scrubbed at the assembly boundary, the same
77
+ // way log-stream redacts every record before any sink sees it. Defaults
78
+ // ON — the default redactor composes b.redact.redact, passing the
79
+ // attribute key as the parent-key context so both field-name rules
80
+ // (authorization / token / session / password) and value-shape detectors
81
+ // (JWT / PEM / credit-card / SSN / connection-string) fire. CWE-532.
82
+ //
83
+ // The redactor is (value, key) → redactedValue. The exporter calls it for
84
+ // every attribute value; a thrown redactor drops the attribute rather
85
+ // than leaking it (the exporter enforces fail-toward-dropping), so a
86
+ // misbehaving custom redactor can never widen the egress surface.
87
+ function _defaultTelemetryRedactor(value, key) {
88
+ return redact().redact(value, { parentKey: typeof key === "string" ? key : null });
89
+ }
90
+
91
+ var _telemetryRedactor = _defaultTelemetryRedactor;
92
+
65
93
  function _safeMetricsTap(name, value, labels) {
66
94
  try { metrics().tap(name, value, labels); }
67
95
  catch (_e) { /* boot-order tolerance — metrics may not be loaded */ }
@@ -106,6 +134,63 @@ function setTap(handler) {
106
134
  _externalTap = handler;
107
135
  }
108
136
 
137
+ /**
138
+ * @primitive b.observability.setRedactor
139
+ * @signature b.observability.setRedactor(redactor)
140
+ * @since 0.14.27
141
+ * @related b.observability.getRedactor, b.redact.redact
142
+ *
143
+ * Override the redactor applied to every span / metric attribute VALUE
144
+ * before the OTLP exporter serializes it onto the wire. Telemetry is a
145
+ * first-class egress sink: an attribute holding a user email, bearer
146
+ * token, or secret would otherwise reach the collector in plaintext
147
+ * (CWE-532). Redaction is ON by default — the default redactor composes
148
+ * `b.redact.redact` and fires both field-name and value-shape rules; this
149
+ * setter only lets an operator swap in a stricter or domain-specific
150
+ * scrubber.
151
+ *
152
+ * The redactor is `redactor(value, key)` and returns the value to export.
153
+ * It runs on the export hot path, so a throw is caught and the attribute
154
+ * is dropped (never exported raw) — a redactor that throws can only
155
+ * shrink the egress surface, never widen it. Pass `null` to restore the
156
+ * default `b.redact.redact`-backed redactor.
157
+ *
158
+ * @example
159
+ * b.observability.setRedactor(function (value, key) {
160
+ * if (key === "enduser.id") return "[REDACTED]";
161
+ * return b.redact.redact(value, { parentKey: key });
162
+ * });
163
+ * b.observability.setRedactor(null); // restore the default
164
+ */
165
+ function setRedactor(redactor) {
166
+ if (redactor !== null && typeof redactor !== "function") {
167
+ throw new TypeError("observability.setRedactor: redactor must be a function or null, got " +
168
+ typeof redactor);
169
+ }
170
+ _telemetryRedactor = redactor === null ? _defaultTelemetryRedactor : redactor;
171
+ }
172
+
173
+ /**
174
+ * @primitive b.observability.getRedactor
175
+ * @signature b.observability.getRedactor()
176
+ * @since 0.14.27
177
+ * @related b.observability.setRedactor, b.redact.redact
178
+ *
179
+ * Return the redactor currently applied to span / metric attribute
180
+ * values on the OTLP egress path. The OTLP exporter calls this to scrub
181
+ * every attribute value before serialization; operators rarely need it
182
+ * directly. When no override has been installed it returns the default
183
+ * `b.redact.redact`-backed redactor.
184
+ *
185
+ * @example
186
+ * var redactor = b.observability.getRedactor();
187
+ * redactor("Bearer eyJabc.eyJdef.sig", "authorization");
188
+ * // → "[REDACTED]" (field-name rule on the "authorization" key)
189
+ */
190
+ function getRedactor() {
191
+ return _telemetryRedactor;
192
+ }
193
+
109
194
  /**
110
195
  * @primitive b.observability.tap
111
196
  * @signature b.observability.tap(name, attrs, fn)
@@ -750,6 +835,8 @@ module.exports = {
750
835
  safeEvent: safeEvent,
751
836
  timed: timed,
752
837
  setTap: setTap,
838
+ setRedactor: setRedactor,
839
+ getRedactor: getRedactor,
753
840
  SEMCONV: SEMCONV,
754
841
  traceContext: traceContext,
755
842
  baggage: baggage,