@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 +2 -0
- package/README.md +2 -1
- package/lib/api-key.js +14 -9
- package/lib/break-glass.js +38 -6
- package/lib/cluster.js +15 -5
- package/lib/db-query.js +22 -0
- package/lib/db-schema.js +7 -3
- package/lib/external-db.js +372 -0
- package/lib/http-client.js +53 -2
- package/lib/middleware/body-parser.js +35 -8
- package/lib/middleware/cors.js +35 -8
- package/lib/migrations.js +19 -14
- package/lib/safe-sql.js +38 -0
- package/lib/seeders.js +18 -13
- package/lib/session.js +23 -4
- package/package.json +1 -1
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.
|
|
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
|
|
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 " +
|
|
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 " +
|
|
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 " +
|
|
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 " +
|
|
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 " +
|
|
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 " +
|
|
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 " +
|
|
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 " +
|
|
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/break-glass.js
CHANGED
|
@@ -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 " +
|
|
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
|
-
|
|
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 " +
|
|
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
|
-
|
|
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 " +
|
|
280
|
-
" WHERE scope =
|
|
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 " +
|
|
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 " +
|
|
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 " +
|
|
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 " +
|
|
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 " +
|
|
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) {
|
package/lib/external-db.js
CHANGED
|
@@ -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
|
};
|
package/lib/http-client.js
CHANGED
|
@@ -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
|
-
|
|
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 === "
|
|
210
|
-
|
|
211
|
-
|
|
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 =
|
|
234
|
-
if (
|
|
235
|
-
|
|
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
|
}
|
package/lib/middleware/cors.js
CHANGED
|
@@ -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 (
|
|
75
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
//
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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 " +
|
|
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
|
|
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 " +
|
|
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 " +
|
|
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 " +
|
|
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 " +
|
|
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 " +
|
|
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 " +
|
|
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 " +
|
|
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 " +
|
|
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 " +
|
|
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 " +
|
|
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 " +
|
|
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 " +
|
|
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 " +
|
|
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 " +
|
|
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 " +
|
|
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 " +
|
|
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 " +
|
|
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 " +
|
|
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 " +
|
|
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 " +
|
|
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 " +
|
|
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 " +
|
|
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 " +
|
|
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 " +
|
|
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 " +
|
|
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 =
|
|
80
|
-
|
|
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
|
|
247
|
-
|
|
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];
|