@blamejs/core 0.6.1 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.6.x
10
10
 
11
+ - **0.6.2** (2026-05-01) — input validation + identifier-quoting consistency
12
+ - **0.6.1** (2026-05-01) — security tightenings + operator-facing jargon sweep
11
13
  - **0.6.0** (2026-05-01) — wiki restructured into 22 focused pages + missing-primitive coverage
12
14
 
13
15
  ## v0.5.x
package/README.md CHANGED
@@ -17,7 +17,7 @@ The modern Node app is a 1,200-package supply-chain liability with no LTS calend
17
17
 
18
18
  ## Status
19
19
 
20
- Pre-1.0. Usable end-to-end — operators can build production apps on it today; the surface is still subject to change before 1.0. Recent line is **v0.6.1** ([releases](https://github.com/blamejs/blamejs/releases) · [npm](https://www.npmjs.com/package/@blamejs/core) · [container](https://github.com/blamejs/blamejs/pkgs/container/blamejs-wiki)).
20
+ Pre-1.0. Usable end-to-end — operators can build production apps on it today; the surface is still subject to change before 1.0. Recent line is **v0.6.3** ([releases](https://github.com/blamejs/blamejs/releases) · [npm](https://www.npmjs.com/package/@blamejs/core) · [container](https://github.com/blamejs/blamejs/pkgs/container/blamejs-wiki)).
21
21
 
22
22
  ```js
23
23
  var b = require("@blamejs/core");
@@ -63,6 +63,7 @@ Full primitive-by-primitive docs live at [blamejs.com](https://blamejs.com), whi
63
63
  - **Validation** — [Safe Parsers](https://blamejs.com/safe-parsers)
64
64
  - **Communication** — [WebSockets](https://blamejs.com/websockets) · [Mail](https://blamejs.com/mail) · [Notifications](https://blamejs.com/notifications)
65
65
  - **Tools** — [Observability](https://blamejs.com/observability) · [Testing](https://blamejs.com/testing) · [i18n & Locale](https://blamejs.com/i18n-locale) · [Format Helpers](https://blamejs.com/format-helpers)
66
+ - **Compliance** — [Compliance Patterns](https://blamejs.com/compliance-patterns)
66
67
  - **Production** — [Cluster Mode](https://blamejs.com/cluster) · [Reliability](https://blamejs.com/reliability) · [Backup & Restore](https://blamejs.com/backup-restore)
67
68
 
68
69
  ## CLI
package/lib/api-key.js CHANGED
@@ -74,7 +74,12 @@ function _emitEvent(name, value, labels) {
74
74
 
75
75
  var _err = ApiKeyError.factory;
76
76
 
77
- var TABLE = "_blamejs_api_keys";
77
+ var TABLE = "_blamejs_api_keys";
78
+ // Pre-quoted form for SQL interpolation. Defense-in-depth: even though
79
+ // our constant is bare-identifier-shaped, every interpolation site uses
80
+ // the wrapped form so a future rename to a reserved-word or
81
+ // whitespace-bearing name would still resolve correctly.
82
+ var Q_TABLE = '"' + TABLE + '"';
78
83
 
79
84
  // Column order used for INSERT — kept as a constant so the placeholders
80
85
  // list and the values list stay in sync. Must match _blamejs_api_keys'
@@ -286,7 +291,7 @@ function create(opts) {
286
291
  function _selectAll() {
287
292
  return "SELECT id, namespace, ownerId, ownerIdHash, secretHash, " +
288
293
  "secondarySecretHash, secondaryExpiresAt, " +
289
- "scopes, metadata, createdAt, expiresAt, revokedAt, lastUsedAt, prefix FROM " + TABLE;
294
+ "scopes, metadata, createdAt, expiresAt, revokedAt, lastUsedAt, prefix FROM " + Q_TABLE;
290
295
  }
291
296
 
292
297
  function _scrubRecord(row) {
@@ -352,7 +357,7 @@ function create(opts) {
352
357
  var quoted = COLS.map(function (c) { return '"' + c + '"'; }).join(", ");
353
358
 
354
359
  await clusterStorage.execute(
355
- "INSERT INTO " + TABLE + " (" + quoted + ") VALUES (" + placeholders + ")",
360
+ "INSERT INTO " + Q_TABLE + " (" + quoted + ") VALUES (" + placeholders + ")",
356
361
  values
357
362
  );
358
363
 
@@ -453,7 +458,7 @@ function create(opts) {
453
458
  if (trackLastUsedAt && cluster.isLeader()) {
454
459
  try {
455
460
  await clusterStorage.execute(
456
- "UPDATE " + TABLE + " SET lastUsedAt = ? WHERE id = ?",
461
+ "UPDATE " + Q_TABLE + " SET lastUsedAt = ? WHERE id = ?",
457
462
  [nowMs, compositeId]
458
463
  );
459
464
  } catch (_e) { /* best-effort; verify success not blocked by lastUsed update */ }
@@ -480,7 +485,7 @@ function create(opts) {
480
485
  var compositeId = _composedId(namespace, idHex);
481
486
  var nowMs = clock();
482
487
  var result = await clusterStorage.execute(
483
- "UPDATE " + TABLE + " SET revokedAt = ? WHERE id = ? AND revokedAt IS NULL",
488
+ "UPDATE " + Q_TABLE + " SET revokedAt = ? WHERE id = ? AND revokedAt IS NULL",
484
489
  [nowMs, compositeId]
485
490
  );
486
491
  var changed = (result.rowCount || 0) > 0;
@@ -537,7 +542,7 @@ function create(opts) {
537
542
  if (gracePeriodMs > 0) {
538
543
  // Move current hash → secondary slot, install new hash as primary.
539
544
  await clusterStorage.execute(
540
- "UPDATE " + TABLE + " SET secretHash = ?, " +
545
+ "UPDATE " + Q_TABLE + " SET secretHash = ?, " +
541
546
  "secondarySecretHash = ?, secondaryExpiresAt = ? WHERE id = ?",
542
547
  [newHash, existing.secretHash, nowMs + gracePeriodMs, compositeId]
543
548
  );
@@ -545,7 +550,7 @@ function create(opts) {
545
550
  // Hard cutover — old secret stops working immediately. Clears
546
551
  // any prior secondary slot too.
547
552
  await clusterStorage.execute(
548
- "UPDATE " + TABLE + " SET secretHash = ?, " +
553
+ "UPDATE " + Q_TABLE + " SET secretHash = ?, " +
549
554
  "secondarySecretHash = NULL, secondaryExpiresAt = NULL WHERE id = ?",
550
555
  [newHash, compositeId]
551
556
  );
@@ -639,7 +644,7 @@ function create(opts) {
639
644
  // extra round-trip per purge call which runs on a schedule (not
640
645
  // request-rate), so the cost is irrelevant.
641
646
  var idRows = await clusterStorage.execute(
642
- "SELECT id FROM " + TABLE + " WHERE namespace = ? AND " +
647
+ "SELECT id FROM " + Q_TABLE + " WHERE namespace = ? AND " +
643
648
  "((revokedAt IS NOT NULL AND revokedAt < ?) OR " +
644
649
  " (expiresAt IS NOT NULL AND expiresAt < ?))",
645
650
  [namespace, threshold, threshold]
@@ -652,7 +657,7 @@ function create(opts) {
652
657
  }
653
658
 
654
659
  var result = await clusterStorage.execute(
655
- "DELETE FROM " + TABLE + " WHERE namespace = ? AND " +
660
+ "DELETE FROM " + Q_TABLE + " WHERE namespace = ? AND " +
656
661
  "((revokedAt IS NOT NULL AND revokedAt < ?) OR " +
657
662
  " (expiresAt IS NOT NULL AND expiresAt < ?))",
658
663
  [namespace, threshold, threshold]
@@ -48,6 +48,7 @@ var observability = require("./observability");
48
48
  var requestHelpers = require("./request-helpers");
49
49
  var safeAsync = require("./safe-async");
50
50
  var safeJson = require("./safe-json");
51
+ var safeSql = require("./safe-sql");
51
52
  var totp = require("./totp");
52
53
  var validateOpts = require("./validate-opts");
53
54
  var { defineClass } = require("./framework-error");
@@ -248,8 +249,12 @@ async function migrate(table, opts) {
248
249
  var lastId = "";
249
250
  // Iterate via _id-keyset paging so we don't load the whole table into memory.
250
251
  while (true) {
252
+ // table is already validated as a safe identifier shape via
253
+ // _validatePolicySet — wrap in "..." per the framework's
254
+ // identifier-quoting convention.
255
+ var qTable = '"' + table + '"';
251
256
  var rows = await clusterStorage.executeAll(
252
- "SELECT * FROM " + table + " WHERE _id > ? ORDER BY _id ASC LIMIT ?",
257
+ "SELECT * FROM " + qTable + " WHERE _id > ? ORDER BY _id ASC LIMIT ?",
253
258
  [lastId, batchSize]
254
259
  );
255
260
  if (!rows || rows.length === 0) break;
@@ -275,11 +280,14 @@ async function migrate(table, opts) {
275
280
  // the cell ciphertext stays as a literal string, not double-sealed.
276
281
  var setCols = Object.keys(update).filter(function (k) { return k !== "_id"; });
277
282
  if (setCols.length > 0) {
278
- var setSql = setCols.map(function (k) { return k + " = ?"; }).join(", ");
283
+ // Column names came from the validated policy.columns
284
+ // also wrap each in "..." for the same identifier-quoting
285
+ // convention.
286
+ var setSql = setCols.map(function (k) { return '"' + k + '" = ?'; }).join(", ");
279
287
  var vals = setCols.map(function (k) { return update[k]; });
280
288
  vals.push(row._id);
281
289
  await clusterStorage.execute(
282
- "UPDATE " + table + " SET " + setSql + " WHERE _id = ?",
290
+ "UPDATE " + qTable + " SET " + setSql + " WHERE _id = ?",
283
291
  vals
284
292
  );
285
293
  migratedRows++;
@@ -343,6 +351,20 @@ function _validatePolicySet(table, opts) {
343
351
  throw new BreakGlassError("breakglass/bad-policy",
344
352
  "policy.set: table must be a non-empty string");
345
353
  }
354
+ // Identifier safety: the table name flows raw into SQL via interpolation
355
+ // in migrate() / unsealRowAsService(). safeSql.validateIdentifier closes
356
+ // the shape so a malicious / mistyped name with embedded `"` or
357
+ // SQL-keyword shape can't break out of the wrapping quotes.
358
+ // allowReserved: true because every interpolation site quotes the
359
+ // identifier, so reserved-word names work via the SQL standard quoting
360
+ // rule.
361
+ try {
362
+ safeSql.validateIdentifier(table, { allowReserved: true });
363
+ } catch (e) {
364
+ throw new BreakGlassError("breakglass/bad-policy",
365
+ "policy.set: table '" + table + "' is not a valid SQL identifier: " +
366
+ ((e && e.message) || String(e)));
367
+ }
346
368
  if (!opts || typeof opts !== "object") {
347
369
  throw new BreakGlassError("breakglass/bad-policy",
348
370
  "policy.set: opts is required");
@@ -358,10 +380,20 @@ function _validatePolicySet(table, opts) {
358
380
  "policy.set: columns must be a non-empty array");
359
381
  }
360
382
  for (var i = 0; i < opts.columns.length; i++) {
361
- if (typeof opts.columns[i] !== "string" || opts.columns[i].length === 0) {
383
+ var colName = opts.columns[i];
384
+ if (typeof colName !== "string" || colName.length === 0) {
362
385
  throw new BreakGlassError("breakglass/bad-policy",
363
386
  "policy.set: columns[" + i + "] must be a non-empty string");
364
387
  }
388
+ // Same identifier-shape check as the table — column names flow into
389
+ // the migrate() UPDATE statement as bare names.
390
+ try {
391
+ safeSql.validateIdentifier(colName, { allowReserved: true });
392
+ } catch (e) {
393
+ throw new BreakGlassError("breakglass/bad-policy",
394
+ "policy.set: columns[" + i + "]='" + colName + "' is not a valid SQL identifier: " +
395
+ ((e && e.message) || String(e)));
396
+ }
365
397
  }
366
398
  if (!Array.isArray(opts.factors) || opts.factors.length === 0) {
367
399
  throw new BreakGlassError("breakglass/bad-policy",
@@ -894,7 +926,7 @@ async function unsealRow(grantHandle, table, rowId, opts) {
894
926
  // glass-locked columns separately (their ciphertext was written
895
927
  // by encryptCell at app-write time, not by cryptoField.sealRow).
896
928
  var rows = await clusterStorage.executeAll(
897
- "SELECT * FROM " + table + " WHERE _id = ?",
929
+ "SELECT * FROM " + '"' + table + '"' + " WHERE _id = ?",
898
930
  [String(rowId)]
899
931
  );
900
932
  if (!rows || rows.length === 0) {
@@ -1093,7 +1125,7 @@ async function unsealRowAsService(req, table, rowId, opts) {
1093
1125
  // Fetch + unseal the row (Model A or Model B path, same as
1094
1126
  // operator-initiated unsealRow).
1095
1127
  var rows = await clusterStorage.executeAll(
1096
- "SELECT * FROM " + table + " WHERE _id = ?",
1128
+ "SELECT * FROM " + '"' + table + '"' + " WHERE _id = ?",
1097
1129
  [String(rowId)]
1098
1130
  );
1099
1131
  if (!rows || rows.length === 0) {
package/lib/cluster.js CHANGED
@@ -57,6 +57,7 @@ var lazyRequire = require("./lazy-require");
57
57
  var { boot } = require("./log");
58
58
  var safeAsync = require("./safe-async");
59
59
  var safeJson = require("./safe-json");
60
+ var safeSql = require("./safe-sql");
60
61
  var safeUrl = require("./safe-url");
61
62
  var { FrameworkError, ClusterError } = require("./framework-error");
62
63
 
@@ -273,12 +274,21 @@ async function init(opts) {
273
274
  // hash → FATAL via process.exit(1). Same posture as the
274
275
  // single-node audit.tip sidecar rollback check.
275
276
  async function _checkChainTipRollback(chainName, logTable, tipTable) {
277
+ // Both tables are framework-internal constants from the call sites
278
+ // (`_blamejs_audit_log`, `_blamejs_consent_log`, etc.). Validate +
279
+ // quote per the framework's identifier-quoting convention so a
280
+ // future rename can't silently break the query.
281
+ safeSql.validateIdentifier(logTable, { allowReserved: true });
282
+ safeSql.validateIdentifier(tipTable, { allowReserved: true });
283
+ var qLogTable = safeSql.quoteIdentifier(logTable);
284
+ var qTipTable = safeSql.quoteIdentifier(tipTable);
285
+
276
286
  var tipRows;
277
287
  try {
278
288
  tipRows = await externalDb().query(
279
- "SELECT atMonotonicCounter, rowHash FROM " + tipTable +
280
- " WHERE scope = '" + chainName + "'",
281
- [],
289
+ "SELECT atMonotonicCounter, rowHash FROM " + qTipTable +
290
+ " WHERE scope = " + (configuredDialect === "postgres" ? "$1" : "?"),
291
+ [chainName],
282
292
  { backend: configuredExternalDbBackend }
283
293
  );
284
294
  } catch (e) {
@@ -298,7 +308,7 @@ async function _checkChainTipRollback(chainName, logTable, tipTable) {
298
308
  var tipHash = tip.rowHash;
299
309
 
300
310
  var currentRows = await externalDb().query(
301
- "SELECT MAX(monotonicCounter) AS m FROM " + logTable,
311
+ "SELECT MAX(monotonicCounter) AS m FROM " + qLogTable,
302
312
  [],
303
313
  { backend: configuredExternalDbBackend }
304
314
  );
@@ -317,7 +327,7 @@ async function _checkChainTipRollback(chainName, logTable, tipTable) {
317
327
 
318
328
  if (tipHash) {
319
329
  var hashRows = await externalDb().query(
320
- "SELECT rowHash FROM " + logTable + " WHERE monotonicCounter = " +
330
+ "SELECT rowHash FROM " + qLogTable + " WHERE monotonicCounter = " +
321
331
  (configuredDialect === "postgres" ? "$1" : "?"),
322
332
  [tipCounter],
323
333
  { backend: configuredExternalDbBackend }
package/lib/db-query.js CHANGED
@@ -29,11 +29,33 @@
29
29
  var { Readable } = require("node:stream");
30
30
  var cryptoField = require("./crypto-field");
31
31
  var { generateToken } = require("./crypto");
32
+ var safeSql = require("./safe-sql");
32
33
 
33
34
  var ALLOWED_OPS = new Set(["=", "!=", "<>", "<", "<=", ">", ">=", "IS", "IS NOT", "LIKE", "IN"]);
34
35
 
35
36
  class Query {
36
37
  constructor(database, tableName) {
38
+ // Identifier safety: tableName flows into SQL via interpolation
39
+ // (parameter placeholders only bind values, not names). Validate at
40
+ // construction so an attacker-controlled name with embedded `"` or
41
+ // SQL keywords can't break out of the wrapping quotes downstream.
42
+ // Cross-schema queries (e.g., Postgres `public.users`) need the
43
+ // schema-qualified API, not a dotted single-identifier — reject `.`
44
+ // here so the failure mode is explicit.
45
+ if (typeof tableName !== "string") {
46
+ throw new TypeError("Query: tableName must be a string, got " + typeof tableName);
47
+ }
48
+ if (tableName.indexOf(".") !== -1) {
49
+ throw new Error("Query: tableName '" + tableName + "' contains '.' — use a single " +
50
+ "identifier; cross-schema queries are not supported by db.from(). " +
51
+ "For Postgres-style schema.table access, use b.externalDb.query directly.");
52
+ }
53
+ // allowReserved: true — db-query always wraps the identifier in
54
+ // `"..."` so a table named `order` resolves correctly via the SQL
55
+ // standard quoting rule. The reserved-word block in safeSql is for
56
+ // call sites that interpolate unquoted.
57
+ safeSql.validateIdentifier(tableName, { allowReserved: true });
58
+
37
59
  this._db = database;
38
60
  this._table = tableName;
39
61
  this._where = [];
package/lib/db-schema.js CHANGED
@@ -33,10 +33,14 @@ function runSql(database, sql) { return database["exec"](sql); }
33
33
  // ---- Internal migrations table ----
34
34
 
35
35
  var MIGRATIONS_TABLE = "_blamejs_migrations";
36
+ // Pre-quoted for SQL interpolation — keeps the call sites consistent
37
+ // with lib/migrations.js and lib/seeders.js so an identifier rename
38
+ // doesn't silently break.
39
+ var Q_MIGRATIONS_TABLE = '"' + MIGRATIONS_TABLE + '"';
36
40
 
37
41
  function ensureMigrationsTable(database) {
38
42
  runSql(database,
39
- "CREATE TABLE IF NOT EXISTS " + MIGRATIONS_TABLE + " (" +
43
+ "CREATE TABLE IF NOT EXISTS " + Q_MIGRATIONS_TABLE + " (" +
40
44
  " name TEXT PRIMARY KEY," +
41
45
  " description TEXT," +
42
46
  " appliedAt TEXT NOT NULL" +
@@ -200,7 +204,7 @@ function runMigrations(database, migrationDir) {
200
204
  }).map(function (e) { return e.name; }).sort();
201
205
 
202
206
  var appliedSet = new Set();
203
- database.prepare("SELECT name FROM " + MIGRATIONS_TABLE).all().forEach(function (r) {
207
+ database.prepare("SELECT name FROM " + Q_MIGRATIONS_TABLE).all().forEach(function (r) {
204
208
  appliedSet.add(r.name);
205
209
  });
206
210
 
@@ -227,7 +231,7 @@ function runMigrations(database, migrationDir) {
227
231
  runSql(database, "BEGIN");
228
232
  mig.up(database);
229
233
  database.prepare(
230
- "INSERT INTO " + MIGRATIONS_TABLE + " (name, description, appliedAt) VALUES (?, ?, ?)"
234
+ "INSERT INTO " + Q_MIGRATIONS_TABLE + " (name, description, appliedAt) VALUES (?, ?, ?)"
231
235
  ).run(file, mig.description || "", new Date().toISOString());
232
236
  runSql(database, "COMMIT");
233
237
  } catch (e) {
@@ -54,6 +54,7 @@ var retryHelper = require("./retry");
54
54
  var C = require("./constants");
55
55
  var lazyRequire = require("./lazy-require");
56
56
  var safeAsync = require("./safe-async");
57
+ var safeSql = require("./safe-sql");
57
58
  var { ExternalDbError } = require("./framework-error");
58
59
 
59
60
  var audit = lazyRequire(function () { return require("./audit"); });
@@ -187,6 +188,9 @@ function init(opts) {
187
188
  residencyTag: cfg.residencyTag || "unrestricted",
188
189
  breaker: new retryHelper.CircuitBreaker("externalDb:" + name, cfg.breaker),
189
190
  retryConfig: cfg.retry || null,
191
+ replicas: _buildReplicas(name, cfg),
192
+ replicaIdx: 0, // round-robin cursor
193
+ replicaFallbackToPrimary: cfg.replicaFallbackToPrimary !== false,
190
194
  };
191
195
  }
192
196
 
@@ -380,6 +384,12 @@ async function shutdown() {
380
384
  if (!initialized) return;
381
385
  for (var name in backends) {
382
386
  try { await backends[name].pool.drain(); } catch (_e) { /* best effort */ }
387
+ var bk = backends[name];
388
+ if (bk && bk.replicas) {
389
+ for (var i = 0; i < bk.replicas.length; i++) {
390
+ try { await bk.replicas[i].pool.drain(); } catch (_e) { /* best effort */ }
391
+ }
392
+ }
383
393
  }
384
394
  backends = {};
385
395
  defaultBackend = null;
@@ -400,9 +410,173 @@ function _requireInit() {
400
410
  if (!initialized) throw _err("NOT_INITIALIZED", "externalDb.init() must be called first", true);
401
411
  }
402
412
 
413
+ // ---- Read-replica routing ----
414
+ //
415
+ // Operators with a primary + replicas declare replicas alongside the
416
+ // primary backend config:
417
+ //
418
+ // externalDb.init({
419
+ // backends: {
420
+ // main: {
421
+ // connect, query, // primary
422
+ // replicas: [
423
+ // { connect: replica1, query, weight: 1 },
424
+ // { connect: replica2, query, weight: 2 },
425
+ // ],
426
+ // replicaFallbackToPrimary: true, // default; on all-replicas-unhealthy,
427
+ // // read.query falls back to primary
428
+ // },
429
+ // },
430
+ // });
431
+ //
432
+ // await externalDb.read.query("SELECT * FROM users"); // → replica
433
+ // await externalDb.write.query("INSERT INTO users ..."); // → primary
434
+ // await externalDb.query("..."); // → primary (legacy, unchanged)
435
+ //
436
+ // Load balancing: weighted round-robin (default weight 1). Weights
437
+ // expand into a static plan at init — a [w1, w2, w3] vector becomes a
438
+ // pre-built index sequence, then read.query() advances replicaIdx.
439
+ //
440
+ // Health: each replica tracks `lastFailureAt`. After UNHEALTHY_COOLDOWN_MS
441
+ // since the last failure, the replica re-enters the rotation. Operators
442
+ // observing all-replicas-down see read.query() fall back to primary
443
+ // (overridable via replicaFallbackToPrimary: false).
444
+
445
+ var REPLICA_UNHEALTHY_COOLDOWN_MS = C.TIME.seconds(30);
446
+
447
+ function _buildReplicas(backendName, cfg) {
448
+ if (!cfg.replicas) return null;
449
+ if (!Array.isArray(cfg.replicas) || cfg.replicas.length === 0) {
450
+ throw _err("INVALID_CONFIG",
451
+ "backend '" + backendName + "': replicas must be a non-empty array", true);
452
+ }
453
+ var out = [];
454
+ for (var i = 0; i < cfg.replicas.length; i++) {
455
+ var r = cfg.replicas[i];
456
+ if (!r || typeof r.connect !== "function") {
457
+ throw _err("INVALID_CONFIG",
458
+ "backend '" + backendName + "': replicas[" + i + "].connect must be a function", true);
459
+ }
460
+ if (typeof r.query !== "function") {
461
+ throw _err("INVALID_CONFIG",
462
+ "backend '" + backendName + "': replicas[" + i + "].query must be a function", true);
463
+ }
464
+ var weight = r.weight !== undefined ? r.weight : 1;
465
+ if (typeof weight !== "number" || !isFinite(weight) || weight <= 0 ||
466
+ Math.floor(weight) !== weight) {
467
+ throw _err("INVALID_CONFIG",
468
+ "backend '" + backendName + "': replicas[" + i + "].weight must be a positive integer", true);
469
+ }
470
+ out.push({
471
+ index: i,
472
+ pool: new Pool(backendName + ":replica:" + i, r),
473
+ query: r.query,
474
+ weight: weight,
475
+ lastFailureAt: 0,
476
+ consecutiveFailures: 0,
477
+ });
478
+ }
479
+ return out;
480
+ }
481
+
482
+ function _pickReplica(b) {
483
+ if (!b.replicas || b.replicas.length === 0) return null;
484
+ var now = Date.now();
485
+ // Build a healthy candidate set.
486
+ var healthy = [];
487
+ for (var i = 0; i < b.replicas.length; i++) {
488
+ var r = b.replicas[i];
489
+ if (now - r.lastFailureAt >= REPLICA_UNHEALTHY_COOLDOWN_MS) healthy.push(r);
490
+ }
491
+ if (healthy.length === 0) return null;
492
+ // Weighted round-robin: walk by weight, advancing replicaIdx by 1 each
493
+ // call and modding by total weight. Each replica's "slot" in the
494
+ // sequence repeats `weight` times.
495
+ var totalWeight = 0;
496
+ for (var w = 0; w < healthy.length; w++) totalWeight += healthy[w].weight;
497
+ var cursor = (b.replicaIdx++) % totalWeight;
498
+ var acc = 0;
499
+ for (var c = 0; c < healthy.length; c++) {
500
+ acc += healthy[c].weight;
501
+ if (cursor < acc) return healthy[c];
502
+ }
503
+ return healthy[0]; // unreachable; defensive
504
+ }
505
+
506
+ async function _readQuery(sql, params, opts) {
507
+ _requireInit();
508
+ opts = opts || {};
509
+ var b = _pickBackend(opts);
510
+ if (!b.replicas || b.replicas.length === 0) {
511
+ // No replicas configured — read.query() returns primary.
512
+ return query(sql, params, opts);
513
+ }
514
+ var replica = _pickReplica(b);
515
+ if (!replica) {
516
+ if (b.replicaFallbackToPrimary) return query(sql, params, opts);
517
+ throw _err("ALL_REPLICAS_UNHEALTHY",
518
+ "backend '" + b.name + "': all replicas unhealthy and fallback disabled", true);
519
+ }
520
+ var t0 = Date.now();
521
+ try {
522
+ var client = await replica.pool.acquire();
523
+ try {
524
+ var res = await replica.query(client, sql, params || []);
525
+ replica.pool.release(client);
526
+ replica.consecutiveFailures = 0;
527
+ _emit("system.externaldb.read", "success", {
528
+ backend: b.name,
529
+ replicaIdx: replica.index,
530
+ durationMs: Date.now() - t0,
531
+ rowCount: res && res.rowCount,
532
+ });
533
+ return res;
534
+ } catch (e) {
535
+ // Connection-shape errors mark unhealthy + destroy.
536
+ if (e && (e.code === "ECONNRESET" || e.code === "ECONNREFUSED" ||
537
+ e.code === "ETIMEDOUT" || e.code === "ENOTFOUND" ||
538
+ e.code === "EPIPE")) {
539
+ await replica.pool.destroy(client);
540
+ replica.lastFailureAt = Date.now();
541
+ replica.consecutiveFailures += 1;
542
+ } else {
543
+ replica.pool.release(client);
544
+ }
545
+ throw e;
546
+ }
547
+ } catch (e) {
548
+ _emit("system.externaldb.read", "failure", {
549
+ backend: b.name,
550
+ replicaIdx: replica.index,
551
+ durationMs: Date.now() - t0,
552
+ errorCode: e.code || null,
553
+ }, (e && e.message) || String(e));
554
+ // Fallback to primary on a failed replica read when allowed.
555
+ if (b.replicaFallbackToPrimary) {
556
+ return query(sql, params, opts);
557
+ }
558
+ throw e;
559
+ }
560
+ }
561
+
562
+ var read = {
563
+ query: _readQuery,
564
+ };
565
+
566
+ // write namespace — alias for the primary path. Lets operators express
567
+ // intent symmetrically with read.query without a magic-comment hint.
568
+ var write = {
569
+ query: function (sql, params, opts) { return query(sql, params, opts); },
570
+ transaction: function (fn, opts) { return transaction(fn, opts); },
571
+ };
572
+
403
573
  function _resetForTest() {
404
574
  Object.keys(backends).forEach(function (n) {
405
575
  try { backends[n].pool.drain(); } catch (_e) {}
576
+ var bk = backends[n];
577
+ if (bk && bk.replicas) {
578
+ bk.replicas.forEach(function (r) { try { r.pool.drain(); } catch (_e) {} });
579
+ }
406
580
  });
407
581
  backends = {};
408
582
  defaultBackend = null;
@@ -411,6 +585,198 @@ function _resetForTest() {
411
585
  db.reset();
412
586
  }
413
587
 
588
+ // ---- configurePool — runtime resize of an existing backend's pool ----
589
+ //
590
+ // Operators tune pool sizing without restarting the app. Existing idle
591
+ // clients are kept; new acquisitions respect the new max. min is honored
592
+ // the next time the pool refills. idleTimeoutMs takes effect on the next
593
+ // reaper tick.
594
+ function configurePool(backendName, opts) {
595
+ _requireInit();
596
+ if (typeof backendName !== "string" || backendName.length === 0) {
597
+ throw _err("INVALID_CONFIG", "configurePool: backendName must be a non-empty string", true);
598
+ }
599
+ var bk = backends[backendName];
600
+ if (!bk) throw _err("UNKNOWN_BACKEND", "configurePool: no backend named '" + backendName + "'", true);
601
+ if (!opts || typeof opts !== "object") {
602
+ throw _err("INVALID_CONFIG", "configurePool: opts must be an object", true);
603
+ }
604
+ var allowed = ["min", "max", "idleTimeoutMs"];
605
+ for (var k in opts) {
606
+ if (!Object.prototype.hasOwnProperty.call(opts, k)) continue;
607
+ if (allowed.indexOf(k) === -1) {
608
+ throw _err("INVALID_CONFIG",
609
+ "configurePool: unknown option '" + k + "'. Allowed: " + allowed.join(", "), true);
610
+ }
611
+ }
612
+ function _requirePosInt(name, value) {
613
+ if (typeof value !== "number" || !isFinite(value) || value <= 0 || Math.floor(value) !== value) {
614
+ throw _err("INVALID_CONFIG",
615
+ "configurePool: " + name + " must be a positive integer, got " + JSON.stringify(value), true);
616
+ }
617
+ }
618
+ if (opts.min !== undefined) _requirePosInt("min", opts.min);
619
+ if (opts.max !== undefined) _requirePosInt("max", opts.max);
620
+ if (opts.idleTimeoutMs !== undefined) _requirePosInt("idleTimeoutMs", opts.idleTimeoutMs);
621
+ if (opts.min !== undefined && opts.max !== undefined && opts.min > opts.max) {
622
+ throw _err("INVALID_CONFIG", "configurePool: min must be <= max", true);
623
+ }
624
+ Object.assign(bk.pool.config, opts);
625
+ }
626
+
627
+ // ---- adapters.connectAs — Postgres role-aware connect wrapper ----
628
+ //
629
+ // Wraps an operator's connect() so that every fresh client runs
630
+ // `SET ROLE`, `SET search_path`, `SET application_name`, and any other
631
+ // operator-supplied GUCs at acquire time. The pattern enables the
632
+ // search_path-views shape: the same SQL `SELECT * FROM sessions`
633
+ // resolves to `public.sessions` for app_user and to
634
+ // `analytics.sessions` (a view with PHI redacted) for analytics_user.
635
+ // See the "Compliance Patterns" wiki page.
636
+ //
637
+ // Identifier inputs (role, schemas in searchPath) are validated via
638
+ // safeSql.validateIdentifier — bad shapes throw at the call site. String
639
+ // values (applicationName, statement_timeout) are quoted as SQL string
640
+ // literals with single-quote escaping per the SQL standard.
641
+ //
642
+ // connect: b.externalDb.adapters.connectAs(rawConnect, {
643
+ // role: "analytics_user",
644
+ // searchPath: ["analytics", "public"],
645
+ // applicationName: "wiki:analytics",
646
+ // statementTimeoutMs: C.TIME.seconds(30),
647
+ // gucs: {
648
+ // idle_in_transaction_session_timeout: "60s",
649
+ // },
650
+ // })
651
+ //
652
+ // `query` is the same query function the backend declares; the wrapper
653
+ // uses it to issue the SET statements.
654
+ function _connectAs(rawConnect, query, opts) {
655
+ if (typeof rawConnect !== "function") {
656
+ throw _err("INVALID_CONFIG", "connectAs: connect must be a function", true);
657
+ }
658
+ if (typeof query !== "function") {
659
+ throw _err("INVALID_CONFIG", "connectAs: query must be a function", true);
660
+ }
661
+ opts = opts || {};
662
+ var allowed = ["role", "searchPath", "applicationName", "statementTimeoutMs", "gucs"];
663
+ for (var k in opts) {
664
+ if (!Object.prototype.hasOwnProperty.call(opts, k)) continue;
665
+ if (allowed.indexOf(k) === -1) {
666
+ throw _err("INVALID_CONFIG",
667
+ "connectAs: unknown option '" + k + "'. Allowed: " + allowed.join(", "), true);
668
+ }
669
+ }
670
+
671
+ // Validate inputs at config time so a malformed name surfaces at
672
+ // boot rather than on the first connection.
673
+ if (opts.role !== undefined) {
674
+ safeSql.validateIdentifier(String(opts.role), { allowReserved: false });
675
+ }
676
+ var pathSegments = null;
677
+ if (opts.searchPath !== undefined) {
678
+ var raw = Array.isArray(opts.searchPath) ? opts.searchPath : [opts.searchPath];
679
+ if (raw.length === 0) {
680
+ throw _err("INVALID_CONFIG", "connectAs: searchPath must have at least one schema", true);
681
+ }
682
+ pathSegments = [];
683
+ for (var pi = 0; pi < raw.length; pi++) {
684
+ safeSql.validateIdentifier(String(raw[pi]), { allowReserved: false });
685
+ pathSegments.push(String(raw[pi]));
686
+ }
687
+ }
688
+ if (opts.applicationName !== undefined && typeof opts.applicationName !== "string") {
689
+ throw _err("INVALID_CONFIG", "connectAs: applicationName must be a string", true);
690
+ }
691
+ if (opts.statementTimeoutMs !== undefined) {
692
+ if (typeof opts.statementTimeoutMs !== "number" || !isFinite(opts.statementTimeoutMs) ||
693
+ opts.statementTimeoutMs <= 0 || Math.floor(opts.statementTimeoutMs) !== opts.statementTimeoutMs) {
694
+ throw _err("INVALID_CONFIG",
695
+ "connectAs: statementTimeoutMs must be a positive integer", true);
696
+ }
697
+ }
698
+ if (opts.gucs !== undefined && (typeof opts.gucs !== "object" || opts.gucs === null)) {
699
+ throw _err("INVALID_CONFIG", "connectAs: gucs must be an object", true);
700
+ }
701
+ if (opts.gucs) {
702
+ for (var gname in opts.gucs) {
703
+ // GUC names: Postgres NAMEDATALEN-shaped identifiers.
704
+ safeSql.validateIdentifier(gname, { allowReserved: true });
705
+ }
706
+ }
707
+
708
+ // Pre-compute the SET statements once — every fresh client runs the
709
+ // same list, so building it per-connect would burn microbenchmarks.
710
+ var stmts = [];
711
+ if (opts.role) {
712
+ stmts.push('SET ROLE "' + opts.role + '"');
713
+ }
714
+ if (pathSegments) {
715
+ var pathSql = pathSegments.map(function (s) { return '"' + s + '"'; }).join(", ");
716
+ stmts.push("SET search_path TO " + pathSql);
717
+ }
718
+ if (opts.applicationName !== undefined) {
719
+ // Single-quoted string literal — SQL-standard escape doubles embedded
720
+ // single quotes.
721
+ var an = String(opts.applicationName).replace(/'/g, "''");
722
+ stmts.push("SET application_name TO '" + an + "'");
723
+ }
724
+ if (opts.statementTimeoutMs !== undefined) {
725
+ stmts.push("SET statement_timeout TO " + opts.statementTimeoutMs);
726
+ }
727
+ if (opts.gucs) {
728
+ for (var gn in opts.gucs) {
729
+ var gv = opts.gucs[gn];
730
+ if (typeof gv === "number") {
731
+ stmts.push('SET "' + gn + '" TO ' + gv);
732
+ } else {
733
+ var gvs = String(gv).replace(/'/g, "''");
734
+ stmts.push('SET "' + gn + '" TO \'' + gvs + "'");
735
+ }
736
+ }
737
+ }
738
+
739
+ return async function wrappedConnect() {
740
+ var client = await rawConnect();
741
+ try {
742
+ for (var i = 0; i < stmts.length; i++) {
743
+ await query(client, stmts[i], []);
744
+ }
745
+ } catch (e) {
746
+ // Initialization failed — the operator's close hook isn't visible
747
+ // here, so we throw and let the pool's catch destroy the partial
748
+ // client.
749
+ throw e;
750
+ }
751
+ return client;
752
+ };
753
+ }
754
+
755
+ // Operators import the helper as `b.externalDb.adapters.connectAs(connect, opts)`
756
+ // — declarative wrapping with shared input validation.
757
+ function _adaptersConnectAs(connect, opts) {
758
+ // The backend's query function is needed to issue SET statements on a
759
+ // freshly-acquired client. Operators pass it via opts.query — same
760
+ // function they declare on the backend itself.
761
+ if (!opts || typeof opts !== "object") {
762
+ throw _err("INVALID_CONFIG",
763
+ "adapters.connectAs: opts must be an object", true);
764
+ }
765
+ if (typeof opts.query !== "function") {
766
+ throw _err("INVALID_CONFIG",
767
+ "adapters.connectAs: opts.query is required (the backend's query function)", true);
768
+ }
769
+ // Pull query off and pass the remaining role-aware opts.
770
+ var query = opts.query;
771
+ var roleOpts = {};
772
+ for (var k in opts) {
773
+ if (Object.prototype.hasOwnProperty.call(opts, k) && k !== "query") {
774
+ roleOpts[k] = opts[k];
775
+ }
776
+ }
777
+ return _connectAs(connect, query, roleOpts);
778
+ }
779
+
414
780
  module.exports = {
415
781
  init: init,
416
782
  query: query,
@@ -418,6 +784,12 @@ module.exports = {
418
784
  healthCheck: healthCheck,
419
785
  listBackends: listBackends,
420
786
  shutdown: shutdown,
787
+ configurePool: configurePool,
788
+ read: read,
789
+ write: write,
790
+ adapters: {
791
+ connectAs: _adaptersConnectAs,
792
+ },
421
793
  Pool: Pool,
422
794
  _resetForTest: _resetForTest,
423
795
  };
@@ -122,13 +122,62 @@ var _transports = new Map();
122
122
  // idle sockets reaped quickly between bursts. ecdhCurve / minVersion
123
123
  // come from pqc-agent and cannot be set here — the framework's
124
124
  // PQC-only TLS posture is one place, in lib/pqc-agent.js.
125
- var HTTP_CLIENT_AGENT_OPTS = {
125
+ //
126
+ // Operators tune at boot via `b.httpClient.configurePool({...})`.
127
+ // Existing transports stay on whichever values were active when they
128
+ // were created — reconfigure runs before any outbound request to take
129
+ // effect on the per-origin cache.
130
+ var DEFAULT_AGENT_OPTS = Object.freeze({
126
131
  keepAlive: true,
127
132
  keepAliveMsecs: 1000,
128
133
  maxSockets: 16,
129
134
  maxFreeSockets: 8,
130
135
  scheduling: "lifo",
131
- };
136
+ });
137
+
138
+ var HTTP_CLIENT_AGENT_OPTS = Object.assign({}, DEFAULT_AGENT_OPTS);
139
+
140
+ function configurePool(opts) {
141
+ if (!opts || typeof opts !== "object") {
142
+ throw new Error("httpClient.configurePool: opts must be an object");
143
+ }
144
+ var allowed = ["keepAlive", "keepAliveMsecs", "maxSockets", "maxFreeSockets", "scheduling"];
145
+ for (var k in opts) {
146
+ if (!Object.prototype.hasOwnProperty.call(opts, k)) continue;
147
+ if (allowed.indexOf(k) === -1) {
148
+ throw new Error("httpClient.configurePool: unknown option '" + k +
149
+ "'. Allowed: " + allowed.join(", "));
150
+ }
151
+ }
152
+ function _requirePositiveInt(name, value) {
153
+ if (typeof value !== "number" || !isFinite(value) || value <= 0 || Math.floor(value) !== value) {
154
+ throw new Error("httpClient.configurePool: " + name +
155
+ " must be a positive integer, got " + JSON.stringify(value));
156
+ }
157
+ }
158
+ if (opts.maxSockets !== undefined) _requirePositiveInt("maxSockets", opts.maxSockets);
159
+ if (opts.maxFreeSockets !== undefined) _requirePositiveInt("maxFreeSockets", opts.maxFreeSockets);
160
+ if (opts.keepAliveMsecs !== undefined) _requirePositiveInt("keepAliveMsecs", opts.keepAliveMsecs);
161
+ if (opts.keepAlive !== undefined && typeof opts.keepAlive !== "boolean") {
162
+ throw new Error("httpClient.configurePool: keepAlive must be a boolean");
163
+ }
164
+ if (opts.scheduling !== undefined && opts.scheduling !== "lifo" && opts.scheduling !== "fifo") {
165
+ throw new Error("httpClient.configurePool: scheduling must be 'lifo' or 'fifo'");
166
+ }
167
+ Object.assign(HTTP_CLIENT_AGENT_OPTS, opts);
168
+ // Existing transports keep their old values (Agent constructor
169
+ // copies). Drop the per-origin cache + tear down idle sockets so
170
+ // subsequent requests build fresh transports with the new opts.
171
+ _transports.forEach(function (t) {
172
+ if (t && t.kind === "h1" && t.agent && typeof t.agent.destroy === "function") {
173
+ try { t.agent.destroy(); } catch (_e) {}
174
+ }
175
+ if (t && t.kind === "h2" && t.session && typeof t.session.close === "function") {
176
+ try { t.session.close(); } catch (_e) {}
177
+ }
178
+ });
179
+ _transports.clear();
180
+ }
132
181
 
133
182
  // h2 session connect options. Same TLS posture as h1 Agent.
134
183
  var DEFAULT_H2_TLS_OPTS = {
@@ -1026,8 +1075,10 @@ function _getCachedTransportKind(url) {
1026
1075
 
1027
1076
  module.exports = {
1028
1077
  request: request,
1078
+ configurePool: configurePool,
1029
1079
  DEFAULT_CONTROL_PLANE_CAP: DEFAULT_CONTROL_PLANE_CAP,
1030
1080
  DEFAULT_GET_CAP: DEFAULT_GET_CAP,
1081
+ DEFAULT_AGENT_OPTS: DEFAULT_AGENT_OPTS,
1031
1082
  _resetForTest: _resetForTest,
1032
1083
  _getCachedTransportCount: _getCachedTransportCount,
1033
1084
  _getCachedTransportKind: _getCachedTransportKind,
@@ -203,12 +203,31 @@ function _typeMatches(actual, allowed) {
203
203
  return false;
204
204
  }
205
205
 
206
+ // RFC 9112 §6.1: Content-Length MUST be a sequence of decimal digits with
207
+ // no whitespace, sign, or trailing garbage. parseInt("123abc") returning
208
+ // 123 is the lenient parse that lets malformed headers slip past the
209
+ // preflight cap; the strict regex catches them at the boundary.
210
+ var STRICT_CONTENT_LENGTH = /^\d+$/;
211
+
212
+ function _parseContentLength(cl) {
213
+ if (typeof cl !== "string" || !STRICT_CONTENT_LENGTH.test(cl)) return null;
214
+ var n = Number(cl);
215
+ return isFinite(n) ? n : null;
216
+ }
217
+
206
218
  function _hasBody(req) {
207
219
  if (!BODY_BEARING_METHODS.has(req.method)) return false;
208
220
  var cl = req.headers && req.headers["content-length"];
209
- if (cl === "0") return false;
210
- // Either Content-Length > 0 OR Transfer-Encoding: chunked → there's a body.
211
- if (typeof cl === "string" && cl !== "0") return true;
221
+ if (typeof cl === "string") {
222
+ var clNum = _parseContentLength(cl);
223
+ // Spec-shaped zero (the only RFC 9112 §6.1 zero) no body. Malformed
224
+ // values (non-decimal-digits) flow through as "yes, has body" so the
225
+ // downstream _bufferBody call rejects with 400 — silently treating
226
+ // a malformed header as "no body" would let the request slip past
227
+ // the parser entirely.
228
+ if (clNum === 0) return false;
229
+ return true;
230
+ }
212
231
  var te = req.headers && req.headers["transfer-encoding"];
213
232
  if (typeof te === "string" && te.length > 0) return true;
214
233
  return false;
@@ -230,14 +249,22 @@ function _bufferBody(req, limit) {
230
249
  return new Promise(function (resolve, reject) {
231
250
  var cl = req.headers && req.headers["content-length"];
232
251
  if (typeof cl === "string") {
233
- var clNum = parseInt(cl, 10);
234
- if (!isNaN(clNum) && clNum > limit) {
235
- var err = new BodyParserError(
252
+ var clNum = _parseContentLength(cl);
253
+ if (clNum === null) {
254
+ // RFC 9112 §6.1 — malformed Content-Length is a 400.
255
+ reject(new BodyParserError(
256
+ "body-parser/bad-content-length",
257
+ "Content-Length is not a sequence of decimal digits: " + JSON.stringify(cl),
258
+ true, 400
259
+ ));
260
+ return;
261
+ }
262
+ if (clNum > limit) {
263
+ reject(new BodyParserError(
236
264
  "body-parser/too-large",
237
265
  "request body exceeds limit (" + clNum + " > " + limit + ")",
238
266
  true, 413
239
- );
240
- reject(err);
267
+ ));
241
268
  return;
242
269
  }
243
270
  }
@@ -67,12 +67,26 @@ function _xffIpFor(trustProxy) {
67
67
 
68
68
  var CorsError = defineClass("CorsError", { alwaysPermanent: true });
69
69
 
70
+ // allowList entries:
71
+ // - { kind: "string", canonical: "https://app.example.com", original: "..." }
72
+ // - { kind: "regex", pattern: /.../ }
73
+ // Both raw entry and the inbound origin run through _canonicalOrigin
74
+ // before equality so case differences ("https://APP" vs "https://app")
75
+ // and default-port differences ("https://x:443" vs "https://x") match.
70
76
  function _matchOrigin(origin, allowList) {
71
77
  if (!origin) return null;
78
+ var canon = _canonicalOrigin(origin);
72
79
  for (var i = 0; i < allowList.length; i++) {
73
80
  var entry = allowList[i];
74
- if (typeof entry === "string" && entry === origin) return origin;
75
- if (entry instanceof RegExp && entry.test(origin)) return origin;
81
+ if (entry.kind === "string") {
82
+ if (canon !== null && entry.canonical === canon) return origin;
83
+ } else if (entry.kind === "regex") {
84
+ // Regex entries match against the raw origin (operator wrote the
85
+ // pattern with whatever case / port shape they intended). Also try
86
+ // the canonical form so case-insensitive intent works without /i.
87
+ if (entry.pattern.test(origin)) return origin;
88
+ if (canon !== null && entry.pattern.test(canon)) return origin;
89
+ }
76
90
  }
77
91
  return null;
78
92
  }
@@ -153,12 +167,25 @@ function create(opts) {
153
167
  ? opts.trustProxy : false;
154
168
  var _xffIp = _xffIpFor(trustProxy);
155
169
 
156
- var origins = opts.origins || [];
157
-
158
- // Tier A validation on opts.origins strings or RegExp only.
159
- for (var oi = 0; oi < origins.length; oi++) {
160
- var entry = origins[oi];
161
- if (typeof entry !== "string" && !(entry instanceof RegExp)) {
170
+ // Build a canonicalized allowList at create() time. String entries
171
+ // get parsed through _canonicalOrigin so case + default-port
172
+ // differences match consistently between the configured value and
173
+ // the inbound Origin header. RegExp entries stay as the operator
174
+ // wrote them.
175
+ var rawOrigins = opts.origins || [];
176
+ var origins = [];
177
+ for (var oi = 0; oi < rawOrigins.length; oi++) {
178
+ var entry = rawOrigins[oi];
179
+ if (typeof entry === "string") {
180
+ var canonEntry = _canonicalOrigin(entry);
181
+ if (canonEntry === null) {
182
+ throw new CorsError("cors/bad-origin",
183
+ "origins[" + oi + "]='" + entry + "' is not a parseable http(s) URL");
184
+ }
185
+ origins.push({ kind: "string", canonical: canonEntry, original: entry });
186
+ } else if (entry instanceof RegExp) {
187
+ origins.push({ kind: "regex", pattern: entry });
188
+ } else {
162
189
  throw new CorsError("cors/bad-origin",
163
190
  "origins[" + oi + "] must be a string or RegExp (got " + typeof entry + ")");
164
191
  }
package/lib/migrations.js CHANGED
@@ -55,6 +55,10 @@ class MigrationError extends FrameworkError {
55
55
  }
56
56
 
57
57
  var MIGRATIONS_TABLE = "_blamejs_migrations";
58
+ // Always interpolate identifiers wrapped in `"..."` so a reserved-word
59
+ // or whitespace-bearing name resolves correctly (defense-in-depth even
60
+ // though our constant is bare-identifier-shaped).
61
+ var Q_MIGRATIONS_TABLE = '"' + MIGRATIONS_TABLE + '"';
58
62
  // Filename grammar: leading numeric prefix (any width), then '-', then a
59
63
  // non-empty body, then '.js'. Numeric prefix orders execution. Letters
60
64
  // in the body include hyphens, underscores, and alphanumerics; anything
@@ -68,7 +72,7 @@ function _runSql(db, sql) { return db["exec"](sql); }
68
72
 
69
73
  function _ensureTable(db) {
70
74
  _runSql(db,
71
- "CREATE TABLE IF NOT EXISTS " + MIGRATIONS_TABLE + " (" +
75
+ "CREATE TABLE IF NOT EXISTS " + Q_MIGRATIONS_TABLE + " (" +
72
76
  " name TEXT PRIMARY KEY," +
73
77
  " description TEXT," +
74
78
  " appliedAt TEXT NOT NULL" +
@@ -80,11 +84,12 @@ function _ensureTable(db) {
80
84
  // concurrently against the same DB race on this table: the winner of
81
85
  // the INSERT acquires the lock; the loser sees a UNIQUE violation and
82
86
  // the operator gets a clear "lock held by other process" error.
83
- var LOCK_TABLE = "_blamejs_migrations_lock";
87
+ var LOCK_TABLE = "_blamejs_migrations_lock";
88
+ var Q_LOCK_TABLE = '"' + LOCK_TABLE + '"';
84
89
 
85
90
  function _ensureLockTable(db) {
86
91
  _runSql(db,
87
- "CREATE TABLE IF NOT EXISTS " + LOCK_TABLE + " (" +
92
+ "CREATE TABLE IF NOT EXISTS " + Q_LOCK_TABLE + " (" +
88
93
  " scope TEXT PRIMARY KEY," +
89
94
  " lockedAt INTEGER NOT NULL," +
90
95
  " lockedBy TEXT NOT NULL," +
@@ -108,19 +113,19 @@ function _acquireLock(db, opts) {
108
113
  // Try to insert; if there's a stale lock, optionally force-replace it.
109
114
  try {
110
115
  db.prepare(
111
- "INSERT INTO " + LOCK_TABLE + " (scope, lockedAt, lockedBy) VALUES ('lock', ?, ?)"
116
+ "INSERT INTO " + Q_LOCK_TABLE + " (scope, lockedAt, lockedBy) VALUES ('lock', ?, ?)"
112
117
  ).run(nowMs, holder);
113
118
  return holder;
114
119
  } catch {
115
120
  // PRIMARY KEY conflict → existing lock. Inspect it.
116
121
  var existing = db.prepare(
117
- "SELECT lockedAt, lockedBy FROM " + LOCK_TABLE + " WHERE scope = 'lock'"
122
+ "SELECT lockedAt, lockedBy FROM " + Q_LOCK_TABLE + " WHERE scope = 'lock'"
118
123
  ).get();
119
124
  if (!existing) {
120
125
  // Race window between INSERT failure and SELECT — try once more.
121
126
  try {
122
127
  db.prepare(
123
- "INSERT INTO " + LOCK_TABLE + " (scope, lockedAt, lockedBy) VALUES ('lock', ?, ?)"
128
+ "INSERT INTO " + Q_LOCK_TABLE + " (scope, lockedAt, lockedBy) VALUES ('lock', ?, ?)"
124
129
  ).run(nowMs, holder);
125
130
  return holder;
126
131
  } catch (e2) {
@@ -135,10 +140,10 @@ function _acquireLock(db, opts) {
135
140
  // single transaction so the next process can't slip in between.
136
141
  _runSql(db, "BEGIN IMMEDIATE");
137
142
  try {
138
- db.prepare("DELETE FROM " + LOCK_TABLE + " WHERE scope = 'lock' AND lockedAt = ?")
143
+ db.prepare("DELETE FROM " + Q_LOCK_TABLE + " WHERE scope = 'lock' AND lockedAt = ?")
139
144
  .run(existing.lockedAt);
140
145
  db.prepare(
141
- "INSERT INTO " + LOCK_TABLE + " (scope, lockedAt, lockedBy) VALUES ('lock', ?, ?)"
146
+ "INSERT INTO " + Q_LOCK_TABLE + " (scope, lockedAt, lockedBy) VALUES ('lock', ?, ?)"
142
147
  ).run(nowMs, holder);
143
148
  _runSql(db, "COMMIT");
144
149
  return holder;
@@ -163,7 +168,7 @@ function _releaseLock(db, holder) {
163
168
  // the operator explicitly used the staleAfterMs path.
164
169
  try {
165
170
  db.prepare(
166
- "DELETE FROM " + LOCK_TABLE + " WHERE scope = 'lock' AND lockedBy = ?"
171
+ "DELETE FROM " + Q_LOCK_TABLE + " WHERE scope = 'lock' AND lockedBy = ?"
167
172
  ).run(holder);
168
173
  } catch (_e) { /* best-effort release; operator can DELETE manually */ }
169
174
  }
@@ -240,7 +245,7 @@ function create(opts) {
240
245
  var db = _resolveDb(opts);
241
246
  _ensureTable(db);
242
247
  return db.prepare(
243
- "SELECT name, description, appliedAt FROM " + MIGRATIONS_TABLE +
248
+ "SELECT name, description, appliedAt FROM " + Q_MIGRATIONS_TABLE +
244
249
  " ORDER BY appliedAt ASC, name ASC"
245
250
  ).all();
246
251
  }
@@ -262,7 +267,7 @@ function create(opts) {
262
267
  _ensureTable(db);
263
268
  return _withLock(db, opts, function () {
264
269
  var appliedSet = new Set(
265
- db.prepare("SELECT name FROM " + MIGRATIONS_TABLE).all()
270
+ db.prepare("SELECT name FROM " + Q_MIGRATIONS_TABLE).all()
266
271
  .map(function (r) { return r.name; })
267
272
  );
268
273
  var files = _list(dir);
@@ -276,7 +281,7 @@ function create(opts) {
276
281
  _txn(db, function () {
277
282
  mod.up(db);
278
283
  db.prepare(
279
- "INSERT INTO " + MIGRATIONS_TABLE +
284
+ "INSERT INTO " + Q_MIGRATIONS_TABLE +
280
285
  " (name, description, appliedAt) VALUES (?, ?, ?)"
281
286
  ).run(file, mod.description || "", new Date().toISOString());
282
287
  });
@@ -306,7 +311,7 @@ function create(opts) {
306
311
  // then by name as a stable tiebreaker for fixtures with identical
307
312
  // timestamps).
308
313
  var rows = db.prepare(
309
- "SELECT name FROM " + MIGRATIONS_TABLE +
314
+ "SELECT name FROM " + Q_MIGRATIONS_TABLE +
310
315
  " ORDER BY appliedAt DESC, name DESC LIMIT ?"
311
316
  ).all(steps);
312
317
 
@@ -323,7 +328,7 @@ function create(opts) {
323
328
  try {
324
329
  _txn(db, function () {
325
330
  mod.down(db);
326
- db.prepare("DELETE FROM " + MIGRATIONS_TABLE + " WHERE name = ?").run(file);
331
+ db.prepare("DELETE FROM " + Q_MIGRATIONS_TABLE + " WHERE name = ?").run(file);
327
332
  });
328
333
  } catch (e) {
329
334
  throw new MigrationError("migrations/down-failed",
package/lib/safe-sql.js CHANGED
@@ -124,6 +124,43 @@ function quoteIdentifier(name, dialect) {
124
124
  return '"' + name + '"';
125
125
  }
126
126
 
127
+ // Quote a multi-part qualified name like `schema.table` or
128
+ // `database.schema.table`. Each segment is validated + quoted
129
+ // independently so the dotted form `"schema"."table"` resolves
130
+ // correctly. Replaces the wrong shape `"schema.table"` (one literal
131
+ // identifier with a dot in it). Accepts an array of parts OR a string
132
+ // with `.` as the separator.
133
+ //
134
+ // quoteQualified(["public", "users"]) → '"public"."users"'
135
+ // quoteQualified("public.users") → '"public"."users"'
136
+ // quoteQualified(["public", "Order"], "postgres")
137
+ // → '"public"."Order"' (case preserved)
138
+ // quoteQualified("dbA.public.users") → '"dbA"."public"."users"'
139
+ function quoteQualified(parts, dialect) {
140
+ var arr;
141
+ if (typeof parts === "string") {
142
+ if (parts.length === 0) {
143
+ throw new SafeSqlError("qualified name must not be empty", "sql/empty");
144
+ }
145
+ arr = parts.split(".");
146
+ } else if (Array.isArray(parts)) {
147
+ arr = parts.slice();
148
+ } else {
149
+ throw new SafeSqlError(
150
+ "qualified name must be a string or array, got " + typeof parts,
151
+ "sql/bad-type"
152
+ );
153
+ }
154
+ if (arr.length === 0) {
155
+ throw new SafeSqlError("qualified name must have at least one segment", "sql/empty");
156
+ }
157
+ var quoted = [];
158
+ for (var i = 0; i < arr.length; i++) {
159
+ quoted.push(quoteIdentifier(arr[i], dialect));
160
+ }
161
+ return quoted.join(".");
162
+ }
163
+
127
164
  function assertOneOf(name, allowlist) {
128
165
  if (typeof name !== "string") {
129
166
  throw new SafeSqlError("name must be a string", "sql/bad-type");
@@ -149,6 +186,7 @@ function assertOneOf(name, allowlist) {
149
186
  module.exports = {
150
187
  validateIdentifier: validateIdentifier,
151
188
  quoteIdentifier: quoteIdentifier,
189
+ quoteQualified: quoteQualified,
152
190
  assertOneOf: assertOneOf,
153
191
  SafeSqlError: SafeSqlError,
154
192
  // Exposed so consumers can compose their own validators
package/lib/seeders.js CHANGED
@@ -68,6 +68,11 @@ var _err = SeederError.factory;
68
68
 
69
69
  var SEEDERS_TABLE = "_blamejs_seeders";
70
70
  var LOCK_TABLE = "_blamejs_seeders_lock";
71
+ // Pre-quoted forms used at every SQL interpolation site — defense in
72
+ // depth so a future rename to a reserved-word or whitespace-bearing
73
+ // table name doesn't silently break the query.
74
+ var Q_SEEDERS_TABLE = '"' + SEEDERS_TABLE + '"';
75
+ var Q_LOCK_TABLE = '"' + LOCK_TABLE + '"';
71
76
 
72
77
  // Filename grammar: leading numeric prefix (any width), '-', non-empty
73
78
  // body of [A-Za-z0-9_-], '.js'. Same shape as migrations to avoid
@@ -285,7 +290,7 @@ function _ensureTables(db) {
285
290
  // EXISTS here is defensive for tests that hand-seed a fresh
286
291
  // node:sqlite Database without going through b.db.
287
292
  _runSql(db,
288
- "CREATE TABLE IF NOT EXISTS " + SEEDERS_TABLE + " (" +
293
+ "CREATE TABLE IF NOT EXISTS " + Q_SEEDERS_TABLE + " (" +
289
294
  " env TEXT NOT NULL," +
290
295
  " name TEXT NOT NULL," +
291
296
  " description TEXT," +
@@ -295,7 +300,7 @@ function _ensureTables(db) {
295
300
  ")"
296
301
  );
297
302
  _runSql(db,
298
- "CREATE TABLE IF NOT EXISTS " + LOCK_TABLE + " (" +
303
+ "CREATE TABLE IF NOT EXISTS " + Q_LOCK_TABLE + " (" +
299
304
  " scope TEXT PRIMARY KEY CHECK (scope = 'lock')," +
300
305
  " lockedAt INTEGER NOT NULL," +
301
306
  " lockedBy TEXT NOT NULL" +
@@ -312,18 +317,18 @@ function _acquireLock(db, lockStaleAfterMs, clock) {
312
317
  var nowMs = clock();
313
318
  try {
314
319
  db.prepare(
315
- "INSERT INTO " + LOCK_TABLE + " (scope, lockedAt, lockedBy) VALUES ('lock', ?, ?)"
320
+ "INSERT INTO " + Q_LOCK_TABLE + " (scope, lockedAt, lockedBy) VALUES ('lock', ?, ?)"
316
321
  ).run(nowMs, holder);
317
322
  return holder;
318
323
  } catch (_e) {
319
324
  var existing = db.prepare(
320
- "SELECT lockedAt, lockedBy FROM " + LOCK_TABLE + " WHERE scope = 'lock'"
325
+ "SELECT lockedAt, lockedBy FROM " + Q_LOCK_TABLE + " WHERE scope = 'lock'"
321
326
  ).get();
322
327
  if (!existing) {
323
328
  // Race window between INSERT failure and SELECT — try once more.
324
329
  try {
325
330
  db.prepare(
326
- "INSERT INTO " + LOCK_TABLE + " (scope, lockedAt, lockedBy) VALUES ('lock', ?, ?)"
331
+ "INSERT INTO " + Q_LOCK_TABLE + " (scope, lockedAt, lockedBy) VALUES ('lock', ?, ?)"
327
332
  ).run(nowMs, holder);
328
333
  return holder;
329
334
  } catch (e2) {
@@ -335,10 +340,10 @@ function _acquireLock(db, lockStaleAfterMs, clock) {
335
340
  if (lockStaleAfterMs > 0 && ageMs > lockStaleAfterMs) {
336
341
  _runSql(db, "BEGIN IMMEDIATE");
337
342
  try {
338
- db.prepare("DELETE FROM " + LOCK_TABLE + " WHERE scope = 'lock' AND lockedAt = ?")
343
+ db.prepare("DELETE FROM " + Q_LOCK_TABLE + " WHERE scope = 'lock' AND lockedAt = ?")
339
344
  .run(existing.lockedAt);
340
345
  db.prepare(
341
- "INSERT INTO " + LOCK_TABLE + " (scope, lockedAt, lockedBy) VALUES ('lock', ?, ?)"
346
+ "INSERT INTO " + Q_LOCK_TABLE + " (scope, lockedAt, lockedBy) VALUES ('lock', ?, ?)"
342
347
  ).run(nowMs, holder);
343
348
  _runSql(db, "COMMIT");
344
349
  return holder;
@@ -358,7 +363,7 @@ function _acquireLock(db, lockStaleAfterMs, clock) {
358
363
  function _releaseLock(db, holder) {
359
364
  try {
360
365
  db.prepare(
361
- "DELETE FROM " + LOCK_TABLE + " WHERE scope = 'lock' AND lockedBy = ?"
366
+ "DELETE FROM " + Q_LOCK_TABLE + " WHERE scope = 'lock' AND lockedBy = ?"
362
367
  ).run(holder);
363
368
  } catch (_e) { /* best-effort */ }
364
369
  }
@@ -409,7 +414,7 @@ function create(opts) {
409
414
 
410
415
  function _appliedRows(db, env) {
411
416
  return db.prepare(
412
- "SELECT name, description, appliedAt, rerunnable FROM " + SEEDERS_TABLE +
417
+ "SELECT name, description, appliedAt, rerunnable FROM " + Q_SEEDERS_TABLE +
413
418
  " WHERE env = ? ORDER BY appliedAt ASC, name ASC"
414
419
  ).all(env);
415
420
  }
@@ -472,7 +477,7 @@ function create(opts) {
472
477
  var holder = _acquireLock(db, lockStaleAfterMs, clock);
473
478
  try {
474
479
  var appliedSet = new Set(
475
- db.prepare("SELECT name FROM " + SEEDERS_TABLE + " WHERE env = ?").all(env)
480
+ db.prepare("SELECT name FROM " + Q_SEEDERS_TABLE + " WHERE env = ?").all(env)
476
481
  .map(function (r) { return r.name; })
477
482
  );
478
483
 
@@ -507,21 +512,21 @@ function create(opts) {
507
512
  await mod.run(db, ctx);
508
513
  if (alreadyApplied && mod.rerunnable) {
509
514
  db.prepare(
510
- "UPDATE " + SEEDERS_TABLE +
515
+ "UPDATE " + Q_SEEDERS_TABLE +
511
516
  " SET appliedAt = ?, description = ?, rerunnable = ?" +
512
517
  " WHERE env = ? AND name = ?"
513
518
  ).run(new Date(clock()).toISOString(), mod.description || "",
514
519
  mod.rerunnable ? 1 : 0, env, name);
515
520
  } else if (alreadyApplied && force) {
516
521
  db.prepare(
517
- "UPDATE " + SEEDERS_TABLE +
522
+ "UPDATE " + Q_SEEDERS_TABLE +
518
523
  " SET appliedAt = ?, description = ?" +
519
524
  " WHERE env = ? AND name = ?"
520
525
  ).run(new Date(clock()).toISOString(), mod.description || "",
521
526
  env, name);
522
527
  } else {
523
528
  db.prepare(
524
- "INSERT INTO " + SEEDERS_TABLE +
529
+ "INSERT INTO " + Q_SEEDERS_TABLE +
525
530
  " (env, name, description, appliedAt, rerunnable) VALUES (?, ?, ?, ?, ?)"
526
531
  ).run(env, name, mod.description || "",
527
532
  new Date(clock()).toISOString(), mod.rerunnable ? 1 : 0);
package/lib/session.js CHANGED
@@ -47,6 +47,22 @@ var { SessionError } = require("./framework-error");
47
47
  var _err = SessionError.factory;
48
48
 
49
49
  var DEFAULT_TTL_MS = C.TIME.days(7);
50
+ // Sanity bound: any session that lives longer than this is almost
51
+ // certainly a misconfigured Infinity / oversized literal. Keeps
52
+ // expiresAt away from epoch overflow + database-int boundary issues.
53
+ var MAX_TTL_MS = C.TIME.days(3650); // ~10 years
54
+
55
+ function _validateTtl(ttl, where) {
56
+ if (typeof ttl !== "number" || !isFinite(ttl) || ttl <= 0) {
57
+ throw _err("INVALID_ARG",
58
+ where + ": ttlMs must be a positive finite number, got " + JSON.stringify(ttl), true);
59
+ }
60
+ if (ttl > MAX_TTL_MS) {
61
+ throw _err("INVALID_ARG",
62
+ where + ": ttlMs " + ttl + " exceeds maximum " + MAX_TTL_MS + " (~10 years). " +
63
+ "Sessions this long suggest a misconfigured value.", true);
64
+ }
65
+ }
50
66
  var SID_NAMESPACE = "bj-session:";
51
67
 
52
68
  // Column order used for INSERT — kept as a constant so the placeholders
@@ -76,8 +92,8 @@ async function create(opts) {
76
92
  if (!opts || !opts.userId) {
77
93
  throw _err("INVALID_ARG", "session.create requires { userId }", true);
78
94
  }
79
- var ttl = typeof opts.ttlMs === "number" ? opts.ttlMs : DEFAULT_TTL_MS;
80
- if (ttl <= 0) throw _err("INVALID_ARG", "session.create: ttlMs must be > 0", true);
95
+ var ttl = opts.ttlMs !== undefined ? opts.ttlMs : DEFAULT_TTL_MS;
96
+ _validateTtl(ttl, "session.create");
81
97
 
82
98
  var sid = generateToken(32); // 64 hex chars; only place the plaintext sid lives
83
99
  var sidHash = _hashSid(sid);
@@ -243,8 +259,11 @@ async function rotate(oldToken, opts) {
243
259
  var newSidHash = _hashSid(newSid);
244
260
  var oldSidHash = _hashSid(oldToken);
245
261
  var nowMs = Date.now();
246
- var newExpires = (typeof opts.ttlMs === "number" && opts.ttlMs > 0)
247
- ? nowMs + opts.ttlMs : null;
262
+ var newExpires = null;
263
+ if (opts.ttlMs !== undefined) {
264
+ _validateTtl(opts.ttlMs, "session.rotate");
265
+ newExpires = nowMs + opts.ttlMs;
266
+ }
248
267
 
249
268
  var setParts = ['"sidHash" = ?', '"lastActivity" = ?'];
250
269
  var setParams = [newSidHash, nowMs];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",