@blamejs/core 0.6.0 → 0.6.2

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
@@ -6,6 +6,11 @@ Pre-1.0 the surface is intentionally evolving — every release may
6
6
  change something operators depend on. Read each entry before
7
7
  upgrading across more than a few patches at a time.
8
8
 
9
+ ## v0.6.x
10
+
11
+ - **0.6.1** (2026-05-01) — security tightenings + operator-facing jargon sweep
12
+ - **0.6.0** (2026-05-01) — wiki restructured into 22 focused pages + missing-primitive coverage
13
+
9
14
  ## v0.5.x
10
15
 
11
16
  - **0.5.18** (2026-05-01) — bypass-fix sweep: route through existing primitives
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.0** ([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.2** ([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");
@@ -115,7 +115,7 @@ Because when something breaks, `blame` should know exactly where it lives. We ow
115
115
 
116
116
  ## Contributing
117
117
 
118
- Patches welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for the dev setup, house rules (zero npm runtime deps, PQC-only crypto, audit-on-every-action, no-MVP), and the PR loop. New to the codebase? Start with [ARCHITECTURE.md](ARCHITECTURE.md) for the orientation map.
118
+ Patches welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for the dev setup, house rules (zero npm runtime deps, PQC-only crypto, audit-on-every-action, ship-complete-not-incremental), and the PR loop. New to the codebase? Start with [ARCHITECTURE.md](ARCHITECTURE.md) for the orientation map.
119
119
 
120
120
  Community standards: [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) (Contributor Covenant 2.1). Be excellent.
121
121
 
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]
package/lib/auth/jwt.js CHANGED
@@ -258,11 +258,28 @@ async function verify(token, opts) {
258
258
  }
259
259
  var tol = typeof opts.clockToleranceSec === "number" ? opts.clockToleranceSec : 0;
260
260
  var p = decoded.payload;
261
- if (typeof p.exp === "number" && p.exp + tol < nowSec) {
261
+
262
+ // Strict registered-claim typing per RFC 7519 §4.1. exp / nbf / iat MUST
263
+ // be NumericDate values (JSON numeric, seconds since epoch). A claim
264
+ // present-but-not-a-number is a malformed token — silently skipping the
265
+ // check would let a token with `exp: "0"` or `exp: "9999999999"` (string)
266
+ // bypass expiration enforcement entirely.
267
+ function _requireNumericDate(name, value) {
268
+ if (typeof value !== "number" || !isFinite(value)) {
269
+ throw new AuthError("auth-jwt/malformed",
270
+ "claim '" + name + "' must be a finite number (RFC 7519 NumericDate), got " +
271
+ (value === null ? "null" : typeof value));
272
+ }
273
+ }
274
+ if (p.exp !== undefined) _requireNumericDate("exp", p.exp);
275
+ if (p.nbf !== undefined) _requireNumericDate("nbf", p.nbf);
276
+ if (p.iat !== undefined) _requireNumericDate("iat", p.iat);
277
+
278
+ if (p.exp !== undefined && p.exp + tol < nowSec) {
262
279
  throw new AuthError("auth-jwt/expired",
263
280
  "token expired at exp=" + p.exp + " (now=" + nowSec + ", tolerance=" + tol + "s)");
264
281
  }
265
- if (typeof p.nbf === "number" && p.nbf - tol > nowSec) {
282
+ if (p.nbf !== undefined && p.nbf - tol > nowSec) {
266
283
  throw new AuthError("auth-jwt/not-yet-valid",
267
284
  "token not yet valid: nbf=" + p.nbf + " (now=" + nowSec + ", tolerance=" + tol + "s)");
268
285
  }
@@ -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) {
@@ -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
  }
@@ -13,14 +13,19 @@
13
13
  * works behind TLS-terminating proxies where the framework can't
14
14
  * infer scheme), or
15
15
  * 2. Origin === request's inferred scheme/host/port (from req.socket
16
- * and req.headers.host — correct for direct deployments), or
17
- * 3. Origin == "null" AND Sec-Fetch-Site == "same-origin" or "none".
18
- * Browsers send `Origin: null` on form-navigation POSTs from a
19
- * page whose response carries `Referrer-Policy: no-referrer` (or
20
- * similarly strict policy) — the Origin is opaqued to prevent a
21
- * cross-site leak of the page's origin. Sec-Fetch-Site is the
22
- * Fetch-metadata signal that distinguishes a same-origin nav
23
- * from a genuine cross-site post in that opaque-origin world.
16
+ * and req.headers.host — correct for direct deployments).
17
+ *
18
+ * Origin: null shortcut (opt-in via strictNullOrigin: false): browsers
19
+ * send `Origin: null` on form-navigation POSTs from a page whose response
20
+ * carries `Referrer-Policy: no-referrer` (or similarly strict policy) —
21
+ * the Origin is opaqued to prevent a cross-site leak. The Sec-Fetch-Site
22
+ * Fetch-metadata signal distinguishes a same-origin nav from a genuine
23
+ * cross-site post in that opaque-origin world. Default is to REFUSE the
24
+ * shortcut (strictNullOrigin: true) — non-browser clients can forge
25
+ * Sec-Fetch-Site freely, and operators using `refuseUnknown: true` as a
26
+ * stricter "refuse unrecognized origin" policy expect the gate to hold.
27
+ * Operators with a no-referrer page that legitimately produces
28
+ * Origin: null on same-origin POSTs flip strictNullOrigin: false.
24
29
  *
25
30
  * Options:
26
31
  * {
@@ -33,14 +38,16 @@
33
38
  * maxAgeSeconds: 600
34
39
  * refuseUnknown: true (refuse cross-origin requests from unlisted
35
40
  * origins instead of just omitting CORS headers)
41
+ * strictNullOrigin: true (default — refuse Origin: null even with
42
+ * Sec-Fetch-Site: same-origin. Set false to
43
+ * allow the no-referrer-page edge case.)
36
44
  * }
37
45
  *
38
46
  * Audit: refuseUnknown blocks emit system.cors.block with the offending Origin.
39
47
  *
40
- * Validation tier: Tier A (config-time throw) opts.siteOrigin must
41
- * parse as an http(s) URL, opts.origins entries must be strings or
42
- * RegExp. Bad config surfaces at create() not at first cross-origin
43
- * request.
48
+ * Configuration validation: opts.siteOrigin must parse as an http(s) URL,
49
+ * opts.origins entries must be strings or RegExp. Bad config surfaces at
50
+ * create() not at first cross-origin request.
44
51
  */
45
52
  var lazyRequire = require("../lazy-require");
46
53
  var audit = lazyRequire(function () { return require("../audit"); });
@@ -60,12 +67,26 @@ function _xffIpFor(trustProxy) {
60
67
 
61
68
  var CorsError = defineClass("CorsError", { alwaysPermanent: true });
62
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.
63
76
  function _matchOrigin(origin, allowList) {
64
77
  if (!origin) return null;
78
+ var canon = _canonicalOrigin(origin);
65
79
  for (var i = 0; i < allowList.length; i++) {
66
80
  var entry = allowList[i];
67
- if (typeof entry === "string" && entry === origin) return origin;
68
- 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
+ }
69
90
  }
70
91
  return null;
71
92
  }
@@ -104,12 +125,15 @@ function _inferRequestOrigin(req, trustProxy) {
104
125
  return _canonicalOrigin(proto + "://" + host);
105
126
  }
106
127
 
107
- function _isSameOrigin(req, originHeader, configuredSiteOrigins, trustProxy) {
108
- // Origin: null + Sec-Fetch-Site: same-origin|none browser opaqued
109
- // the Origin (typically because of Referrer-Policy: no-referrer on the
110
- // page) but is also explicitly telling us the request is same-origin.
111
- // Trust the Fetch-metadata signal in that case.
128
+ function _isSameOrigin(req, originHeader, configuredSiteOrigins, trustProxy, strictNullOrigin) {
129
+ // Origin: null arrives when a browser opaques the Origin (e.g.
130
+ // Referrer-Policy: no-referrer on the page). Sec-Fetch-Site can
131
+ // distinguish the same-origin case, but non-browser clients can forge
132
+ // that header freely strictNullOrigin: true (default) refuses the
133
+ // shortcut so refuseUnknown holds against forged callers. Operators
134
+ // with a legitimate no-referrer page flip strictNullOrigin: false.
112
135
  if (originHeader === "null") {
136
+ if (strictNullOrigin) return false;
113
137
  var sfs = req && req.headers && req.headers["sec-fetch-site"];
114
138
  if (sfs === "same-origin" || sfs === "none") return true;
115
139
  return false;
@@ -137,17 +161,31 @@ function create(opts) {
137
161
  validateOpts(opts, [
138
162
  "origins", "siteOrigin", "methods", "headers", "exposeHeaders",
139
163
  "credentials", "maxAgeSeconds", "refuseUnknown", "trustProxy",
164
+ "strictNullOrigin",
140
165
  ], "middleware.cors");
141
166
  var trustProxy = opts.trustProxy === true || typeof opts.trustProxy === "number"
142
167
  ? opts.trustProxy : false;
143
168
  var _xffIp = _xffIpFor(trustProxy);
144
169
 
145
- var origins = opts.origins || [];
146
-
147
- // Tier A validation on opts.origins strings or RegExp only.
148
- for (var oi = 0; oi < origins.length; oi++) {
149
- var entry = origins[oi];
150
- 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 {
151
189
  throw new CorsError("cors/bad-origin",
152
190
  "origins[" + oi + "] must be a string or RegExp (got " + typeof entry + ")");
153
191
  }
@@ -185,6 +223,11 @@ function create(opts) {
185
223
  var credentials = !!opts.credentials;
186
224
  var maxAge = String(opts.maxAgeSeconds || 600);
187
225
  var refuseUnknown = opts.refuseUnknown !== false;
226
+ // strictNullOrigin defaults true: refuse Origin: null even with
227
+ // Sec-Fetch-Site: same-origin (non-browser callers can forge that
228
+ // header). Operators with a no-referrer page producing legitimate
229
+ // Origin: null on same-origin POSTs flip to false explicitly.
230
+ var strictNullOrigin = opts.strictNullOrigin !== false;
188
231
 
189
232
  return function cors(req, res, next) {
190
233
  var origin = req.headers && req.headers.origin;
@@ -193,7 +236,7 @@ function create(opts) {
193
236
  // Same-origin POST/PUT/etc. carry an Origin header per the Fetch
194
237
  // spec but should not be subject to CORS allow-listing — they're
195
238
  // the operator's own site talking to itself.
196
- if (_isSameOrigin(req, origin, siteOrigins, trustProxy)) return next();
239
+ if (_isSameOrigin(req, origin, siteOrigins, trustProxy, strictNullOrigin)) return next();
197
240
 
198
241
  var matched = _matchOrigin(origin, origins);
199
242
  if (!matched) {
@@ -50,7 +50,9 @@
50
50
  * Full options:
51
51
  * {
52
52
  * cookie: true | { EITHER this...
53
- * name: "csrf",
53
+ * name: auto: "__Host-csrf" over HTTPS, "csrf" over HTTP.
54
+ * Operators with a custom name override here; the framework
55
+ * validates that __Host-* names carry path="/" and Secure.
54
56
  * sameSite: "Lax" | "Strict" | "None",
55
57
  * secure: auto-detected from request scheme,
56
58
  * path: "/",
@@ -73,7 +75,15 @@ var audit = lazyRequire(function () { return require("../audit"); });
73
75
  var DEFAULT_FIELD_NAME = "_csrf";
74
76
  var DEFAULT_HEADER_NAME = "X-CSRF-Token";
75
77
  var DEFAULT_METHODS = Object.freeze(["POST", "PUT", "DELETE", "PATCH"]);
76
- var DEFAULT_COOKIE_NAME = "csrf";
78
+
79
+ // Default cookie name uses the RFC 6265bis __Host- prefix when the request
80
+ // is over HTTPS. The prefix forces browsers to refuse the cookie unless
81
+ // it carries Secure + Path=/ + no Domain attribute — closing the
82
+ // "malicious sibling subdomain sets a cookie on the parent domain to
83
+ // subvert double-submit verification" attack class. On plain HTTP (dev),
84
+ // browsers reject __Host- entirely, so we fall back to the bare name.
85
+ var DEFAULT_COOKIE_NAME_SECURE = "__Host-csrf";
86
+ var DEFAULT_COOKIE_NAME_INSECURE = "csrf";
77
87
 
78
88
  function _parseCookieHeader(header) {
79
89
  // Minimal cookie-header parser — RFC 6265 §5.2 form. Ignores attributes,
@@ -191,7 +201,9 @@ function create(opts) {
191
201
  throw new Error("middleware.csrfProtect: opts.cookie must be true or an object");
192
202
  }
193
203
  cookieCfg = {
194
- name: raw.name || DEFAULT_COOKIE_NAME,
204
+ // name: explicit operator override wins; otherwise auto-resolved
205
+ // per-request based on whether the cookie is being issued Secure.
206
+ name: raw.name || null,
195
207
  sameSite: raw.sameSite || "Lax",
196
208
  // secure: undefined means auto-detect from request scheme; explicit
197
209
  // true/false overrides.
@@ -203,6 +215,27 @@ function create(opts) {
203
215
  if (["Lax", "Strict", "None"].indexOf(cookieCfg.sameSite) === -1) {
204
216
  throw new Error("middleware.csrfProtect: opts.cookie.sameSite must be Lax|Strict|None");
205
217
  }
218
+ // __Host- prefix safety: if operator picks a __Host- name, the
219
+ // Path/Domain/Secure constraints must be compatible. Path must be "/",
220
+ // no Domain (we never set one), Secure resolved per-request. Catch
221
+ // operator-side typos (e.g. __Host-csrf with a custom path) at boot.
222
+ if (cookieCfg.name && /^__Host-/.test(cookieCfg.name)) {
223
+ if (cookieCfg.path !== "/") {
224
+ throw new Error("middleware.csrfProtect: __Host-* cookie name requires path='/'");
225
+ }
226
+ if (cookieCfg.secure === false) {
227
+ throw new Error("middleware.csrfProtect: __Host-* cookie name requires secure (cannot be explicit false)");
228
+ }
229
+ }
230
+ }
231
+
232
+ // Resolve the cookie name for a specific request — operator override
233
+ // wins; otherwise __Host-csrf when the cookie will be Secure, plain
234
+ // csrf when over HTTP (browsers reject __Host- without Secure).
235
+ function _resolveCookieName(req) {
236
+ if (cookieCfg.name) return cookieCfg.name;
237
+ var willBeSecure = cookieCfg.secure == null ? _isHttps(req) : !!cookieCfg.secure;
238
+ return willBeSecure ? DEFAULT_COOKIE_NAME_SECURE : DEFAULT_COOKIE_NAME_INSECURE;
206
239
  }
207
240
 
208
241
  function _emitDenied(req, reason) {
@@ -221,14 +254,15 @@ function create(opts) {
221
254
  // req.csrfToken so templates have something to render.
222
255
  function _issueIfNeeded(req, res) {
223
256
  if (!cookieCfg) return null;
257
+ var cookieName = _resolveCookieName(req);
224
258
  var cookies = _parseCookieHeader(req.headers && req.headers.cookie);
225
- var existing = cookies[cookieCfg.name];
259
+ var existing = cookies[cookieName];
226
260
  if (existing && /^[a-f0-9]{2,}$/.test(existing)) {
227
261
  req.csrfToken = existing;
228
262
  return existing;
229
263
  }
230
264
  var fresh = forms.generateCsrfToken();
231
- var setCookie = _formatSetCookie(cookieCfg.name, fresh, {
265
+ var setCookie = _formatSetCookie(cookieName, fresh, {
232
266
  path: cookieCfg.path,
233
267
  sameSite: cookieCfg.sameSite,
234
268
  secure: cookieCfg.secure == null ? _isHttps(req) : !!cookieCfg.secure,
@@ -64,11 +64,23 @@ function _clientIpFor(trustProxy) {
64
64
  };
65
65
  }
66
66
 
67
+ // Reject NaN / Infinity / negative / non-positive / non-number at create
68
+ // time so a misconfigured rate-limit can't silently degrade to "no
69
+ // limit" or produce divide-by-zero verdicts at request time.
70
+ function _requirePositiveNumber(name, value) {
71
+ if (typeof value !== "number" || !isFinite(value) || value <= 0) {
72
+ throw new Error("middleware.rateLimit: " + name + " must be a positive finite number, got " +
73
+ JSON.stringify(value));
74
+ }
75
+ }
76
+
67
77
  // ---- Memory backend (token bucket) ----
68
78
 
69
79
  function _memoryBackend(opts) {
70
- var burst = opts.burst || 60;
71
- var refillPerSecond = opts.refillPerSecond || 10;
80
+ var burst = opts.burst != null ? opts.burst : 60;
81
+ var refillPerSecond = opts.refillPerSecond != null ? opts.refillPerSecond : 10;
82
+ _requirePositiveNumber("burst", burst);
83
+ _requirePositiveNumber("refillPerSecond", refillPerSecond);
72
84
  var buckets = new Map();
73
85
 
74
86
  // Periodic GC of stale buckets so the map doesn't grow unbounded.
@@ -128,9 +140,13 @@ function _memoryBackend(opts) {
128
140
  // ---- Cluster backend (fixed-window counter, SQL-backed) ----
129
141
 
130
142
  function _clusterBackend(opts) {
131
- var limit = opts.limit || 60;
132
- var windowMs = opts.windowMs || C.TIME.minutes(1);
133
- var pruneIntervalMs = opts.pruneIntervalMs || C.TIME.minutes(5);
143
+ var limit = opts.limit != null ? opts.limit : 60;
144
+ var windowMs = opts.windowMs != null ? opts.windowMs : C.TIME.minutes(1);
145
+ var pruneIntervalMs = opts.pruneIntervalMs != null
146
+ ? opts.pruneIntervalMs : C.TIME.minutes(5);
147
+ _requirePositiveNumber("limit", limit);
148
+ _requirePositiveNumber("windowMs", windowMs);
149
+ _requirePositiveNumber("pruneIntervalMs", pruneIntervalMs);
134
150
  var lastPruneAt = 0;
135
151
 
136
152
  // Best-effort prune of expired window rows. Rate-limited at the
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.0",
3
+ "version": "0.6.2",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",