@blamejs/core 0.6.4 → 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.
@@ -0,0 +1,424 @@
1
+ "use strict";
2
+ /**
3
+ * b.db.declareView — declarative view + GRANT migration spec.
4
+ *
5
+ * Returns a migration-shape object that b.externalDb.migrate(...) applies
6
+ * against a Postgres backend. The view exposes a deliberately-narrowed
7
+ * column projection of the source table — sensitive columns are dropped
8
+ * (redactColumns), sealed columns are auto-omitted (operator declares
9
+ * via sealedColumns; a future externalDb sealed-fields registry will
10
+ * make this automatic), and existing derived hash columns can be
11
+ * exposed in place of their plaintext source via hashColumns.
12
+ *
13
+ * Postgres-only: SQLite has no GRANT semantics; MySQL's CREATE VIEW
14
+ * grammar differs and isn't covered. Apply throws NOT_SUPPORTED at
15
+ * migration-apply time when the targeted backend's dialect isn't
16
+ * "postgres" so operators see the failure cause clearly.
17
+ *
18
+ * Public API (b.db.declareView):
19
+ *
20
+ * var mig = b.db.declareView({
21
+ * schema: "analytics", // target schema
22
+ * name: "sessions", // view name
23
+ * source: "public.sessions", // schema.table OR ["public","sessions"]
24
+ * redactColumns: ["ssn", "diagnosis"], // dropped from view
25
+ * sealedColumns: ["secrets_jsonb"], // operator-declared sealed cols (auto-omit)
26
+ * hashColumns: { emailHash: "email" },// hide source plaintext, keep hash
27
+ * whereClause: "deleted_at IS NULL", // optional filter
28
+ * grantTo: ["analytics_user"], // roles that get SELECT
29
+ * backend: "main", // optional — defaults to default backend
30
+ * });
31
+ *
32
+ * // mig is a migration-shape object: { description, target, backend, up, down }
33
+ * // Place it in a migration file:
34
+ * // module.exports = mig;
35
+ * // and run b.externalDb.migrate.create({ dir }).up();
36
+ *
37
+ * Validation runs at migration apply (not declare) time so source-column
38
+ * existence and role existence checks query the live database. Operator
39
+ * typos surface as clear errors at the migrate command, not as silent
40
+ * empty views or grant-to-nonexistent-role footguns.
41
+ *
42
+ * Tier-A validation at declareView() call time:
43
+ * - schema, name, source segments → safeSql.validateIdentifier
44
+ * - column names in redactColumns / sealedColumns / hashColumns →
45
+ * safeSql.validateIdentifier
46
+ * - role names in grantTo → safeSql.validateIdentifier
47
+ * - whereClause stays operator-supplied SQL (the framework cannot
48
+ * validate arbitrary expressions); it interpolates as-is into the
49
+ * migration text. Operators wrap any literal values inside
50
+ * whereClause via standard SQL quoting.
51
+ *
52
+ * Audit metadata emitted on apply:
53
+ * {
54
+ * view: "schema.name",
55
+ * source: "schema.table",
56
+ * selectedColumns: [string],
57
+ * redactedColumns: [string], // intersection of redactColumns & source
58
+ * autoExcludedSealed: [string], // sealedColumns members found in source
59
+ * hashedColumns: { aliasOrHashCol: srcCol },
60
+ * grantedTo: [string],
61
+ * }
62
+ */
63
+ var safeSql = require("./safe-sql");
64
+ var { defineClass } = require("./framework-error");
65
+
66
+ var DeclareViewError = defineClass("DeclareViewError", { alwaysPermanent: true });
67
+
68
+ var ALLOWED_OPTS = [
69
+ "schema", "name", "source",
70
+ "redactColumns", "sealedColumns", "hashColumns",
71
+ "whereClause", "grantTo", "backend",
72
+ ];
73
+
74
+ function _err(code, message) {
75
+ return new DeclareViewError(code, message);
76
+ }
77
+
78
+ function _validateIdent(where, value) {
79
+ try {
80
+ safeSql.validateIdentifier(value, { allowReserved: true });
81
+ } catch (e) {
82
+ throw _err("declare-view/bad-identifier",
83
+ where + ": invalid identifier '" + value + "': " + ((e && e.message) || String(e)));
84
+ }
85
+ }
86
+
87
+ function _validateStringArray(where, arr, optional) {
88
+ if (arr === undefined || arr === null) {
89
+ if (optional) return [];
90
+ throw _err("declare-view/missing-opt", where + " is required");
91
+ }
92
+ if (!Array.isArray(arr)) {
93
+ throw _err("declare-view/bad-type", where + " must be an array of strings");
94
+ }
95
+ for (var i = 0; i < arr.length; i++) {
96
+ if (typeof arr[i] !== "string" || arr[i].length === 0) {
97
+ throw _err("declare-view/bad-entry",
98
+ where + "[" + i + "] must be a non-empty string");
99
+ }
100
+ }
101
+ return arr.slice();
102
+ }
103
+
104
+ function _parseSource(source) {
105
+ // Accepts "schema.table" or ["schema", "table"] or "table" (defaults to public).
106
+ var parts;
107
+ if (typeof source === "string") {
108
+ if (source.length === 0) {
109
+ throw _err("declare-view/bad-source", "source must be a non-empty string or array");
110
+ }
111
+ parts = source.split(".");
112
+ } else if (Array.isArray(source)) {
113
+ parts = source.slice();
114
+ } else {
115
+ throw _err("declare-view/bad-source",
116
+ "source must be a string ('schema.table') or array (['schema','table']), got " + typeof source);
117
+ }
118
+ if (parts.length === 1) parts = ["public", parts[0]];
119
+ if (parts.length !== 2) {
120
+ throw _err("declare-view/bad-source",
121
+ "source must resolve to two segments [schema, table]; got " + parts.length + " segment(s)");
122
+ }
123
+ for (var i = 0; i < parts.length; i++) _validateIdent("source[" + i + "]", parts[i]);
124
+ return { schema: parts[0], name: parts[1] };
125
+ }
126
+
127
+ function _validateOpts(opts) {
128
+ if (!opts || typeof opts !== "object") {
129
+ throw _err("declare-view/bad-opts", "declareView requires an opts object");
130
+ }
131
+ for (var k in opts) {
132
+ if (Object.prototype.hasOwnProperty.call(opts, k) && ALLOWED_OPTS.indexOf(k) === -1) {
133
+ throw _err("declare-view/unknown-opt",
134
+ "unknown opt '" + k + "'. Allowed: " + ALLOWED_OPTS.join(", "));
135
+ }
136
+ }
137
+
138
+ if (typeof opts.schema !== "string" || opts.schema.length === 0) {
139
+ throw _err("declare-view/missing-opt", "schema is required");
140
+ }
141
+ _validateIdent("schema", opts.schema);
142
+
143
+ if (typeof opts.name !== "string" || opts.name.length === 0) {
144
+ throw _err("declare-view/missing-opt", "name is required");
145
+ }
146
+ _validateIdent("name", opts.name);
147
+
148
+ if (opts.source === undefined) {
149
+ throw _err("declare-view/missing-opt", "source is required");
150
+ }
151
+ var src = _parseSource(opts.source);
152
+
153
+ var redactColumns = _validateStringArray("redactColumns", opts.redactColumns, true);
154
+ for (var i = 0; i < redactColumns.length; i++) {
155
+ _validateIdent("redactColumns[" + i + "]", redactColumns[i]);
156
+ }
157
+
158
+ var sealedColumns = _validateStringArray("sealedColumns", opts.sealedColumns, true);
159
+ for (var j = 0; j < sealedColumns.length; j++) {
160
+ _validateIdent("sealedColumns[" + j + "]", sealedColumns[j]);
161
+ }
162
+
163
+ var hashColumns = {};
164
+ if (opts.hashColumns !== undefined && opts.hashColumns !== null) {
165
+ if (typeof opts.hashColumns !== "object" || Array.isArray(opts.hashColumns)) {
166
+ throw _err("declare-view/bad-type",
167
+ "hashColumns must be an object { aliasOrHashCol: srcCol }");
168
+ }
169
+ for (var hc in opts.hashColumns) {
170
+ if (!Object.prototype.hasOwnProperty.call(opts.hashColumns, hc)) continue;
171
+ _validateIdent("hashColumns key '" + hc + "'", hc);
172
+ var v = opts.hashColumns[hc];
173
+ if (typeof v !== "string" || v.length === 0) {
174
+ throw _err("declare-view/bad-entry",
175
+ "hashColumns['" + hc + "'] must be a non-empty string (the source plaintext column)");
176
+ }
177
+ _validateIdent("hashColumns['" + hc + "']", v);
178
+ hashColumns[hc] = v;
179
+ }
180
+ }
181
+
182
+ var whereClause = null;
183
+ if (opts.whereClause !== undefined && opts.whereClause !== null) {
184
+ if (typeof opts.whereClause !== "string") {
185
+ throw _err("declare-view/bad-type", "whereClause must be a string");
186
+ }
187
+ if (opts.whereClause.indexOf(";") !== -1) {
188
+ throw _err("declare-view/bad-where",
189
+ "whereClause must not contain ';' — use a single boolean expression");
190
+ }
191
+ whereClause = opts.whereClause;
192
+ }
193
+
194
+ var grantTo = _validateStringArray("grantTo", opts.grantTo, true);
195
+ for (var g = 0; g < grantTo.length; g++) {
196
+ _validateIdent("grantTo[" + g + "]", grantTo[g]);
197
+ }
198
+
199
+ if (opts.backend !== undefined && opts.backend !== null) {
200
+ if (typeof opts.backend !== "string" || opts.backend.length === 0) {
201
+ throw _err("declare-view/bad-type", "backend must be a non-empty string");
202
+ }
203
+ }
204
+
205
+ return {
206
+ schema: opts.schema,
207
+ name: opts.name,
208
+ source: src,
209
+ redactColumns: redactColumns,
210
+ sealedColumns: sealedColumns,
211
+ hashColumns: hashColumns,
212
+ whereClause: whereClause,
213
+ grantTo: grantTo,
214
+ backend: opts.backend || null,
215
+ };
216
+ }
217
+
218
+ // ---- Apply-time helpers (run inside up()) ----
219
+
220
+ async function _fetchSourceColumns(xdb, schema, table) {
221
+ var res = await xdb.query(
222
+ "SELECT column_name FROM information_schema.columns " +
223
+ "WHERE table_schema = $1 AND table_name = $2 " +
224
+ "ORDER BY ordinal_position ASC",
225
+ [schema, table]
226
+ );
227
+ var rows = (res && res.rows) || [];
228
+ return rows.map(function (r) { return r.column_name; });
229
+ }
230
+
231
+ async function _fetchExistingRoles(xdb, names) {
232
+ if (names.length === 0) return new Set();
233
+ var placeholders = names.map(function (_, i) { return "$" + (i + 1); }).join(", ");
234
+ var res = await xdb.query(
235
+ "SELECT rolname FROM pg_roles WHERE rolname IN (" + placeholders + ")",
236
+ names
237
+ );
238
+ var rows = (res && res.rows) || [];
239
+ return new Set(rows.map(function (r) { return r.rolname; }));
240
+ }
241
+
242
+ function _missing(required, available) {
243
+ var miss = [];
244
+ for (var i = 0; i < required.length; i++) {
245
+ if (available.indexOf(required[i]) === -1) miss.push(required[i]);
246
+ }
247
+ return miss;
248
+ }
249
+
250
+ function _buildSelectColumnList(sourceCols, spec) {
251
+ // Drop set: redactColumns ∩ source, sealedColumns ∩ source, hashColumns.values ∩ source.
252
+ var dropSet = Object.create(null);
253
+ for (var i = 0; i < spec.redactColumns.length; i++) dropSet[spec.redactColumns[i]] = true;
254
+ for (var j = 0; j < spec.sealedColumns.length; j++) dropSet[spec.sealedColumns[j]] = true;
255
+ for (var hc in spec.hashColumns) dropSet[spec.hashColumns[hc]] = true;
256
+
257
+ var kept = [];
258
+ for (var k = 0; k < sourceCols.length; k++) {
259
+ if (!dropSet[sourceCols[k]]) kept.push(sourceCols[k]);
260
+ }
261
+ return kept;
262
+ }
263
+
264
+ function _intersectInSource(list, sourceCols) {
265
+ var srcSet = Object.create(null);
266
+ for (var i = 0; i < sourceCols.length; i++) srcSet[sourceCols[i]] = true;
267
+ var out = [];
268
+ for (var j = 0; j < list.length; j++) {
269
+ if (srcSet[list[j]]) out.push(list[j]);
270
+ }
271
+ return out;
272
+ }
273
+
274
+ function _ensureBackendIsPostgres(externalDb, backendName) {
275
+ // externalDb.listBackends() returns { name, dialect, ... } per backend.
276
+ var list = externalDb.listBackends();
277
+ var found = null;
278
+ for (var i = 0; i < list.length; i++) {
279
+ if (list[i].name === backendName) { found = list[i]; break; }
280
+ }
281
+ if (!found) {
282
+ throw _err("declare-view/unknown-backend",
283
+ "no externalDb backend named '" + backendName + "' — declared backends: " +
284
+ list.map(function (b) { return b.name; }).join(", "));
285
+ }
286
+ if (found.dialect !== "postgres") {
287
+ throw _err("declare-view/not-supported",
288
+ "declareView is Postgres-only; backend '" + backendName + "' has dialect='" +
289
+ found.dialect + "'. Write the view as a hand-rolled migration for this dialect.");
290
+ }
291
+ }
292
+
293
+ // ---- The factory ----
294
+
295
+ function declareView(opts) {
296
+ var spec = _validateOpts(opts);
297
+
298
+ // The migration shape consumed by b.externalDb.migrate. The runner
299
+ // resolves the backend at apply time (operator may set spec.backend
300
+ // explicitly OR rely on the migrate runner's default backend).
301
+ var description = "declareView " + spec.schema + "." + spec.name;
302
+ var qView = safeSql.quoteQualified([spec.schema, spec.name], "postgres");
303
+ var qSource = safeSql.quoteQualified([spec.source.schema, spec.source.name], "postgres");
304
+
305
+ async function up(xdb, ctx) {
306
+ // Tier-A boundary: confirm we're on Postgres before any DDL leaves the process.
307
+ if (ctx && ctx.externalDb && ctx.backendName) {
308
+ _ensureBackendIsPostgres(ctx.externalDb, ctx.backendName);
309
+ }
310
+
311
+ // Live validation — source columns + roles must exist.
312
+ var sourceCols = await _fetchSourceColumns(xdb, spec.source.schema, spec.source.name);
313
+ if (sourceCols.length === 0) {
314
+ throw _err("declare-view/source-not-found",
315
+ "source table '" + spec.source.schema + "." + spec.source.name +
316
+ "' has no columns visible (does it exist? does the migration role have SELECT on information_schema.columns?)");
317
+ }
318
+
319
+ var missingRedact = _missing(spec.redactColumns, sourceCols);
320
+ if (missingRedact.length > 0) {
321
+ throw _err("declare-view/redact-not-in-source",
322
+ "redactColumns [" + missingRedact.join(", ") + "] not present on source '" +
323
+ spec.source.schema + "." + spec.source.name + "'");
324
+ }
325
+
326
+ var missingHashKeys = _missing(Object.keys(spec.hashColumns), sourceCols);
327
+ if (missingHashKeys.length > 0) {
328
+ throw _err("declare-view/hash-key-not-in-source",
329
+ "hashColumns keys [" + missingHashKeys.join(", ") + "] not present on source '" +
330
+ spec.source.schema + "." + spec.source.name +
331
+ "' — declare them as derivedHashes on the source schema first");
332
+ }
333
+
334
+ var missingHashSrc = _missing(_objValues(spec.hashColumns), sourceCols);
335
+ if (missingHashSrc.length > 0) {
336
+ throw _err("declare-view/hash-source-not-in-source",
337
+ "hashColumns values [" + missingHashSrc.join(", ") + "] not present on source '" +
338
+ spec.source.schema + "." + spec.source.name + "'");
339
+ }
340
+
341
+ if (spec.grantTo.length > 0) {
342
+ var existing = await _fetchExistingRoles(xdb, spec.grantTo);
343
+ var missingRoles = [];
344
+ for (var i = 0; i < spec.grantTo.length; i++) {
345
+ if (!existing.has(spec.grantTo[i])) missingRoles.push(spec.grantTo[i]);
346
+ }
347
+ if (missingRoles.length > 0) {
348
+ throw _err("declare-view/role-not-found",
349
+ "grantTo roles [" + missingRoles.join(", ") +
350
+ "] not present in pg_roles — CREATE ROLE them in an earlier migration");
351
+ }
352
+ }
353
+
354
+ var selectedColumns = _buildSelectColumnList(sourceCols, spec);
355
+ if (selectedColumns.length === 0) {
356
+ throw _err("declare-view/empty-select",
357
+ "view '" + spec.schema + "." + spec.name +
358
+ "' would have zero columns after redact/sealed/hash exclusions — adjust the spec");
359
+ }
360
+
361
+ // Build CREATE VIEW. Each column is independently quoted so a
362
+ // reserved-word column name (e.g. "user", "order") resolves correctly.
363
+ var quotedCols = selectedColumns.map(function (c) {
364
+ return safeSql.quoteIdentifier(c, "postgres");
365
+ }).join(", ");
366
+ var createSql = "CREATE VIEW " + qView + " AS SELECT " + quotedCols +
367
+ " FROM " + qSource;
368
+ if (spec.whereClause) createSql += " WHERE " + spec.whereClause;
369
+
370
+ await xdb.query(createSql, []);
371
+
372
+ // GRANT SELECT — one statement covers all roles.
373
+ if (spec.grantTo.length > 0) {
374
+ var quotedRoles = spec.grantTo.map(function (r) {
375
+ return safeSql.quoteIdentifier(r, "postgres");
376
+ }).join(", ");
377
+ await xdb.query(
378
+ "GRANT SELECT ON " + qView + " TO " + quotedRoles,
379
+ []
380
+ );
381
+ }
382
+
383
+ return {
384
+ view: spec.schema + "." + spec.name,
385
+ source: spec.source.schema + "." + spec.source.name,
386
+ selectedColumns: selectedColumns,
387
+ redactedColumns: _intersectInSource(spec.redactColumns, sourceCols),
388
+ autoExcludedSealed: _intersectInSource(spec.sealedColumns, sourceCols),
389
+ hashedColumns: Object.assign({}, spec.hashColumns),
390
+ grantedTo: spec.grantTo.slice(),
391
+ };
392
+ }
393
+
394
+ async function down(xdb, ctx) {
395
+ if (ctx && ctx.externalDb && ctx.backendName) {
396
+ _ensureBackendIsPostgres(ctx.externalDb, ctx.backendName);
397
+ }
398
+ await xdb.query("DROP VIEW IF EXISTS " + qView, []);
399
+ }
400
+
401
+ return {
402
+ description: description,
403
+ target: "externalDb",
404
+ backend: spec.backend,
405
+ up: up,
406
+ down: down,
407
+ // Expose the validated spec for testability — declareView callers
408
+ // can introspect what the migration will emit without running it.
409
+ _spec: spec,
410
+ };
411
+ }
412
+
413
+ function _objValues(obj) {
414
+ var out = [];
415
+ for (var k in obj) {
416
+ if (Object.prototype.hasOwnProperty.call(obj, k)) out.push(obj[k]);
417
+ }
418
+ return out;
419
+ }
420
+
421
+ module.exports = {
422
+ declareView: declareView,
423
+ DeclareViewError: DeclareViewError,
424
+ };
@@ -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
@@ -1168,6 +1168,16 @@ module.exports = {
1168
1168
  var m = tableMetadata[name];
1169
1169
  return m ? JSON.parse(JSON.stringify(m)) : null;
1170
1170
  },
1171
+ // declareView — declarative CREATE VIEW + GRANT migration spec for an
1172
+ // externalDb backend. Returns a migration-shape object for use with
1173
+ // b.externalDb.migrate. Postgres-only; fail-fast at apply time on other
1174
+ // dialects. See lib/db-declare-view.js.
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,
1171
1181
  // Internal accessors used by audit / subject / consent modules.
1172
1182
  // Not part of the public contract — apps should not depend on them.
1173
1183
  _getSubjectTables: function () { return subjectTables.slice(); },