@blamejs/core 0.6.5 → 0.6.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,7 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.6.x
10
10
 
11
+ - **0.6.5** (2026-05-01) — b.db.declareView + b.externalDb.migrate
11
12
  - **0.6.4** (2026-05-01) — wiki schema docs realigned with the actual lib API
12
13
  - **0.6.3** (2026-05-01) — externalDb pool tuning + role-aware connect + read-replica routing
13
14
  - **0.6.2** (2026-05-01) — input validation + identifier-quoting consistency
@@ -0,0 +1,272 @@
1
+ "use strict";
2
+ /**
3
+ * b.db.declareRowPolicy — declarative Postgres ROW LEVEL SECURITY policy
4
+ * migration spec.
5
+ *
6
+ * Returns a migration-shape object that b.externalDb.migrate(...) applies
7
+ * against a Postgres backend. Generates:
8
+ *
9
+ * ALTER TABLE <schema>.<table> ENABLE ROW LEVEL SECURITY; -- idempotent
10
+ * CREATE POLICY <name> ON <schema>.<table>
11
+ * [AS PERMISSIVE | RESTRICTIVE]
12
+ * FOR <command>
13
+ * [TO <role>]
14
+ * USING (<expr>)
15
+ * [WITH CHECK (<expr>)];
16
+ *
17
+ * Pairs with b.externalDb.transaction({ sessionGucs: { 'app.tenant_id': uuid } })
18
+ * for the per-request `SET LOCAL` plumbing. The recommended tenant-per-row
19
+ * shape:
20
+ *
21
+ * b.db.declareRowPolicy({
22
+ * schema: "public",
23
+ * table: "sessions",
24
+ * name: "tenant_isolation",
25
+ * role: "app_user",
26
+ * using: "tenant_id = current_setting('app.tenant_id')::uuid",
27
+ * withCheck: "tenant_id = current_setting('app.tenant_id')::uuid",
28
+ * command: "ALL",
29
+ * });
30
+ *
31
+ * await b.externalDb.transaction(async function (tx) {
32
+ * return await tx.query("SELECT * FROM sessions WHERE _id = $1", [sid]);
33
+ * }, { sessionGucs: { "app.tenant_id": req.user.tenantId } });
34
+ *
35
+ * Postgres-only: SQLite + MySQL have no equivalent grammar. Apply throws
36
+ * NOT_SUPPORTED at migration-apply time when the targeted backend's
37
+ * dialect isn't "postgres".
38
+ *
39
+ * Validation at declareRowPolicy() call time — bad shape throws here, not
40
+ * at apply time:
41
+ * - schema, table, name, role → safeSql.validateIdentifier
42
+ * - command ∈ {ALL, SELECT, INSERT, UPDATE, DELETE}
43
+ * - permissive boolean
44
+ * - using / withCheck operator-supplied SQL strings; semicolons rejected
45
+ *
46
+ * Audit metadata emitted on apply:
47
+ * {
48
+ * policy: "schema.table.name",
49
+ * table: "schema.table",
50
+ * role: "...",
51
+ * command: "ALL"|...,
52
+ * permissive: true|false,
53
+ * hasWithCheck: bool,
54
+ * }
55
+ */
56
+ var safeSql = require("./safe-sql");
57
+ var { defineClass } = require("./framework-error");
58
+
59
+ var DeclareRowPolicyError = defineClass("DeclareRowPolicyError", { alwaysPermanent: true });
60
+
61
+ var ALLOWED_OPTS = [
62
+ "schema", "table", "name", "role",
63
+ "using", "withCheck", "command", "permissive", "backend",
64
+ ];
65
+
66
+ var ALLOWED_COMMANDS = ["ALL", "SELECT", "INSERT", "UPDATE", "DELETE"];
67
+
68
+ function _err(code, message) {
69
+ return new DeclareRowPolicyError(code, message);
70
+ }
71
+
72
+ function _validateIdent(where, value) {
73
+ try {
74
+ safeSql.validateIdentifier(value, { allowReserved: true });
75
+ } catch (e) {
76
+ throw _err("declare-row-policy/bad-identifier",
77
+ where + ": invalid identifier '" + value + "': " + ((e && e.message) || String(e)));
78
+ }
79
+ }
80
+
81
+ function _validateExpression(where, value) {
82
+ if (typeof value !== "string") {
83
+ throw _err("declare-row-policy/bad-type", where + " must be a string");
84
+ }
85
+ if (value.length === 0) {
86
+ throw _err("declare-row-policy/empty-expression", where + " must be a non-empty boolean expression");
87
+ }
88
+ if (value.indexOf(";") !== -1) {
89
+ throw _err("declare-row-policy/bad-expression",
90
+ where + " must not contain ';' — use a single boolean expression");
91
+ }
92
+ return value;
93
+ }
94
+
95
+ function _validateOpts(opts) {
96
+ if (!opts || typeof opts !== "object") {
97
+ throw _err("declare-row-policy/bad-opts", "declareRowPolicy requires an opts object");
98
+ }
99
+ for (var k in opts) {
100
+ if (Object.prototype.hasOwnProperty.call(opts, k) && ALLOWED_OPTS.indexOf(k) === -1) {
101
+ throw _err("declare-row-policy/unknown-opt",
102
+ "unknown opt '" + k + "'. Allowed: " + ALLOWED_OPTS.join(", "));
103
+ }
104
+ }
105
+
106
+ if (typeof opts.schema !== "string" || opts.schema.length === 0) {
107
+ throw _err("declare-row-policy/missing-opt", "schema is required");
108
+ }
109
+ _validateIdent("schema", opts.schema);
110
+
111
+ if (typeof opts.table !== "string" || opts.table.length === 0) {
112
+ throw _err("declare-row-policy/missing-opt", "table is required");
113
+ }
114
+ _validateIdent("table", opts.table);
115
+
116
+ if (typeof opts.name !== "string" || opts.name.length === 0) {
117
+ throw _err("declare-row-policy/missing-opt", "name is required");
118
+ }
119
+ _validateIdent("name", opts.name);
120
+
121
+ var role = null;
122
+ if (opts.role !== undefined && opts.role !== null) {
123
+ if (typeof opts.role !== "string" || opts.role.length === 0) {
124
+ throw _err("declare-row-policy/bad-type", "role must be a non-empty string");
125
+ }
126
+ _validateIdent("role", opts.role);
127
+ role = opts.role;
128
+ }
129
+
130
+ if (opts.using === undefined || opts.using === null) {
131
+ throw _err("declare-row-policy/missing-opt", "using is required (USING expression)");
132
+ }
133
+ var using = _validateExpression("using", opts.using);
134
+
135
+ var withCheck = null;
136
+ if (opts.withCheck !== undefined && opts.withCheck !== null) {
137
+ withCheck = _validateExpression("withCheck", opts.withCheck);
138
+ }
139
+
140
+ var command = "ALL";
141
+ if (opts.command !== undefined && opts.command !== null) {
142
+ if (typeof opts.command !== "string") {
143
+ throw _err("declare-row-policy/bad-type", "command must be a string");
144
+ }
145
+ var upper = opts.command.toUpperCase();
146
+ if (ALLOWED_COMMANDS.indexOf(upper) === -1) {
147
+ throw _err("declare-row-policy/bad-command",
148
+ "command must be one of " + ALLOWED_COMMANDS.join(", ") + ", got '" + opts.command + "'");
149
+ }
150
+ command = upper;
151
+ }
152
+
153
+ var permissive = true;
154
+ if (opts.permissive !== undefined && opts.permissive !== null) {
155
+ if (typeof opts.permissive !== "boolean") {
156
+ throw _err("declare-row-policy/bad-type", "permissive must be a boolean");
157
+ }
158
+ permissive = opts.permissive;
159
+ }
160
+
161
+ if (opts.backend !== undefined && opts.backend !== null) {
162
+ if (typeof opts.backend !== "string" || opts.backend.length === 0) {
163
+ throw _err("declare-row-policy/bad-type", "backend must be a non-empty string");
164
+ }
165
+ }
166
+
167
+ return {
168
+ schema: opts.schema,
169
+ table: opts.table,
170
+ name: opts.name,
171
+ role: role,
172
+ using: using,
173
+ withCheck: withCheck,
174
+ command: command,
175
+ permissive: permissive,
176
+ backend: opts.backend || null,
177
+ };
178
+ }
179
+
180
+ function _ensureBackendIsPostgres(externalDb, backendName) {
181
+ var list = externalDb.listBackends();
182
+ var found = null;
183
+ for (var i = 0; i < list.length; i++) {
184
+ if (list[i].name === backendName) { found = list[i]; break; }
185
+ }
186
+ if (!found) {
187
+ throw _err("declare-row-policy/unknown-backend",
188
+ "no externalDb backend named '" + backendName + "' — declared backends: " +
189
+ list.map(function (b) { return b.name; }).join(", "));
190
+ }
191
+ if (found.dialect !== "postgres") {
192
+ throw _err("declare-row-policy/not-supported",
193
+ "declareRowPolicy is Postgres-only; backend '" + backendName + "' has dialect='" +
194
+ found.dialect + "'. Write the policy as a hand-rolled migration for this dialect.");
195
+ }
196
+ }
197
+
198
+ function declareRowPolicy(opts) {
199
+ var spec = _validateOpts(opts);
200
+ var qTable = safeSql.quoteQualified([spec.schema, spec.table], "postgres");
201
+ var qPolicy = safeSql.quoteIdentifier(spec.name, "postgres");
202
+ var qRole = spec.role ? safeSql.quoteIdentifier(spec.role, "postgres") : null;
203
+
204
+ var description = "declareRowPolicy " + spec.schema + "." + spec.table + "." + spec.name;
205
+
206
+ async function up(xdb, ctx) {
207
+ if (ctx && ctx.externalDb && ctx.backendName) {
208
+ _ensureBackendIsPostgres(ctx.externalDb, ctx.backendName);
209
+ }
210
+
211
+ // Idempotent ENABLE — Postgres has no IF NOT EXISTS for this. Read
212
+ // the current setting from pg_class and skip the ALTER when already
213
+ // on, so re-running a migration set in a partially-applied state
214
+ // doesn't fail with a no-op error from the lock acquisition.
215
+ var rlsCheck = await xdb.query(
216
+ "SELECT relrowsecurity FROM pg_class c " +
217
+ "JOIN pg_namespace n ON n.oid = c.relnamespace " +
218
+ "WHERE n.nspname = $1 AND c.relname = $2",
219
+ [spec.schema, spec.table]
220
+ );
221
+ var rows = (rlsCheck && rlsCheck.rows) || [];
222
+ if (rows.length === 0) {
223
+ throw _err("declare-row-policy/table-not-found",
224
+ "source table '" + spec.schema + "." + spec.table +
225
+ "' not found (does it exist? does the migration role have visibility?)");
226
+ }
227
+ if (!rows[0].relrowsecurity) {
228
+ await xdb.query("ALTER TABLE " + qTable + " ENABLE ROW LEVEL SECURITY", []);
229
+ }
230
+
231
+ // CREATE POLICY assembled in canonical order: name → table → AS
232
+ // PERMISSIVE/RESTRICTIVE → FOR command → TO role → USING → WITH CHECK.
233
+ var sql = "CREATE POLICY " + qPolicy + " ON " + qTable;
234
+ sql += " AS " + (spec.permissive ? "PERMISSIVE" : "RESTRICTIVE");
235
+ sql += " FOR " + spec.command;
236
+ if (qRole) sql += " TO " + qRole;
237
+ sql += " USING (" + spec.using + ")";
238
+ if (spec.withCheck) sql += " WITH CHECK (" + spec.withCheck + ")";
239
+
240
+ await xdb.query(sql, []);
241
+
242
+ return {
243
+ policy: spec.schema + "." + spec.table + "." + spec.name,
244
+ table: spec.schema + "." + spec.table,
245
+ role: spec.role,
246
+ command: spec.command,
247
+ permissive: spec.permissive,
248
+ hasWithCheck: !!spec.withCheck,
249
+ };
250
+ }
251
+
252
+ async function down(xdb, ctx) {
253
+ if (ctx && ctx.externalDb && ctx.backendName) {
254
+ _ensureBackendIsPostgres(ctx.externalDb, ctx.backendName);
255
+ }
256
+ await xdb.query("DROP POLICY IF EXISTS " + qPolicy + " ON " + qTable, []);
257
+ }
258
+
259
+ return {
260
+ description: description,
261
+ target: "externalDb",
262
+ backend: spec.backend,
263
+ up: up,
264
+ down: down,
265
+ _spec: spec,
266
+ };
267
+ }
268
+
269
+ module.exports = {
270
+ declareRowPolicy: declareRowPolicy,
271
+ DeclareRowPolicyError: DeclareRowPolicyError,
272
+ };
@@ -0,0 +1,50 @@
1
+ "use strict";
2
+ /**
3
+ * db-role-context — shared AsyncLocalStorage registry for the request-time
4
+ * DB role binding.
5
+ *
6
+ * The b.middleware.dbRoleFor middleware enters a scope with { role }; the
7
+ * externalDb backend picker reads the same store at query time. Anything
8
+ * deep in the async stack — handler, db query, transaction body, audit
9
+ * write — sees the same role without explicit threading.
10
+ *
11
+ * Out-of-request callers (jobs, schedulers, CLIs) use externalDb.runAs to
12
+ * push a role into the same store for the body of their work.
13
+ *
14
+ * Public API (consumed by externalDb / middleware / permissions):
15
+ * getRole() → string | null
16
+ * runWithRole(role, fn) → fn() inside the role-bound ALS scope
17
+ *
18
+ * The role string must be a SQL-identifier-shaped value; callers are
19
+ * responsible for validating before pushing into the store. The store
20
+ * holds a frozen { role } shape so consumers can't mutate it sideways.
21
+ */
22
+ var { AsyncLocalStorage } = require("node:async_hooks");
23
+
24
+ var _als = new AsyncLocalStorage();
25
+
26
+ function getStore() {
27
+ return _als.getStore() || null;
28
+ }
29
+
30
+ function getRole() {
31
+ var s = getStore();
32
+ return s && s.role ? s.role : null;
33
+ }
34
+
35
+ function runWithRole(role, fn) {
36
+ if (typeof fn !== "function") {
37
+ throw new TypeError("db-role-context.runWithRole: fn must be a function");
38
+ }
39
+ // Null / undefined role passes through as "no binding" — useful for
40
+ // explicitly entering a scope that resets any inherited role.
41
+ var store = role ? Object.freeze({ role: String(role) }) : Object.freeze({ role: null });
42
+ return _als.run(store, fn);
43
+ }
44
+
45
+ module.exports = {
46
+ getRole: getRole,
47
+ runWithRole: runWithRole,
48
+ // For diagnostic use; consumers should prefer getRole.
49
+ _als: _als,
50
+ };
package/lib/db.js CHANGED
@@ -1173,6 +1173,11 @@ module.exports = {
1173
1173
  // b.externalDb.migrate. Postgres-only; fail-fast at apply time on other
1174
1174
  // dialects. See lib/db-declare-view.js.
1175
1175
  declareView: require("./db-declare-view").declareView,
1176
+ // declareRowPolicy — declarative Postgres ROW LEVEL SECURITY migration
1177
+ // spec. Pairs with externalDb.transaction({ sessionGucs }) for the
1178
+ // per-request `SET LOCAL` plumbing. Postgres-only; fail-fast on other
1179
+ // dialects. See lib/db-declare-row-policy.js.
1180
+ declareRowPolicy: require("./db-declare-row-policy").declareRowPolicy,
1176
1181
  // Internal accessors used by audit / subject / consent modules.
1177
1182
  // Not part of the public contract — apps should not depend on them.
1178
1183
  _getSubjectTables: function () { return subjectTables.slice(); },
@@ -52,6 +52,7 @@
52
52
  */
53
53
  var retryHelper = require("./retry");
54
54
  var C = require("./constants");
55
+ var dbRoleContext = require("./db-role-context");
55
56
  var lazyRequire = require("./lazy-require");
56
57
  var safeAsync = require("./safe-async");
57
58
  var safeSql = require("./safe-sql");
@@ -65,6 +66,10 @@ var _err = ExternalDbError.factory;
65
66
  var initialized = false;
66
67
  var backends = {};
67
68
  var defaultBackend = null;
69
+ // Operator-declared { role: backendName } map for request-time pool pick.
70
+ // Populated at init() from opts.dbRoleBackends. Read by _pickBackend
71
+ // when no explicit opts.backend is supplied AND the ALS scope has a role.
72
+ var dbRoleBackends = {};
68
73
 
69
74
  // ---- Pool ----
70
75
  //
@@ -166,6 +171,7 @@ function init(opts) {
166
171
  if (!opts || !opts.backends) throw new Error("externalDb.init({ backends }) is required");
167
172
 
168
173
  backends = {};
174
+ dbRoleBackends = {};
169
175
  for (var name in opts.backends) {
170
176
  var cfg = opts.backends[name];
171
177
  if (typeof cfg.connect !== "function") {
@@ -208,6 +214,39 @@ function init(opts) {
208
214
  }
209
215
 
210
216
  defaultBackend = opts.defaultBackend || Object.keys(backends)[0];
217
+
218
+ // dbRoleBackends — request-time role → backend mapping. Each role name
219
+ // validates as a SQL identifier at init (matches the dbRoleFor
220
+ // middleware's runtime check) so a typo surfaces at boot rather than
221
+ // as a silent default-backend fallback at the first request.
222
+ if (opts.dbRoleBackends !== undefined && opts.dbRoleBackends !== null) {
223
+ if (typeof opts.dbRoleBackends !== "object" || Array.isArray(opts.dbRoleBackends)) {
224
+ throw _err("INVALID_CONFIG",
225
+ "dbRoleBackends must be an object map of role → backendName", true);
226
+ }
227
+ for (var role in opts.dbRoleBackends) {
228
+ if (!Object.prototype.hasOwnProperty.call(opts.dbRoleBackends, role)) continue;
229
+ try {
230
+ safeSql.validateIdentifier(role, { allowReserved: false });
231
+ } catch (e) {
232
+ throw _err("INVALID_CONFIG",
233
+ "dbRoleBackends: role '" + role + "' is not a valid SQL identifier: " +
234
+ ((e && e.message) || String(e)), true);
235
+ }
236
+ var bn = opts.dbRoleBackends[role];
237
+ if (typeof bn !== "string" || bn.length === 0) {
238
+ throw _err("INVALID_CONFIG",
239
+ "dbRoleBackends['" + role + "']: backend name must be a non-empty string", true);
240
+ }
241
+ if (!Object.prototype.hasOwnProperty.call(backends, bn)) {
242
+ throw _err("INVALID_CONFIG",
243
+ "dbRoleBackends['" + role + "']: no backend named '" + bn + "' " +
244
+ "(declared backends: " + Object.keys(backends).join(", ") + ")", true);
245
+ }
246
+ dbRoleBackends[role] = bn;
247
+ }
248
+ }
249
+
211
250
  _validateResidency();
212
251
  initialized = true;
213
252
  }
@@ -232,6 +271,16 @@ function _validateResidency() {
232
271
  }
233
272
 
234
273
  // ---- Backend selection ----
274
+ //
275
+ // Pick precedence:
276
+ // 1. opts.backend — explicit override always wins
277
+ // 2. opts.classification — first backend serving that class
278
+ // 3. ALS-bound dbRole + dbRoleBackends — request-time auto-pick
279
+ // 4. defaultBackend — final fallback
280
+ //
281
+ // The ALS path matches the dbRoleFor middleware shape: middleware sets
282
+ // the role; deep async reads pick up the matching backend without having
283
+ // to thread `req` through every call site.
235
284
 
236
285
  function _pickBackend(opts) {
237
286
  opts = opts || {};
@@ -252,6 +301,10 @@ function _pickBackend(opts) {
252
301
  throw _err("NO_BACKEND_FOR_CLASSIFICATION",
253
302
  "no backend serves classification '" + classification + "'", true);
254
303
  }
304
+ var role = dbRoleContext.getRole();
305
+ if (role && Object.prototype.hasOwnProperty.call(dbRoleBackends, role)) {
306
+ return backends[dbRoleBackends[role]];
307
+ }
255
308
  return backends[defaultBackend] || null;
256
309
  }
257
310
 
@@ -320,6 +373,16 @@ async function transaction(fn, opts) {
320
373
  opts = opts || {};
321
374
  var b = _pickBackend(opts);
322
375
 
376
+ // sessionGucs — per-transaction `SET LOCAL "name" = value` plumbing.
377
+ // Each name validates as a SQL identifier (Postgres GUC names follow
378
+ // the same NAMEDATALEN-shaped rules; dotted GUCs like 'app.tenant_id'
379
+ // validate per-segment via quoteQualified). Values are emitted as SQL
380
+ // string literals (single-quote escaped) for strings, raw for finite
381
+ // numbers. SET LOCAL ties the binding to the surrounding transaction
382
+ // so the tenant_id used by RLS policies resets cleanly at
383
+ // COMMIT/ROLLBACK without caller cleanup.
384
+ var prebuiltGucs = _buildSessionGucsStatements(opts.sessionGucs);
385
+
323
386
  var t0 = Date.now();
324
387
  return await b.breaker.wrap(async function () {
325
388
  var client = await b.pool.acquire();
@@ -329,6 +392,9 @@ async function transaction(fn, opts) {
329
392
  var committed = false;
330
393
  try {
331
394
  await b.beginTx(client);
395
+ for (var gi = 0; gi < prebuiltGucs.length; gi++) {
396
+ await b.query(client, prebuiltGucs[gi], []);
397
+ }
332
398
  var result = await fn(txClient);
333
399
  await b.commit(client);
334
400
  committed = true;
@@ -410,6 +476,60 @@ async function shutdown() {
410
476
  initialized = false;
411
477
  }
412
478
 
479
+ // Build the SET LOCAL statements for a transaction's sessionGucs map.
480
+ // Identifier-validates each GUC name (per dot-segment so dotted names
481
+ // like 'app.tenant_id' work), quotes them with the Postgres dialect,
482
+ // and renders the value as either a SQL string literal (single-quoted,
483
+ // embedded quotes doubled) or a numeric literal for finite numbers.
484
+ // Bad shapes throw at the call site rather than as a confused Postgres
485
+ // error mid-transaction.
486
+ function _buildSessionGucsStatements(sessionGucs) {
487
+ if (sessionGucs === undefined || sessionGucs === null) return [];
488
+ if (typeof sessionGucs !== "object" || Array.isArray(sessionGucs)) {
489
+ throw _err("INVALID_SESSION_GUCS",
490
+ "sessionGucs must be an object map of name → value", true);
491
+ }
492
+ var out = [];
493
+ for (var name in sessionGucs) {
494
+ if (!Object.prototype.hasOwnProperty.call(sessionGucs, name)) continue;
495
+ if (typeof name !== "string" || name.length === 0) {
496
+ throw _err("INVALID_SESSION_GUCS",
497
+ "sessionGucs: GUC name must be a non-empty string", true);
498
+ }
499
+ // Validate per-segment so dotted GUCs (Postgres custom GUC class.
500
+ // setting form) pass. quoteQualified handles both the validation
501
+ // and the dot-quoted rendering.
502
+ var qName;
503
+ try {
504
+ qName = safeSql.quoteQualified(name, "postgres");
505
+ } catch (e) {
506
+ throw _err("INVALID_SESSION_GUCS",
507
+ "sessionGucs: name '" + name + "' is not a valid identifier: " +
508
+ ((e && e.message) || String(e)), true);
509
+ }
510
+ var value = sessionGucs[name];
511
+ var literal;
512
+ if (typeof value === "number" && isFinite(value)) {
513
+ literal = String(value);
514
+ } else if (typeof value === "boolean") {
515
+ // Postgres SET accepts on/off/true/false — render true/false.
516
+ literal = value ? "true" : "false";
517
+ } else if (typeof value === "string") {
518
+ literal = "'" + value.replace(/'/g, "''") + "'";
519
+ } else if (value === null || value === undefined) {
520
+ throw _err("INVALID_SESSION_GUCS",
521
+ "sessionGucs['" + name + "']: value must be a string, finite number, or boolean (got " +
522
+ (value === null ? "null" : "undefined") + ")", true);
523
+ } else {
524
+ throw _err("INVALID_SESSION_GUCS",
525
+ "sessionGucs['" + name + "']: value must be a string, finite number, or boolean (got " +
526
+ typeof value + ")", true);
527
+ }
528
+ out.push("SET LOCAL " + qName + " = " + literal);
529
+ }
530
+ return out;
531
+ }
532
+
413
533
  // Fire-and-forget audit emission. We CANNOT await this in cluster mode:
414
534
  // audit storage routes back through external-db when cluster mode is
415
535
  // active, so awaiting would create a recursive dependency (every audit
@@ -594,6 +714,7 @@ function _resetForTest() {
594
714
  });
595
715
  backends = {};
596
716
  defaultBackend = null;
717
+ dbRoleBackends = {};
597
718
  initialized = false;
598
719
  audit.reset();
599
720
  db.reset();
@@ -791,6 +912,37 @@ function _adaptersConnectAs(connect, opts) {
791
912
  return _connectAs(connect, query, roleOpts);
792
913
  }
793
914
 
915
+ // ---- runAs / currentRole — out-of-request role binding ----
916
+ //
917
+ // Inside an HTTP request the dbRoleFor middleware already pushes the
918
+ // role into the shared db-role-context ALS. Background workers (jobs,
919
+ // schedulers, CLI commands) don't run under that middleware — they wrap
920
+ // their work in runAs(role, fn) so the same backend-pick logic applies.
921
+ //
922
+ // await b.externalDb.runAs("analytics_user", async function () {
923
+ // return await b.externalDb.read.query("SELECT ..."); // → analytics backend
924
+ // });
925
+ //
926
+ // currentRole() returns the active role (or null) — useful for diagnostic
927
+ // logs and observability labels.
928
+ function runAs(role, fn) {
929
+ if (typeof fn !== "function") {
930
+ throw _err("INVALID_FN", "externalDb.runAs: fn must be a function", true);
931
+ }
932
+ if (role !== null && role !== undefined) {
933
+ if (typeof role !== "string" || role.length === 0) {
934
+ throw _err("INVALID_ROLE",
935
+ "externalDb.runAs: role must be a non-empty string or null", true);
936
+ }
937
+ safeSql.validateIdentifier(role, { allowReserved: false });
938
+ }
939
+ return dbRoleContext.runWithRole(role || null, fn);
940
+ }
941
+
942
+ function currentRole() {
943
+ return dbRoleContext.getRole();
944
+ }
945
+
794
946
  module.exports = {
795
947
  init: init,
796
948
  query: query,
@@ -801,6 +953,8 @@ module.exports = {
801
953
  configurePool: configurePool,
802
954
  read: read,
803
955
  write: write,
956
+ runAs: runAs,
957
+ currentRole: currentRole,
804
958
  adapters: {
805
959
  connectAs: _adaptersConnectAs,
806
960
  },
@@ -0,0 +1,218 @@
1
+ "use strict";
2
+ /**
3
+ * dbRoleFor middleware — binds a request-time DB role.
4
+ *
5
+ * Operators using the search_path-views compliance recipe (see
6
+ * b.db.declareView and the Compliance Patterns wiki page) declare two
7
+ * Postgres roles: app_user (full source) and analytics_user (redacted
8
+ * view). Each role gets its own externalDb backend — same SQL,
9
+ * different connection pool. dbRoleFor picks the role for the current
10
+ * request and pushes it into the shared db-role-context AsyncLocalStorage
11
+ * scope so b.externalDb.query / read / write / transaction auto-route
12
+ * to the matching backend without any operator threading of the role
13
+ * through their handler signature.
14
+ *
15
+ * var perms = b.permissions.create({
16
+ * roles: {
17
+ * admin: { extends: ["app"], permissions: ["*:*"] },
18
+ * app: { permissions: ["sessions:*"], dbRole: "app_user" },
19
+ * analyst: { permissions: ["sessions:read"], dbRole: "analytics_user" },
20
+ * },
21
+ * });
22
+ *
23
+ * router.use(b.middleware.attachUser(...));
24
+ * router.use(b.middleware.dbRoleFor({
25
+ * permissions: perms, // resolves dbRole from req.user.roles
26
+ * defaultRole: "app_user",
27
+ * }));
28
+ *
29
+ * router.get("/sessions", function (req, res) {
30
+ * // No `{ backend: ... }` opt — the framework picked it from req.dbRole.
31
+ * b.externalDb.read.query("SELECT * FROM sessions WHERE _id = $1", [sid])
32
+ * .then(...);
33
+ * });
34
+ *
35
+ * Resolution order:
36
+ * 1. opts.resolve(req) — operator-supplied custom resolver
37
+ * 2. opts.permissions.dbRoleFor — RBAC mapping (when permissions provided)
38
+ * 3. opts.defaultRole — fallback string
39
+ * 4. null — no binding (externalDb falls back to default backend)
40
+ *
41
+ * Validation at create() time — bad shape throws here, not at the first
42
+ * request:
43
+ * - opts shape (validateOpts allow-list)
44
+ * - resolve / responder must be functions if provided
45
+ * - permissions must expose dbRoleFor (the b.permissions shape)
46
+ * - defaultRole, when provided, must be a SQL-identifier-shaped string
47
+ * - missingRoleStatus must be a 100-599 integer
48
+ *
49
+ * Runtime validation on resolver output:
50
+ * - resolver returns must be string | null | undefined
51
+ * - non-empty string return MUST match safeSql.validateIdentifier; a
52
+ * malformed identifier from a resolver is a wiring bug (the operator
53
+ * plugged in a resolver that returns garbage). Routed through
54
+ * next(err) so the request surfaces a clear error instead of silently
55
+ * routing to the default backend.
56
+ *
57
+ * Failure modes:
58
+ * - resolver throws → 500 propagated via next(err)
59
+ * - role required but absent → respond with missingRoleStatus (default 401)
60
+ * - role identifier malformed → respond with 500 (resolver bug — not a runtime user error)
61
+ *
62
+ * Observability event: db.role.bound { value: 1, labels: { role, source } }
63
+ * source ∈ "resolver" | "permissions" | "default"
64
+ *
65
+ * Audit emission of `db.role.switched` (the cross-request transition
66
+ * record) lands in v0.6.7 — this middleware focuses on binding the role.
67
+ */
68
+ var dbRoleContext = require("../db-role-context");
69
+ var lazyRequire = require("../lazy-require");
70
+ var safeSql = require("../safe-sql");
71
+ var validateOpts = require("../validate-opts");
72
+ var { defineClass } = require("../framework-error");
73
+
74
+ var observability = lazyRequire(function () { return require("../observability"); });
75
+
76
+ var DbRoleForError = defineClass("DbRoleForError", { alwaysPermanent: true });
77
+ var _err = function (code, message) { return new DbRoleForError(code, message); };
78
+
79
+ var ALLOWED_OPTS = [
80
+ "resolve", "permissions", "defaultRole",
81
+ "requireRole", "missingRoleStatus", "responder",
82
+ ];
83
+
84
+ function _emitEvent(name, value, labels) {
85
+ try { observability().event(name, value, labels || {}); }
86
+ catch (_e) { /* hot-path observability sink — drop silent by design */ }
87
+ }
88
+
89
+ function _validateRoleIdentifier(role, where) {
90
+ try {
91
+ safeSql.validateIdentifier(role, { allowReserved: false });
92
+ } catch (e) {
93
+ throw _err("db-role-for/bad-role",
94
+ where + ": role '" + role + "' is not a valid SQL identifier: " +
95
+ ((e && e.message) || String(e)));
96
+ }
97
+ }
98
+
99
+ function _defaultResponder(req, res, status, info) {
100
+ res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
101
+ res.end(JSON.stringify(info));
102
+ }
103
+
104
+ function create(opts) {
105
+ opts = opts || {};
106
+ validateOpts(opts, ALLOWED_OPTS, "middleware.dbRoleFor");
107
+
108
+ if (opts.resolve !== undefined && typeof opts.resolve !== "function") {
109
+ throw _err("db-role-for/bad-opt",
110
+ "middleware.dbRoleFor: resolve must be a function");
111
+ }
112
+ if (opts.responder !== undefined && typeof opts.responder !== "function") {
113
+ throw _err("db-role-for/bad-opt",
114
+ "middleware.dbRoleFor: responder must be a function");
115
+ }
116
+ if (opts.permissions !== undefined && opts.permissions !== null) {
117
+ if (typeof opts.permissions !== "object" ||
118
+ typeof opts.permissions.dbRoleFor !== "function") {
119
+ throw _err("db-role-for/bad-opt",
120
+ "middleware.dbRoleFor: permissions must be a b.permissions instance " +
121
+ "(missing dbRoleFor method)");
122
+ }
123
+ }
124
+ if (opts.defaultRole !== undefined && opts.defaultRole !== null) {
125
+ if (typeof opts.defaultRole !== "string" || opts.defaultRole.length === 0) {
126
+ throw _err("db-role-for/bad-opt",
127
+ "middleware.dbRoleFor: defaultRole must be a non-empty string");
128
+ }
129
+ _validateRoleIdentifier(opts.defaultRole, "middleware.dbRoleFor: defaultRole");
130
+ }
131
+ if (opts.requireRole !== undefined && typeof opts.requireRole !== "boolean") {
132
+ throw _err("db-role-for/bad-opt",
133
+ "middleware.dbRoleFor: requireRole must be a boolean");
134
+ }
135
+ if (opts.missingRoleStatus !== undefined) {
136
+ if (typeof opts.missingRoleStatus !== "number" ||
137
+ !isFinite(opts.missingRoleStatus) ||
138
+ opts.missingRoleStatus < 100 || opts.missingRoleStatus > 599) {
139
+ throw _err("db-role-for/bad-opt",
140
+ "middleware.dbRoleFor: missingRoleStatus must be an HTTP status code (100-599)");
141
+ }
142
+ }
143
+
144
+ var resolveFn = opts.resolve || null;
145
+ var perms = opts.permissions || null;
146
+ var defaultRole = opts.defaultRole || null;
147
+ var requireRole = !!opts.requireRole;
148
+ var missingRoleStatus = opts.missingRoleStatus || 401;
149
+ var responder = opts.responder || _defaultResponder;
150
+
151
+ return function dbRoleForMiddleware(req, res, next) {
152
+ var role = null;
153
+ var source = null;
154
+
155
+ if (resolveFn) {
156
+ var resolved;
157
+ try { resolved = resolveFn(req); }
158
+ catch (e) { return next(e); }
159
+ if (resolved !== undefined && resolved !== null && resolved !== "") {
160
+ role = resolved;
161
+ source = "resolver";
162
+ }
163
+ }
164
+
165
+ if (!role && perms) {
166
+ // permissions.dbRoleFor walks req.user.roles / req.apiKey.scopes via
167
+ // the configured resolver and returns the first declared dbRole.
168
+ var fromPerms;
169
+ try { fromPerms = perms.dbRoleFor(req); }
170
+ catch (e) { return next(e); }
171
+ if (fromPerms) {
172
+ role = fromPerms;
173
+ source = "permissions";
174
+ }
175
+ }
176
+
177
+ if (!role && defaultRole) {
178
+ role = defaultRole;
179
+ source = "default";
180
+ }
181
+
182
+ if (!role) {
183
+ if (requireRole) {
184
+ _emitEvent("db.role.missing", 1, {});
185
+ return responder(req, res, missingRoleStatus, {
186
+ error: "missing_db_role",
187
+ status: missingRoleStatus,
188
+ });
189
+ }
190
+ // No binding — let externalDb fall back to its default backend.
191
+ req.dbRole = null;
192
+ return next();
193
+ }
194
+
195
+ if (typeof role !== "string") {
196
+ return next(_err("db-role-for/bad-resolver-return",
197
+ "middleware.dbRoleFor: resolver returned non-string role: " + typeof role));
198
+ }
199
+ // Validate the resolver-supplied identifier at request time — a
200
+ // malformed identifier is a wiring bug, not a request-shape concern.
201
+ // Route the throw through next(err) so an operator's errorHandler
202
+ // reaches it instead of the request hanging.
203
+ try {
204
+ _validateRoleIdentifier(role, "middleware.dbRoleFor: resolver/" + source);
205
+ } catch (e) {
206
+ return next(e);
207
+ }
208
+
209
+ req.dbRole = role;
210
+ _emitEvent("db.role.bound", 1, { role: role, source: source });
211
+ dbRoleContext.runWithRole(role, function () { next(); });
212
+ };
213
+ }
214
+
215
+ module.exports = {
216
+ create: create,
217
+ DbRoleForError: DbRoleForError,
218
+ };
@@ -33,6 +33,7 @@ module.exports = {
33
33
  sse: require("./sse").create,
34
34
  requestLog: require("./request-log").create,
35
35
  apiEncrypt: require("./api-encrypt"),
36
+ dbRoleFor: require("./db-role-for").create,
36
37
 
37
38
  // Module exports for advanced use (constants, raw factory access)
38
39
  _modules: {
@@ -52,5 +53,6 @@ module.exports = {
52
53
  sse: require("./sse"),
53
54
  requestLog: require("./request-log"),
54
55
  apiEncrypt: require("./api-encrypt"),
56
+ dbRoleFor: require("./db-role-for"),
55
57
  },
56
58
  };
@@ -47,6 +47,7 @@
47
47
 
48
48
  var lazyRequire = require("./lazy-require");
49
49
  var requestHelpers = require("./request-helpers");
50
+ var safeSql = require("./safe-sql");
50
51
  var validateOpts = require("./validate-opts");
51
52
  var { PermissionsError } = require("./framework-error");
52
53
 
@@ -116,7 +117,7 @@ function _validateScopePattern(scope, ctx) {
116
117
 
117
118
  function _normalizeRoleEntry(name, entry) {
118
119
  if (Array.isArray(entry)) {
119
- return { extends: [], permissions: entry.slice() };
120
+ return { extends: [], permissions: entry.slice(), dbRole: null };
120
121
  }
121
122
  if (entry && typeof entry === "object") {
122
123
  var ext = entry.extends || [];
@@ -127,9 +128,27 @@ function _normalizeRoleEntry(name, entry) {
127
128
  if (!Array.isArray(perms)) {
128
129
  throw _err("BAD_ROLE", "role '" + name + "': permissions must be an array of scope strings");
129
130
  }
130
- return { extends: ext.slice(), permissions: perms.slice() };
131
+ var dbRole = null;
132
+ if (entry.dbRole !== undefined && entry.dbRole !== null) {
133
+ if (typeof entry.dbRole !== "string" || entry.dbRole.length === 0) {
134
+ throw _err("BAD_ROLE",
135
+ "role '" + name + "': dbRole must be a non-empty string");
136
+ }
137
+ // dbRole feeds straight into externalDb backend pick + the
138
+ // dbRoleFor middleware's identifier check; validate at create()
139
+ // time so a typo surfaces at boot, not on the first request.
140
+ try {
141
+ safeSql.validateIdentifier(entry.dbRole, { allowReserved: false });
142
+ } catch (e) {
143
+ throw _err("BAD_ROLE",
144
+ "role '" + name + "': dbRole '" + entry.dbRole +
145
+ "' is not a valid SQL identifier: " + ((e && e.message) || String(e)));
146
+ }
147
+ dbRole = entry.dbRole;
148
+ }
149
+ return { extends: ext.slice(), permissions: perms.slice(), dbRole: dbRole };
131
150
  }
132
- throw _err("BAD_ROLE", "role '" + name + "' must be an array of scopes or { extends?, permissions }");
151
+ throw _err("BAD_ROLE", "role '" + name + "' must be an array of scopes or { extends?, permissions, dbRole? }");
133
152
  }
134
153
 
135
154
  function _validateRoles(roles) {
@@ -385,6 +404,44 @@ function create(opts) {
385
404
  };
386
405
  }
387
406
 
407
+ // dbRoleFor — walk the actor's roles in order and return the first
408
+ // declared dbRole. Composes with b.middleware.dbRoleFor so a single
409
+ // RBAC table drives both authorization scopes and request-time DB
410
+ // role binding.
411
+ //
412
+ // The arg can be the request (default resolver pulls actor from
413
+ // req.user / req.apiKey) OR an actor object directly. Returns null if
414
+ // no actor is found OR the actor's roles don't include any with a
415
+ // declared dbRole.
416
+ //
417
+ // Lookup order: extends are walked depth-first so a child role that
418
+ // overrides dbRole takes precedence over its parent. When multiple
419
+ // top-level roles are listed, the first wins (operators wanting a
420
+ // priority order should list more-specific roles first).
421
+ function dbRoleFor(reqOrActor) {
422
+ var actor = reqOrActor;
423
+ // Heuristic: a request shape carries headers / url; resolve through
424
+ // the configured resolver. An actor shape has roles / scopes
425
+ // directly.
426
+ if (actor && (actor.headers || actor.url || actor.method)) {
427
+ actor = resolver(actor);
428
+ }
429
+ if (!actor || typeof actor !== "object") return null;
430
+ var roleNames = Array.isArray(actor.roles) ? actor.roles : null;
431
+ if (!roleNames || roleNames.length === 0) return null;
432
+ // Walk the same DFS order expand() uses so the first-seen dbRole
433
+ // is consistent with how scopes are inherited.
434
+ var visited = new Set();
435
+ for (var i = 0; i < roleNames.length; i++) {
436
+ var name = roleNames[i];
437
+ if (typeof name !== "string") continue;
438
+ if (!Object.prototype.hasOwnProperty.call(roleTable, name)) continue;
439
+ var found = _findDbRole(name, roleTable, visited);
440
+ if (found) return found;
441
+ }
442
+ return null;
443
+ }
444
+
388
445
  return {
389
446
  require: function (scope) { return _middleware("single", scope); },
390
447
  requireAll: function (scopes) { return _middleware("all", scopes); },
@@ -393,11 +450,27 @@ function create(opts) {
393
450
  checkAll: checkAll,
394
451
  checkAny: checkAny,
395
452
  expand: expand,
453
+ dbRoleFor: dbRoleFor,
396
454
  has: function (name) { return Object.prototype.hasOwnProperty.call(roleTable, name); },
397
455
  roles: Object.freeze(Object.keys(roleTable)),
398
456
  };
399
457
  }
400
458
 
459
+ function _findDbRole(roleName, table, visited) {
460
+ if (visited.has(roleName)) return null;
461
+ visited.add(roleName);
462
+ var spec = table[roleName];
463
+ if (!spec) return null;
464
+ // Child overrides parent — check this role's own dbRole first, then
465
+ // recurse into extends.
466
+ if (spec.dbRole) return spec.dbRole;
467
+ for (var i = 0; i < spec.extends.length; i++) {
468
+ var found = _findDbRole(spec.extends[i], table, visited);
469
+ if (found) return found;
470
+ }
471
+ return null;
472
+ }
473
+
401
474
  // ---- Helpers ----
402
475
 
403
476
  function _labelize(requested) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.6.5",
3
+ "version": "0.6.6",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",