@blamejs/core 0.7.4 → 0.7.18

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.
Files changed (180) hide show
  1. package/CHANGELOG.md +423 -395
  2. package/README.md +150 -149
  3. package/bin/blamejs.js +0 -0
  4. package/index.js +308 -284
  5. package/lib/api-key.js +660 -663
  6. package/lib/api-snapshot.js +338 -338
  7. package/lib/app-shutdown.js +385 -385
  8. package/lib/app.js +365 -365
  9. package/lib/archive.js +250 -250
  10. package/lib/atomic-file.js +544 -544
  11. package/lib/audit-chain.js +177 -177
  12. package/lib/audit-sign.js +344 -344
  13. package/lib/audit-tools.js +677 -677
  14. package/lib/audit.js +766 -766
  15. package/lib/auth/jwt.js +311 -311
  16. package/lib/auth/lockout.js +436 -436
  17. package/lib/auth/oauth.js +721 -721
  18. package/lib/auth/passkey.js +181 -181
  19. package/lib/auth/password.js +594 -594
  20. package/lib/backup/bundle.js +217 -217
  21. package/lib/backup/crypto.js +176 -176
  22. package/lib/backup/index.js +515 -515
  23. package/lib/backup/manifest.js +282 -282
  24. package/lib/break-glass.js +1338 -1338
  25. package/lib/bundler.js +441 -441
  26. package/lib/cache-redis.js +256 -256
  27. package/lib/cache.js +1206 -1206
  28. package/lib/canonical-json.js +115 -115
  29. package/lib/chain-writer.js +234 -234
  30. package/lib/cli-helpers.js +206 -206
  31. package/lib/cli.js +2334 -2334
  32. package/lib/cluster-provider-db.js +317 -317
  33. package/lib/cluster-storage.js +226 -226
  34. package/lib/cluster.js +703 -703
  35. package/lib/codepoint-class.js +196 -0
  36. package/lib/config-drift.js +301 -301
  37. package/lib/consent.js +222 -222
  38. package/lib/constants.js +191 -191
  39. package/lib/cookies.js +315 -315
  40. package/lib/credential-hash.js +322 -322
  41. package/lib/crypto.js +266 -266
  42. package/lib/csv.js +275 -286
  43. package/lib/db-declare-row-policy.js +267 -267
  44. package/lib/db-declare-view.js +420 -421
  45. package/lib/db-query.js +406 -406
  46. package/lib/db-schema.js +319 -319
  47. package/lib/db.js +1288 -1288
  48. package/lib/deprecate.js +222 -222
  49. package/lib/dev.js +335 -335
  50. package/lib/dual-control.js +473 -473
  51. package/lib/error-page.js +420 -420
  52. package/lib/external-db-migrate.js +441 -441
  53. package/lib/external-db.js +1061 -1061
  54. package/lib/file-type.js +273 -273
  55. package/lib/file-upload.js +213 -10
  56. package/lib/forms.js +422 -422
  57. package/lib/framework-error.js +293 -215
  58. package/lib/framework-schema.js +717 -717
  59. package/lib/gate-contract.js +971 -0
  60. package/lib/guard-all.js +405 -0
  61. package/lib/guard-archive.js +739 -0
  62. package/lib/guard-csv.js +816 -0
  63. package/lib/guard-email.js +744 -0
  64. package/lib/guard-filename.js +724 -0
  65. package/lib/guard-html.js +976 -0
  66. package/lib/guard-json.js +729 -0
  67. package/lib/guard-markdown.js +586 -0
  68. package/lib/guard-svg.js +976 -0
  69. package/lib/guard-xml.js +405 -0
  70. package/lib/guard-yaml.js +529 -0
  71. package/lib/handlers.js +350 -350
  72. package/lib/http-client-cookie-jar.js +508 -508
  73. package/lib/http-client.js +1195 -1195
  74. package/lib/i18n.js +878 -878
  75. package/lib/jobs.js +185 -185
  76. package/lib/log-stream-cloudwatch.js +369 -369
  77. package/lib/log-stream-local.js +146 -146
  78. package/lib/log-stream-otlp-grpc.js +410 -410
  79. package/lib/log-stream-otlp.js +286 -286
  80. package/lib/log-stream-syslog.js +302 -302
  81. package/lib/log-stream-webhook.js +199 -199
  82. package/lib/log-stream.js +330 -330
  83. package/lib/log.js +500 -500
  84. package/lib/mail-bounce.js +528 -528
  85. package/lib/mail-dkim.js +369 -362
  86. package/lib/mail.js +981 -962
  87. package/lib/metrics.js +683 -683
  88. package/lib/middleware/api-encrypt.js +936 -936
  89. package/lib/middleware/attach-user.js +157 -157
  90. package/lib/middleware/body-parser.js +1170 -1091
  91. package/lib/middleware/bot-guard.js +178 -178
  92. package/lib/middleware/compression.js +452 -452
  93. package/lib/middleware/cors.js +314 -314
  94. package/lib/middleware/csp-nonce.js +348 -348
  95. package/lib/middleware/csrf-protect.js +316 -316
  96. package/lib/middleware/db-role-for.js +264 -264
  97. package/lib/middleware/health.js +392 -392
  98. package/lib/middleware/index.js +79 -79
  99. package/lib/middleware/rate-limit.js +358 -358
  100. package/lib/middleware/request-id.js +61 -61
  101. package/lib/middleware/request-log.js +168 -168
  102. package/lib/middleware/require-auth.js +104 -104
  103. package/lib/middleware/security-headers.js +116 -116
  104. package/lib/middleware/sse.js +166 -166
  105. package/lib/migrations.js +383 -383
  106. package/lib/mtls-ca.js +518 -518
  107. package/lib/mtls-engine-default.js +481 -481
  108. package/lib/network-dns.js +632 -632
  109. package/lib/network-heartbeat.js +290 -290
  110. package/lib/network-nts.js +574 -574
  111. package/lib/network-proxy.js +265 -265
  112. package/lib/network-tls.js +328 -328
  113. package/lib/network.js +233 -233
  114. package/lib/notify.js +612 -612
  115. package/lib/ntp-check.js +229 -229
  116. package/lib/numeric-bounds.js +111 -91
  117. package/lib/object-store/azure-blob-bucket-ops.js +349 -349
  118. package/lib/object-store/azure-blob.js +488 -488
  119. package/lib/object-store/gcs-bucket-ops.js +351 -351
  120. package/lib/object-store/gcs.js +519 -519
  121. package/lib/object-store/http-put.js +153 -153
  122. package/lib/object-store/index.js +197 -197
  123. package/lib/object-store/sigv4-bucket-ops.js +1092 -1092
  124. package/lib/object-store/sigv4.js +903 -903
  125. package/lib/observability.js +151 -151
  126. package/lib/otel-export.js +269 -269
  127. package/lib/pagination.js +464 -464
  128. package/lib/parsers/index.js +80 -80
  129. package/lib/parsers/safe-env.js +642 -642
  130. package/lib/parsers/safe-ini.js +292 -292
  131. package/lib/parsers/safe-toml.js +784 -784
  132. package/lib/parsers/safe-xml.js +390 -390
  133. package/lib/parsers/safe-yaml.js +1015 -1015
  134. package/lib/permissions.js +708 -708
  135. package/lib/pqc-agent.js +87 -87
  136. package/lib/pqc-gate.js +279 -279
  137. package/lib/protobuf-encoder.js +190 -190
  138. package/lib/protocol-dispatcher.js +161 -161
  139. package/lib/pubsub-redis.js +167 -167
  140. package/lib/pubsub.js +429 -429
  141. package/lib/queue-local.js +476 -476
  142. package/lib/queue-redis.js +745 -745
  143. package/lib/queue-sqs.js +319 -319
  144. package/lib/queue.js +695 -695
  145. package/lib/redis-client.js +519 -519
  146. package/lib/request-helpers.js +340 -340
  147. package/lib/restore-bundle.js +237 -237
  148. package/lib/restore-rollback.js +259 -259
  149. package/lib/restore.js +409 -409
  150. package/lib/retry.js +376 -376
  151. package/lib/router.js +748 -748
  152. package/lib/safe-async.js +735 -735
  153. package/lib/safe-buffer.js +237 -237
  154. package/lib/safe-json.js +541 -541
  155. package/lib/safe-schema.js +1266 -1266
  156. package/lib/safe-url.js +159 -159
  157. package/lib/scheduler.js +706 -706
  158. package/lib/security-assert.js +373 -373
  159. package/lib/seeders.js +618 -618
  160. package/lib/session.js +478 -478
  161. package/lib/slug.js +269 -269
  162. package/lib/ssrf-guard.js +401 -401
  163. package/lib/static.js +184 -4
  164. package/lib/storage.js +471 -471
  165. package/lib/subject.js +281 -281
  166. package/lib/template.js +791 -791
  167. package/lib/testing.js +798 -798
  168. package/lib/time.js +310 -310
  169. package/lib/totp.js +302 -302
  170. package/lib/tracing.js +494 -494
  171. package/lib/uuid.js +132 -132
  172. package/lib/validate-opts.js +340 -319
  173. package/lib/vault/index.js +308 -308
  174. package/lib/vault/rotate.js +784 -784
  175. package/lib/vault/wrap.js +296 -296
  176. package/lib/vendor/noble-ciphers.cjs +9 -9
  177. package/lib/webhook.js +595 -595
  178. package/lib/websocket.js +1048 -1048
  179. package/package.json +77 -77
  180. package/sbom.cyclonedx.json +7 -7
@@ -1,421 +1,420 @@
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
- * Throw at declareView() call time on:
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 validateOpts = require("./validate-opts");
65
- var { defineClass } = require("./framework-error");
66
-
67
- var DeclareViewError = defineClass("DeclareViewError", { alwaysPermanent: true });
68
-
69
- var ALLOWED_OPTS = [
70
- "schema", "name", "source",
71
- "redactColumns", "sealedColumns", "hashColumns",
72
- "whereClause", "grantTo", "backend",
73
- ];
74
-
75
- function _err(code, message) {
76
- return new DeclareViewError(code, message);
77
- }
78
-
79
- function _validateIdent(where, value) {
80
- try {
81
- safeSql.validateIdentifier(value, { allowReserved: true });
82
- } catch (e) {
83
- throw _err("declare-view/bad-identifier",
84
- where + ": invalid identifier '" + value + "': " + ((e && e.message) || String(e)));
85
- }
86
- }
87
-
88
- function _validateStringArray(where, arr, optional) {
89
- if (arr === undefined || arr === null) {
90
- if (optional) return [];
91
- throw _err("declare-view/missing-opt", where + " is required");
92
- }
93
- if (!Array.isArray(arr)) {
94
- throw _err("declare-view/bad-type", where + " must be an array of strings");
95
- }
96
- for (var i = 0; i < arr.length; i++) {
97
- if (typeof arr[i] !== "string" || arr[i].length === 0) {
98
- throw _err("declare-view/bad-entry",
99
- where + "[" + i + "] must be a non-empty string");
100
- }
101
- }
102
- return arr.slice();
103
- }
104
-
105
- function _parseSource(source) {
106
- // Accepts "schema.table" or ["schema", "table"] or "table" (defaults to public).
107
- var parts;
108
- if (typeof source === "string") {
109
- if (source.length === 0) {
110
- throw _err("declare-view/bad-source", "source must be a non-empty string or array");
111
- }
112
- parts = source.split(".");
113
- } else if (Array.isArray(source)) {
114
- parts = source.slice();
115
- } else {
116
- throw _err("declare-view/bad-source",
117
- "source must be a string ('schema.table') or array (['schema','table']), got " + typeof source);
118
- }
119
- if (parts.length === 1) parts = ["public", parts[0]];
120
- if (parts.length !== 2) {
121
- throw _err("declare-view/bad-source",
122
- "source must resolve to two segments [schema, table]; got " + parts.length + " segment(s)");
123
- }
124
- for (var i = 0; i < parts.length; i++) _validateIdent("source[" + i + "]", parts[i]);
125
- return { schema: parts[0], name: parts[1] };
126
- }
127
-
128
- function _validateOpts(opts) {
129
- if (!opts || typeof opts !== "object") {
130
- throw _err("declare-view/bad-opts", "declareView requires an opts object");
131
- }
132
- for (var k in opts) {
133
- if (Object.prototype.hasOwnProperty.call(opts, k) && ALLOWED_OPTS.indexOf(k) === -1) {
134
- throw _err("declare-view/unknown-opt",
135
- "unknown opt '" + k + "'. Allowed: " + ALLOWED_OPTS.join(", "));
136
- }
137
- }
138
-
139
- validateOpts.requireNonEmptyString(opts.schema, "schema", DeclareViewError, "declare-view/missing-opt");
140
- _validateIdent("schema", opts.schema);
141
-
142
- validateOpts.requireNonEmptyString(opts.name, "name", DeclareViewError, "declare-view/missing-opt");
143
- _validateIdent("name", opts.name);
144
-
145
- if (opts.source === undefined) {
146
- throw _err("declare-view/missing-opt", "source is required");
147
- }
148
- var src = _parseSource(opts.source);
149
-
150
- var redactColumns = _validateStringArray("redactColumns", opts.redactColumns, true);
151
- for (var i = 0; i < redactColumns.length; i++) {
152
- _validateIdent("redactColumns[" + i + "]", redactColumns[i]);
153
- }
154
-
155
- var sealedColumns = _validateStringArray("sealedColumns", opts.sealedColumns, true);
156
- for (var j = 0; j < sealedColumns.length; j++) {
157
- _validateIdent("sealedColumns[" + j + "]", sealedColumns[j]);
158
- }
159
-
160
- var hashColumns = {};
161
- if (opts.hashColumns !== undefined && opts.hashColumns !== null) {
162
- if (typeof opts.hashColumns !== "object" || Array.isArray(opts.hashColumns)) {
163
- throw _err("declare-view/bad-type",
164
- "hashColumns must be an object { aliasOrHashCol: srcCol }");
165
- }
166
- for (var hc in opts.hashColumns) {
167
- if (!Object.prototype.hasOwnProperty.call(opts.hashColumns, hc)) continue;
168
- _validateIdent("hashColumns key '" + hc + "'", hc);
169
- var v = opts.hashColumns[hc];
170
- if (typeof v !== "string" || v.length === 0) {
171
- throw _err("declare-view/bad-entry",
172
- "hashColumns['" + hc + "'] must be a non-empty string (the source plaintext column)");
173
- }
174
- _validateIdent("hashColumns['" + hc + "']", v);
175
- hashColumns[hc] = v;
176
- }
177
- }
178
-
179
- var whereClause = null;
180
- if (opts.whereClause !== undefined && opts.whereClause !== null) {
181
- if (typeof opts.whereClause !== "string") {
182
- throw _err("declare-view/bad-type", "whereClause must be a string");
183
- }
184
- if (opts.whereClause.indexOf(";") !== -1) {
185
- throw _err("declare-view/bad-where",
186
- "whereClause must not contain ';' — use a single boolean expression");
187
- }
188
- whereClause = opts.whereClause;
189
- }
190
-
191
- var grantTo = _validateStringArray("grantTo", opts.grantTo, true);
192
- for (var g = 0; g < grantTo.length; g++) {
193
- _validateIdent("grantTo[" + g + "]", grantTo[g]);
194
- }
195
-
196
- if (opts.backend !== undefined && opts.backend !== null) {
197
- if (typeof opts.backend !== "string" || opts.backend.length === 0) {
198
- throw _err("declare-view/bad-type", "backend must be a non-empty string");
199
- }
200
- }
201
-
202
- return {
203
- schema: opts.schema,
204
- name: opts.name,
205
- source: src,
206
- redactColumns: redactColumns,
207
- sealedColumns: sealedColumns,
208
- hashColumns: hashColumns,
209
- whereClause: whereClause,
210
- grantTo: grantTo,
211
- backend: opts.backend || null,
212
- };
213
- }
214
-
215
- // ---- Apply-time helpers (run inside up()) ----
216
-
217
- async function _fetchSourceColumns(xdb, schema, table) {
218
- var res = await xdb.query(
219
- "SELECT column_name FROM information_schema.columns " +
220
- "WHERE table_schema = $1 AND table_name = $2 " +
221
- "ORDER BY ordinal_position ASC",
222
- [schema, table]
223
- );
224
- var rows = (res && res.rows) || [];
225
- return rows.map(function (r) { return r.column_name; });
226
- }
227
-
228
- async function _fetchExistingRoles(xdb, names) {
229
- if (names.length === 0) return new Set();
230
- var placeholders = names.map(function (_, i) { return "$" + (i + 1); }).join(", ");
231
- var res = await xdb.query(
232
- "SELECT rolname FROM pg_roles WHERE rolname IN (" + placeholders + ")",
233
- names
234
- );
235
- var rows = (res && res.rows) || [];
236
- return new Set(rows.map(function (r) { return r.rolname; }));
237
- }
238
-
239
- function _missing(required, available) {
240
- var miss = [];
241
- for (var i = 0; i < required.length; i++) {
242
- if (available.indexOf(required[i]) === -1) miss.push(required[i]);
243
- }
244
- return miss;
245
- }
246
-
247
- function _buildSelectColumnList(sourceCols, spec) {
248
- // Drop set: redactColumns ∩ source, sealedColumns ∩ source, hashColumns.values ∩ source.
249
- var dropSet = Object.create(null);
250
- for (var i = 0; i < spec.redactColumns.length; i++) dropSet[spec.redactColumns[i]] = true;
251
- for (var j = 0; j < spec.sealedColumns.length; j++) dropSet[spec.sealedColumns[j]] = true;
252
- for (var hc in spec.hashColumns) dropSet[spec.hashColumns[hc]] = true;
253
-
254
- var kept = [];
255
- for (var k = 0; k < sourceCols.length; k++) {
256
- if (!dropSet[sourceCols[k]]) kept.push(sourceCols[k]);
257
- }
258
- return kept;
259
- }
260
-
261
- function _intersectInSource(list, sourceCols) {
262
- var srcSet = Object.create(null);
263
- for (var i = 0; i < sourceCols.length; i++) srcSet[sourceCols[i]] = true;
264
- var out = [];
265
- for (var j = 0; j < list.length; j++) {
266
- if (srcSet[list[j]]) out.push(list[j]);
267
- }
268
- return out;
269
- }
270
-
271
- function _ensureBackendIsPostgres(externalDb, backendName) {
272
- // externalDb.listBackends() returns { name, dialect, ... } per backend.
273
- var list = externalDb.listBackends();
274
- var found = null;
275
- for (var i = 0; i < list.length; i++) {
276
- if (list[i].name === backendName) { found = list[i]; break; }
277
- }
278
- if (!found) {
279
- throw _err("declare-view/unknown-backend",
280
- "no externalDb backend named '" + backendName + "' — declared backends: " +
281
- list.map(function (b) { return b.name; }).join(", "));
282
- }
283
- if (found.dialect !== "postgres") {
284
- throw _err("declare-view/not-supported",
285
- "declareView is Postgres-only; backend '" + backendName + "' has dialect='" +
286
- found.dialect + "'. Write the view as a hand-rolled migration for this dialect.");
287
- }
288
- }
289
-
290
- // ---- The factory ----
291
-
292
- function declareView(opts) {
293
- var spec = _validateOpts(opts);
294
-
295
- // The migration shape consumed by b.externalDb.migrate. The runner
296
- // resolves the backend at apply time (operator may set spec.backend
297
- // explicitly OR rely on the migrate runner's default backend).
298
- var description = "declareView " + spec.schema + "." + spec.name;
299
- var qView = safeSql.quoteQualified([spec.schema, spec.name], "postgres");
300
- var qSource = safeSql.quoteQualified([spec.source.schema, spec.source.name], "postgres");
301
-
302
- async function up(xdb, ctx) {
303
- // Boundary throw: confirm we're on Postgres before any DDL leaves the process.
304
- if (ctx && ctx.externalDb && ctx.backendName) {
305
- _ensureBackendIsPostgres(ctx.externalDb, ctx.backendName);
306
- }
307
-
308
- // Live validation source columns + roles must exist.
309
- var sourceCols = await _fetchSourceColumns(xdb, spec.source.schema, spec.source.name);
310
- if (sourceCols.length === 0) {
311
- throw _err("declare-view/source-not-found",
312
- "source table '" + spec.source.schema + "." + spec.source.name +
313
- "' has no columns visible (does it exist? does the migration role have SELECT on information_schema.columns?)");
314
- }
315
-
316
- var missingRedact = _missing(spec.redactColumns, sourceCols);
317
- if (missingRedact.length > 0) {
318
- throw _err("declare-view/redact-not-in-source",
319
- "redactColumns [" + missingRedact.join(", ") + "] not present on source '" +
320
- spec.source.schema + "." + spec.source.name + "'");
321
- }
322
-
323
- var missingHashKeys = _missing(Object.keys(spec.hashColumns), sourceCols);
324
- if (missingHashKeys.length > 0) {
325
- throw _err("declare-view/hash-key-not-in-source",
326
- "hashColumns keys [" + missingHashKeys.join(", ") + "] not present on source '" +
327
- spec.source.schema + "." + spec.source.name +
328
- "' — declare them as derivedHashes on the source schema first");
329
- }
330
-
331
- var missingHashSrc = _missing(_objValues(spec.hashColumns), sourceCols);
332
- if (missingHashSrc.length > 0) {
333
- throw _err("declare-view/hash-source-not-in-source",
334
- "hashColumns values [" + missingHashSrc.join(", ") + "] not present on source '" +
335
- spec.source.schema + "." + spec.source.name + "'");
336
- }
337
-
338
- if (spec.grantTo.length > 0) {
339
- var existing = await _fetchExistingRoles(xdb, spec.grantTo);
340
- var missingRoles = [];
341
- for (var i = 0; i < spec.grantTo.length; i++) {
342
- if (!existing.has(spec.grantTo[i])) missingRoles.push(spec.grantTo[i]);
343
- }
344
- if (missingRoles.length > 0) {
345
- throw _err("declare-view/role-not-found",
346
- "grantTo roles [" + missingRoles.join(", ") +
347
- "] not present in pg_roles — CREATE ROLE them in an earlier migration");
348
- }
349
- }
350
-
351
- var selectedColumns = _buildSelectColumnList(sourceCols, spec);
352
- if (selectedColumns.length === 0) {
353
- throw _err("declare-view/empty-select",
354
- "view '" + spec.schema + "." + spec.name +
355
- "' would have zero columns after redact/sealed/hash exclusions — adjust the spec");
356
- }
357
-
358
- // Build CREATE VIEW. Each column is independently quoted so a
359
- // reserved-word column name (e.g. "user", "order") resolves correctly.
360
- var quotedCols = selectedColumns.map(function (c) {
361
- return safeSql.quoteIdentifier(c, "postgres");
362
- }).join(", ");
363
- var createSql = "CREATE VIEW " + qView + " AS SELECT " + quotedCols +
364
- " FROM " + qSource;
365
- if (spec.whereClause) createSql += " WHERE " + spec.whereClause;
366
-
367
- await xdb.query(createSql, []);
368
-
369
- // GRANT SELECT one statement covers all roles.
370
- if (spec.grantTo.length > 0) {
371
- var quotedRoles = spec.grantTo.map(function (r) {
372
- return safeSql.quoteIdentifier(r, "postgres");
373
- }).join(", ");
374
- await xdb.query(
375
- "GRANT SELECT ON " + qView + " TO " + quotedRoles,
376
- []
377
- );
378
- }
379
-
380
- return {
381
- view: spec.schema + "." + spec.name,
382
- source: spec.source.schema + "." + spec.source.name,
383
- selectedColumns: selectedColumns,
384
- redactedColumns: _intersectInSource(spec.redactColumns, sourceCols),
385
- autoExcludedSealed: _intersectInSource(spec.sealedColumns, sourceCols),
386
- hashedColumns: Object.assign({}, spec.hashColumns),
387
- grantedTo: spec.grantTo.slice(),
388
- };
389
- }
390
-
391
- async function down(xdb, ctx) {
392
- if (ctx && ctx.externalDb && ctx.backendName) {
393
- _ensureBackendIsPostgres(ctx.externalDb, ctx.backendName);
394
- }
395
- await xdb.query("DROP VIEW IF EXISTS " + qView, []);
396
- }
397
-
398
- return {
399
- description: description,
400
- target: "externalDb",
401
- backend: spec.backend,
402
- up: up,
403
- down: down,
404
- // Expose the validated spec for testability declareView callers
405
- // can introspect what the migration will emit without running it.
406
- _spec: spec,
407
- };
408
- }
409
-
410
- function _objValues(obj) {
411
- var out = [];
412
- for (var k in obj) {
413
- if (Object.prototype.hasOwnProperty.call(obj, k)) out.push(obj[k]);
414
- }
415
- return out;
416
- }
417
-
418
- module.exports = {
419
- declareView: declareView,
420
- DeclareViewError: DeclareViewError,
421
- };
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
+ * Throw at declareView() call time on:
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 validateOpts = require("./validate-opts");
65
+ var { defineClass } = require("./framework-error");
66
+
67
+ var DeclareViewError = defineClass("DeclareViewError", { alwaysPermanent: true });
68
+
69
+ var ALLOWED_OPTS = [
70
+ "schema", "name", "source",
71
+ "redactColumns", "sealedColumns", "hashColumns",
72
+ "whereClause", "grantTo", "backend",
73
+ ];
74
+
75
+ function _err(code, message) {
76
+ return new DeclareViewError(code, message);
77
+ }
78
+
79
+ function _validateIdent(where, value) {
80
+ try {
81
+ safeSql.validateIdentifier(value, { allowReserved: true });
82
+ } catch (e) {
83
+ throw _err("declare-view/bad-identifier",
84
+ where + ": invalid identifier '" + value + "': " + ((e && e.message) || String(e)));
85
+ }
86
+ }
87
+
88
+ function _validateStringArray(where, arr, optional) {
89
+ if (arr === undefined || arr === null) {
90
+ if (optional) return [];
91
+ throw _err("declare-view/missing-opt", where + " is required");
92
+ }
93
+ if (!Array.isArray(arr)) {
94
+ throw _err("declare-view/bad-type", where + " must be an array of strings");
95
+ }
96
+ for (var i = 0; i < arr.length; i++) {
97
+ if (typeof arr[i] !== "string" || arr[i].length === 0) {
98
+ throw _err("declare-view/bad-entry",
99
+ where + "[" + i + "] must be a non-empty string");
100
+ }
101
+ }
102
+ return arr.slice();
103
+ }
104
+
105
+ function _parseSource(source) {
106
+ // Accepts "schema.table" or ["schema", "table"] or "table" (defaults to public).
107
+ var parts;
108
+ if (typeof source === "string") {
109
+ if (source.length === 0) {
110
+ throw _err("declare-view/bad-source", "source must be a non-empty string or array");
111
+ }
112
+ parts = source.split(".");
113
+ } else if (Array.isArray(source)) {
114
+ parts = source.slice();
115
+ } else {
116
+ throw _err("declare-view/bad-source",
117
+ "source must be a string ('schema.table') or array (['schema','table']), got " + typeof source);
118
+ }
119
+ if (parts.length === 1) parts = ["public", parts[0]];
120
+ if (parts.length !== 2) {
121
+ throw _err("declare-view/bad-source",
122
+ "source must resolve to two segments [schema, table]; got " + parts.length + " segment(s)");
123
+ }
124
+ for (var i = 0; i < parts.length; i++) _validateIdent("source[" + i + "]", parts[i]);
125
+ return { schema: parts[0], name: parts[1] };
126
+ }
127
+
128
+ function _validateOpts(opts) {
129
+ if (!opts || typeof opts !== "object") {
130
+ throw _err("declare-view/bad-opts", "declareView requires an opts object");
131
+ }
132
+ for (var k in opts) {
133
+ if (Object.prototype.hasOwnProperty.call(opts, k) && ALLOWED_OPTS.indexOf(k) === -1) {
134
+ throw _err("declare-view/unknown-opt",
135
+ "unknown opt '" + k + "'. Allowed: " + ALLOWED_OPTS.join(", "));
136
+ }
137
+ }
138
+
139
+ validateOpts.requireNonEmptyString(opts.schema, "schema", DeclareViewError, "declare-view/missing-opt");
140
+ _validateIdent("schema", opts.schema);
141
+
142
+ validateOpts.requireNonEmptyString(opts.name, "name", DeclareViewError, "declare-view/missing-opt");
143
+ _validateIdent("name", opts.name);
144
+
145
+ if (opts.source === undefined) {
146
+ throw _err("declare-view/missing-opt", "source is required");
147
+ }
148
+ var src = _parseSource(opts.source);
149
+
150
+ var redactColumns = _validateStringArray("redactColumns", opts.redactColumns, true);
151
+ for (var i = 0; i < redactColumns.length; i++) {
152
+ _validateIdent("redactColumns[" + i + "]", redactColumns[i]);
153
+ }
154
+
155
+ var sealedColumns = _validateStringArray("sealedColumns", opts.sealedColumns, true);
156
+ for (var j = 0; j < sealedColumns.length; j++) {
157
+ _validateIdent("sealedColumns[" + j + "]", sealedColumns[j]);
158
+ }
159
+
160
+ var hashColumns = {};
161
+ validateOpts.optionalPlainObject(opts.hashColumns, "hashColumns",
162
+ DeclareViewError, "declare-view/bad-type",
163
+ "must be an object { aliasOrHashCol: srcCol }");
164
+ if (opts.hashColumns !== undefined && opts.hashColumns !== null) {
165
+ for (var hc in opts.hashColumns) {
166
+ if (!Object.prototype.hasOwnProperty.call(opts.hashColumns, hc)) continue;
167
+ _validateIdent("hashColumns key '" + hc + "'", hc);
168
+ var v = opts.hashColumns[hc];
169
+ if (typeof v !== "string" || v.length === 0) {
170
+ throw _err("declare-view/bad-entry",
171
+ "hashColumns['" + hc + "'] must be a non-empty string (the source plaintext column)");
172
+ }
173
+ _validateIdent("hashColumns['" + hc + "']", v);
174
+ hashColumns[hc] = v;
175
+ }
176
+ }
177
+
178
+ var whereClause = null;
179
+ if (opts.whereClause !== undefined && opts.whereClause !== null) {
180
+ if (typeof opts.whereClause !== "string") {
181
+ throw _err("declare-view/bad-type", "whereClause must be a string");
182
+ }
183
+ if (opts.whereClause.indexOf(";") !== -1) {
184
+ throw _err("declare-view/bad-where",
185
+ "whereClause must not contain ';' — use a single boolean expression");
186
+ }
187
+ whereClause = opts.whereClause;
188
+ }
189
+
190
+ var grantTo = _validateStringArray("grantTo", opts.grantTo, true);
191
+ for (var g = 0; g < grantTo.length; g++) {
192
+ _validateIdent("grantTo[" + g + "]", grantTo[g]);
193
+ }
194
+
195
+ if (opts.backend !== undefined && opts.backend !== null) {
196
+ if (typeof opts.backend !== "string" || opts.backend.length === 0) {
197
+ throw _err("declare-view/bad-type", "backend must be a non-empty string");
198
+ }
199
+ }
200
+
201
+ return {
202
+ schema: opts.schema,
203
+ name: opts.name,
204
+ source: src,
205
+ redactColumns: redactColumns,
206
+ sealedColumns: sealedColumns,
207
+ hashColumns: hashColumns,
208
+ whereClause: whereClause,
209
+ grantTo: grantTo,
210
+ backend: opts.backend || null,
211
+ };
212
+ }
213
+
214
+ // ---- Apply-time helpers (run inside up()) ----
215
+
216
+ async function _fetchSourceColumns(xdb, schema, table) {
217
+ var res = await xdb.query(
218
+ "SELECT column_name FROM information_schema.columns " +
219
+ "WHERE table_schema = $1 AND table_name = $2 " +
220
+ "ORDER BY ordinal_position ASC",
221
+ [schema, table]
222
+ );
223
+ var rows = (res && res.rows) || [];
224
+ return rows.map(function (r) { return r.column_name; });
225
+ }
226
+
227
+ async function _fetchExistingRoles(xdb, names) {
228
+ if (names.length === 0) return new Set();
229
+ var placeholders = names.map(function (_, i) { return "$" + (i + 1); }).join(", ");
230
+ var res = await xdb.query(
231
+ "SELECT rolname FROM pg_roles WHERE rolname IN (" + placeholders + ")",
232
+ names
233
+ );
234
+ var rows = (res && res.rows) || [];
235
+ return new Set(rows.map(function (r) { return r.rolname; }));
236
+ }
237
+
238
+ function _missing(required, available) {
239
+ var miss = [];
240
+ for (var i = 0; i < required.length; i++) {
241
+ if (available.indexOf(required[i]) === -1) miss.push(required[i]);
242
+ }
243
+ return miss;
244
+ }
245
+
246
+ function _buildSelectColumnList(sourceCols, spec) {
247
+ // Drop set: redactColumns ∩ source, sealedColumns ∩ source, hashColumns.values ∩ source.
248
+ var dropSet = Object.create(null);
249
+ for (var i = 0; i < spec.redactColumns.length; i++) dropSet[spec.redactColumns[i]] = true;
250
+ for (var j = 0; j < spec.sealedColumns.length; j++) dropSet[spec.sealedColumns[j]] = true;
251
+ for (var hc in spec.hashColumns) dropSet[spec.hashColumns[hc]] = true;
252
+
253
+ var kept = [];
254
+ for (var k = 0; k < sourceCols.length; k++) {
255
+ if (!dropSet[sourceCols[k]]) kept.push(sourceCols[k]);
256
+ }
257
+ return kept;
258
+ }
259
+
260
+ function _intersectInSource(list, sourceCols) {
261
+ var srcSet = Object.create(null);
262
+ for (var i = 0; i < sourceCols.length; i++) srcSet[sourceCols[i]] = true;
263
+ var out = [];
264
+ for (var j = 0; j < list.length; j++) {
265
+ if (srcSet[list[j]]) out.push(list[j]);
266
+ }
267
+ return out;
268
+ }
269
+
270
+ function _ensureBackendIsPostgres(externalDb, backendName) {
271
+ // externalDb.listBackends() returns { name, dialect, ... } per backend.
272
+ var list = externalDb.listBackends();
273
+ var found = null;
274
+ for (var i = 0; i < list.length; i++) {
275
+ if (list[i].name === backendName) { found = list[i]; break; }
276
+ }
277
+ if (!found) {
278
+ throw _err("declare-view/unknown-backend",
279
+ "no externalDb backend named '" + backendName + "' — declared backends: " +
280
+ list.map(function (b) { return b.name; }).join(", "));
281
+ }
282
+ if (found.dialect !== "postgres") {
283
+ throw _err("declare-view/not-supported",
284
+ "declareView is Postgres-only; backend '" + backendName + "' has dialect='" +
285
+ found.dialect + "'. Write the view as a hand-rolled migration for this dialect.");
286
+ }
287
+ }
288
+
289
+ // ---- The factory ----
290
+
291
+ function declareView(opts) {
292
+ var spec = _validateOpts(opts);
293
+
294
+ // The migration shape consumed by b.externalDb.migrate. The runner
295
+ // resolves the backend at apply time (operator may set spec.backend
296
+ // explicitly OR rely on the migrate runner's default backend).
297
+ var description = "declareView " + spec.schema + "." + spec.name;
298
+ var qView = safeSql.quoteQualified([spec.schema, spec.name], "postgres");
299
+ var qSource = safeSql.quoteQualified([spec.source.schema, spec.source.name], "postgres");
300
+
301
+ async function up(xdb, ctx) {
302
+ // Boundary throw: confirm we're on Postgres before any DDL leaves the process.
303
+ if (ctx && ctx.externalDb && ctx.backendName) {
304
+ _ensureBackendIsPostgres(ctx.externalDb, ctx.backendName);
305
+ }
306
+
307
+ // Live validation — source columns + roles must exist.
308
+ var sourceCols = await _fetchSourceColumns(xdb, spec.source.schema, spec.source.name);
309
+ if (sourceCols.length === 0) {
310
+ throw _err("declare-view/source-not-found",
311
+ "source table '" + spec.source.schema + "." + spec.source.name +
312
+ "' has no columns visible (does it exist? does the migration role have SELECT on information_schema.columns?)");
313
+ }
314
+
315
+ var missingRedact = _missing(spec.redactColumns, sourceCols);
316
+ if (missingRedact.length > 0) {
317
+ throw _err("declare-view/redact-not-in-source",
318
+ "redactColumns [" + missingRedact.join(", ") + "] not present on source '" +
319
+ spec.source.schema + "." + spec.source.name + "'");
320
+ }
321
+
322
+ var missingHashKeys = _missing(Object.keys(spec.hashColumns), sourceCols);
323
+ if (missingHashKeys.length > 0) {
324
+ throw _err("declare-view/hash-key-not-in-source",
325
+ "hashColumns keys [" + missingHashKeys.join(", ") + "] not present on source '" +
326
+ spec.source.schema + "." + spec.source.name +
327
+ "' declare them as derivedHashes on the source schema first");
328
+ }
329
+
330
+ var missingHashSrc = _missing(_objValues(spec.hashColumns), sourceCols);
331
+ if (missingHashSrc.length > 0) {
332
+ throw _err("declare-view/hash-source-not-in-source",
333
+ "hashColumns values [" + missingHashSrc.join(", ") + "] not present on source '" +
334
+ spec.source.schema + "." + spec.source.name + "'");
335
+ }
336
+
337
+ if (spec.grantTo.length > 0) {
338
+ var existing = await _fetchExistingRoles(xdb, spec.grantTo);
339
+ var missingRoles = [];
340
+ for (var i = 0; i < spec.grantTo.length; i++) {
341
+ if (!existing.has(spec.grantTo[i])) missingRoles.push(spec.grantTo[i]);
342
+ }
343
+ if (missingRoles.length > 0) {
344
+ throw _err("declare-view/role-not-found",
345
+ "grantTo roles [" + missingRoles.join(", ") +
346
+ "] not present in pg_roles — CREATE ROLE them in an earlier migration");
347
+ }
348
+ }
349
+
350
+ var selectedColumns = _buildSelectColumnList(sourceCols, spec);
351
+ if (selectedColumns.length === 0) {
352
+ throw _err("declare-view/empty-select",
353
+ "view '" + spec.schema + "." + spec.name +
354
+ "' would have zero columns after redact/sealed/hash exclusions — adjust the spec");
355
+ }
356
+
357
+ // Build CREATE VIEW. Each column is independently quoted so a
358
+ // reserved-word column name (e.g. "user", "order") resolves correctly.
359
+ var quotedCols = selectedColumns.map(function (c) {
360
+ return safeSql.quoteIdentifier(c, "postgres");
361
+ }).join(", ");
362
+ var createSql = "CREATE VIEW " + qView + " AS SELECT " + quotedCols +
363
+ " FROM " + qSource;
364
+ if (spec.whereClause) createSql += " WHERE " + spec.whereClause;
365
+
366
+ await xdb.query(createSql, []);
367
+
368
+ // GRANT SELECT — one statement covers all roles.
369
+ if (spec.grantTo.length > 0) {
370
+ var quotedRoles = spec.grantTo.map(function (r) {
371
+ return safeSql.quoteIdentifier(r, "postgres");
372
+ }).join(", ");
373
+ await xdb.query(
374
+ "GRANT SELECT ON " + qView + " TO " + quotedRoles,
375
+ []
376
+ );
377
+ }
378
+
379
+ return {
380
+ view: spec.schema + "." + spec.name,
381
+ source: spec.source.schema + "." + spec.source.name,
382
+ selectedColumns: selectedColumns,
383
+ redactedColumns: _intersectInSource(spec.redactColumns, sourceCols),
384
+ autoExcludedSealed: _intersectInSource(spec.sealedColumns, sourceCols),
385
+ hashedColumns: Object.assign({}, spec.hashColumns),
386
+ grantedTo: spec.grantTo.slice(),
387
+ };
388
+ }
389
+
390
+ async function down(xdb, ctx) {
391
+ if (ctx && ctx.externalDb && ctx.backendName) {
392
+ _ensureBackendIsPostgres(ctx.externalDb, ctx.backendName);
393
+ }
394
+ await xdb.query("DROP VIEW IF EXISTS " + qView, []);
395
+ }
396
+
397
+ return {
398
+ description: description,
399
+ target: "externalDb",
400
+ backend: spec.backend,
401
+ up: up,
402
+ down: down,
403
+ // Expose the validated spec for testability — declareView callers
404
+ // can introspect what the migration will emit without running it.
405
+ _spec: spec,
406
+ };
407
+ }
408
+
409
+ function _objValues(obj) {
410
+ var out = [];
411
+ for (var k in obj) {
412
+ if (Object.prototype.hasOwnProperty.call(obj, k)) out.push(obj[k]);
413
+ }
414
+ return out;
415
+ }
416
+
417
+ module.exports = {
418
+ declareView: declareView,
419
+ DeclareViewError: DeclareViewError,
420
+ };