@blamejs/core 0.8.43 → 0.8.49

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 (222) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/README.md +10 -10
  3. package/index.js +52 -0
  4. package/lib/a2a.js +159 -34
  5. package/lib/acme.js +762 -0
  6. package/lib/ai-pref.js +166 -43
  7. package/lib/api-key.js +108 -47
  8. package/lib/api-snapshot.js +157 -40
  9. package/lib/app-shutdown.js +113 -77
  10. package/lib/archive.js +337 -40
  11. package/lib/arg-parser.js +697 -0
  12. package/lib/asyncapi.js +99 -55
  13. package/lib/atomic-file.js +465 -104
  14. package/lib/audit-chain.js +123 -34
  15. package/lib/audit-daily-review.js +389 -0
  16. package/lib/audit-sign.js +302 -56
  17. package/lib/audit-tools.js +412 -63
  18. package/lib/audit.js +656 -35
  19. package/lib/auth/jwt-external.js +17 -0
  20. package/lib/auth/oauth.js +7 -0
  21. package/lib/auth-bot-challenge.js +505 -0
  22. package/lib/auth-header.js +92 -25
  23. package/lib/backup/bundle.js +26 -0
  24. package/lib/backup/index.js +512 -89
  25. package/lib/backup/manifest.js +168 -7
  26. package/lib/break-glass.js +415 -39
  27. package/lib/budr.js +103 -30
  28. package/lib/bundler.js +86 -66
  29. package/lib/cache.js +192 -72
  30. package/lib/chain-writer.js +65 -40
  31. package/lib/circuit-breaker.js +56 -33
  32. package/lib/cli-helpers.js +106 -75
  33. package/lib/cli.js +6 -30
  34. package/lib/cloud-events.js +99 -32
  35. package/lib/cluster-storage.js +162 -37
  36. package/lib/cluster.js +340 -49
  37. package/lib/codepoint-class.js +66 -0
  38. package/lib/compliance.js +424 -24
  39. package/lib/config-drift.js +111 -46
  40. package/lib/config.js +94 -40
  41. package/lib/consent.js +165 -18
  42. package/lib/constants.js +1 -0
  43. package/lib/content-credentials.js +153 -48
  44. package/lib/cookies.js +154 -62
  45. package/lib/credential-hash.js +133 -61
  46. package/lib/crypto-field.js +702 -18
  47. package/lib/crypto-hpke.js +256 -0
  48. package/lib/crypto.js +744 -22
  49. package/lib/csv.js +178 -35
  50. package/lib/daemon.js +456 -0
  51. package/lib/dark-patterns.js +186 -55
  52. package/lib/db-query.js +79 -2
  53. package/lib/db.js +1431 -60
  54. package/lib/ddl-change-control.js +523 -0
  55. package/lib/deprecate.js +195 -40
  56. package/lib/dev.js +82 -39
  57. package/lib/dora.js +67 -48
  58. package/lib/dr-runbook.js +368 -0
  59. package/lib/dsr.js +142 -11
  60. package/lib/dual-control.js +91 -56
  61. package/lib/events.js +120 -41
  62. package/lib/external-db-migrate.js +192 -2
  63. package/lib/external-db.js +795 -50
  64. package/lib/fapi2.js +122 -1
  65. package/lib/fda-21cfr11.js +395 -0
  66. package/lib/fdx.js +132 -2
  67. package/lib/file-type.js +87 -0
  68. package/lib/file-upload.js +93 -0
  69. package/lib/flag.js +82 -20
  70. package/lib/forms.js +132 -29
  71. package/lib/framework-error.js +169 -0
  72. package/lib/framework-schema.js +163 -35
  73. package/lib/gate-contract.js +849 -175
  74. package/lib/graphql-federation.js +68 -7
  75. package/lib/guard-all.js +172 -55
  76. package/lib/guard-archive.js +286 -124
  77. package/lib/guard-auth.js +194 -21
  78. package/lib/guard-cidr.js +190 -28
  79. package/lib/guard-csv.js +397 -51
  80. package/lib/guard-domain.js +213 -91
  81. package/lib/guard-email.js +236 -29
  82. package/lib/guard-filename.js +307 -75
  83. package/lib/guard-graphql.js +263 -30
  84. package/lib/guard-html.js +310 -116
  85. package/lib/guard-image.js +243 -30
  86. package/lib/guard-json.js +260 -54
  87. package/lib/guard-jsonpath.js +235 -23
  88. package/lib/guard-jwt.js +284 -30
  89. package/lib/guard-markdown.js +204 -22
  90. package/lib/guard-mime.js +190 -26
  91. package/lib/guard-oauth.js +277 -28
  92. package/lib/guard-pdf.js +251 -27
  93. package/lib/guard-regex.js +226 -18
  94. package/lib/guard-shell.js +229 -26
  95. package/lib/guard-svg.js +177 -10
  96. package/lib/guard-template.js +232 -21
  97. package/lib/guard-time.js +195 -29
  98. package/lib/guard-uuid.js +189 -30
  99. package/lib/guard-xml.js +259 -36
  100. package/lib/guard-yaml.js +241 -44
  101. package/lib/honeytoken.js +63 -27
  102. package/lib/html-balance.js +83 -0
  103. package/lib/http-client.js +486 -59
  104. package/lib/http-message-signature.js +582 -0
  105. package/lib/i18n.js +102 -49
  106. package/lib/iab-mspa.js +112 -32
  107. package/lib/iab-tcf.js +107 -2
  108. package/lib/inbox.js +90 -52
  109. package/lib/keychain.js +865 -0
  110. package/lib/legal-hold.js +374 -0
  111. package/lib/local-db-thin.js +320 -0
  112. package/lib/log-stream.js +281 -51
  113. package/lib/log.js +184 -86
  114. package/lib/mail-bounce.js +107 -62
  115. package/lib/mail.js +295 -58
  116. package/lib/mcp.js +108 -27
  117. package/lib/metrics.js +98 -89
  118. package/lib/middleware/age-gate.js +36 -0
  119. package/lib/middleware/ai-act-disclosure.js +37 -0
  120. package/lib/middleware/api-encrypt.js +45 -0
  121. package/lib/middleware/assetlinks.js +40 -0
  122. package/lib/middleware/asyncapi-serve.js +35 -0
  123. package/lib/middleware/attach-user.js +40 -0
  124. package/lib/middleware/bearer-auth.js +40 -0
  125. package/lib/middleware/body-parser.js +230 -0
  126. package/lib/middleware/bot-disclose.js +34 -0
  127. package/lib/middleware/bot-guard.js +39 -0
  128. package/lib/middleware/compression.js +37 -0
  129. package/lib/middleware/cookies.js +32 -0
  130. package/lib/middleware/cors.js +40 -0
  131. package/lib/middleware/csp-nonce.js +40 -0
  132. package/lib/middleware/csp-report.js +34 -0
  133. package/lib/middleware/csrf-protect.js +43 -0
  134. package/lib/middleware/daily-byte-quota.js +53 -85
  135. package/lib/middleware/db-role-for.js +40 -0
  136. package/lib/middleware/dpop.js +40 -0
  137. package/lib/middleware/error-handler.js +37 -14
  138. package/lib/middleware/fetch-metadata.js +39 -0
  139. package/lib/middleware/flag-context.js +34 -0
  140. package/lib/middleware/gpc.js +33 -0
  141. package/lib/middleware/headers.js +35 -0
  142. package/lib/middleware/health.js +46 -0
  143. package/lib/middleware/host-allowlist.js +30 -0
  144. package/lib/middleware/network-allowlist.js +38 -0
  145. package/lib/middleware/openapi-serve.js +34 -0
  146. package/lib/middleware/rate-limit.js +160 -18
  147. package/lib/middleware/request-id.js +36 -18
  148. package/lib/middleware/request-log.js +37 -0
  149. package/lib/middleware/require-aal.js +29 -0
  150. package/lib/middleware/require-auth.js +32 -0
  151. package/lib/middleware/require-bound-key.js +41 -0
  152. package/lib/middleware/require-content-type.js +32 -0
  153. package/lib/middleware/require-methods.js +27 -0
  154. package/lib/middleware/require-mtls.js +33 -0
  155. package/lib/middleware/require-step-up.js +37 -0
  156. package/lib/middleware/security-headers.js +44 -0
  157. package/lib/middleware/security-txt.js +38 -0
  158. package/lib/middleware/span-http-server.js +37 -0
  159. package/lib/middleware/sse.js +36 -0
  160. package/lib/middleware/trace-log-correlation.js +33 -0
  161. package/lib/middleware/trace-propagate.js +32 -0
  162. package/lib/middleware/tus-upload.js +90 -0
  163. package/lib/middleware/web-app-manifest.js +53 -0
  164. package/lib/mtls-ca.js +100 -70
  165. package/lib/network-byte-quota.js +308 -0
  166. package/lib/network-heartbeat.js +135 -0
  167. package/lib/network-tls.js +534 -4
  168. package/lib/network.js +103 -0
  169. package/lib/notify.js +114 -43
  170. package/lib/ntp-check.js +192 -51
  171. package/lib/observability.js +145 -47
  172. package/lib/openapi.js +90 -44
  173. package/lib/outbox.js +99 -1
  174. package/lib/pagination.js +168 -86
  175. package/lib/parsers/index.js +16 -5
  176. package/lib/permissions.js +93 -40
  177. package/lib/pqc-agent.js +84 -8
  178. package/lib/pqc-software.js +94 -60
  179. package/lib/process-spawn.js +95 -21
  180. package/lib/pubsub.js +96 -66
  181. package/lib/queue.js +375 -54
  182. package/lib/redact.js +793 -21
  183. package/lib/render.js +139 -47
  184. package/lib/request-helpers.js +485 -121
  185. package/lib/restore-bundle.js +142 -39
  186. package/lib/restore-rollback.js +136 -45
  187. package/lib/retention.js +178 -50
  188. package/lib/retry.js +116 -33
  189. package/lib/router.js +475 -23
  190. package/lib/safe-async.js +543 -94
  191. package/lib/safe-buffer.js +337 -41
  192. package/lib/safe-json.js +467 -62
  193. package/lib/safe-jsonpath.js +285 -0
  194. package/lib/safe-schema.js +631 -87
  195. package/lib/safe-sql.js +221 -59
  196. package/lib/safe-url.js +278 -46
  197. package/lib/sandbox-worker.js +135 -0
  198. package/lib/sandbox.js +358 -0
  199. package/lib/scheduler.js +135 -70
  200. package/lib/self-update.js +647 -0
  201. package/lib/session-device-binding.js +431 -0
  202. package/lib/session.js +259 -49
  203. package/lib/slug.js +138 -26
  204. package/lib/ssrf-guard.js +316 -56
  205. package/lib/storage.js +433 -70
  206. package/lib/subject.js +405 -23
  207. package/lib/template.js +148 -8
  208. package/lib/tenant-quota.js +545 -0
  209. package/lib/testing.js +440 -53
  210. package/lib/time.js +291 -23
  211. package/lib/tls-exporter.js +239 -0
  212. package/lib/tracing.js +90 -74
  213. package/lib/uuid.js +97 -22
  214. package/lib/vault/index.js +284 -22
  215. package/lib/vault/seal-pem-file.js +66 -0
  216. package/lib/watcher.js +368 -0
  217. package/lib/webhook.js +196 -63
  218. package/lib/websocket.js +393 -68
  219. package/lib/wiki-concepts.js +338 -0
  220. package/lib/worker-pool.js +464 -0
  221. package/package.json +3 -3
  222. package/sbom.cyclonedx.json +7 -7
@@ -1,54 +1,39 @@
1
1
  "use strict";
2
2
  /**
3
- * External database service — pluggable wrapper for app-data DB connections.
3
+ * @module b.externalDb
4
+ * @nav Data
5
+ * @title External Database
4
6
  *
5
- * Framework state (audit_log, consent_log, _blamejs_*) stays in the local
6
- * SQLite via b.db. This module is for APP DATAwhen an operator wants to
7
- * keep their app's domain tables in Postgres / MySQL / MongoDB / libsql /
8
- * etc., they configure a backend here and use b.externalDb.query() instead
9
- * of b.db.from() for those tables.
7
+ * @intro
8
+ * External-database integration for app dataPostgres / MySQL /
9
+ * SQLite / MongoDB connection pooling, retry, circuit breaker,
10
+ * classification routing, residency enforcement, and audit hooks.
10
11
  *
11
- * Bring-your-own-client design (per "zero npm runtime deps" rule):
12
- * The operator supplies the actual DB driver via the backend's connect/
13
- * query/close functions. The framework adds:
14
- * - Connection pooling (lazy-create, reuse across queries)
15
- * - Retry on transient errors (5xx-equivalent + network)
16
- * - Circuit breaker per-backend
17
- * - Classification routing (which backend serves which data class)
18
- * - Residency enforcement (boot-time validation against
19
- * db.getDataResidency().region)
20
- * - Audit hooks (system.externaldb.{query,transaction,connect.failure})
12
+ * Framework state (audit_log, consent_log, _blamejs_*) stays in the
13
+ * local SQLite via `b.db`. This module is for APP DATA — when an
14
+ * operator keeps domain tables in Postgres / MySQL / MongoDB / libsql,
15
+ * they configure a backend here and use `b.externalDb.query()` instead
16
+ * of `b.db.from()` for those tables. The same surface also serves
17
+ * cluster-mode coordination (leader election advisory locks,
18
+ * cross-replica routing) when the cluster provider points at the same
19
+ * backend.
21
20
  *
22
- * Built-in protocol adapters (native pg-wire, libsql-HTTP, MongoDB wire)
23
- * are not currently bundled operators supply `connect`/`query`/`close`
24
- * directly using their wire client of choice. When framework-bundled
25
- * adapters land they will be available as `b.externalDb.adapters.pg`,
26
- * `.libsqlHttp`, etc., but the bring-your-own-client API is the
27
- * permanent surface.
21
+ * Bring-your-own-client design (per "zero npm runtime deps" rule):
22
+ * the operator supplies the actual DB driver via each backend's
23
+ * `connect` / `query` / `close` hooks. The framework layers
24
+ * connection pooling (lazy-create, idle reaping), transient-error
25
+ * retry, per-backend circuit breaker, classification routing
26
+ * (which backend serves which data class), residency enforcement
27
+ * against `db.getDataResidency().region`, and audit hooks
28
+ * (`system.externaldb.{query,transaction,read}`).
28
29
  *
29
- * Public API:
30
- * externalDb.init({ backends: { name: { connect, query, close?, ... } },
31
- * defaultBackend? })
32
- * externalDb.query(sql, params?, opts?) → { rows, rowCount }
33
- * externalDb.transaction(fn, opts?) → fn's return value
34
- * externalDb.healthCheck(backendName?) → backend status
35
- * externalDb.listBackends()
36
- * externalDb.shutdown()
30
+ * Read-replica routing exposes `b.externalDb.read.query()` and
31
+ * `b.externalDb.write.query()` reads weight-round-robin across
32
+ * declared replicas with health tracking and primary fallback;
33
+ * writes always route to primary.
37
34
  *
38
- * Backend config:
39
- * {
40
- * connect(): async () → client (returns operator's DB client)
41
- * query(client, sql, params): async → { rows, rowCount }
42
- * close(client): async → void
43
- * ping(client): async → bool (optional health check)
44
- * beginTx(client): async → void (optional; default 'BEGIN')
45
- * commit(client): async → void (optional; default 'COMMIT')
46
- * rollback(client): async → void (optional; default 'ROLLBACK')
47
- * pool: { min: 1, max: 10, idleTimeoutMs: C.TIME.minutes(1) }
48
- * classifications: ['personal' | 'operational' | 'public' | <custom>]
49
- * residencyTag: 'EU' | 'US' | ...
50
- * retry, breaker
51
- * }
35
+ * @card
36
+ * External-database integration for app data — Postgres / MySQL / SQLite / MongoDB connection pooling, retry, circuit breaker, classification routing, residency enforcement, and audit hooks.
52
37
  */
53
38
  var retryHelper = require("./retry");
54
39
  var C = require("./constants");
@@ -71,6 +56,92 @@ function _emitMetric(name, value, labels) {
71
56
  catch (_e) { /* hot-path observability sink — drop silent by design */ }
72
57
  }
73
58
 
59
+ // Statement-class classifier for auth-failure forensics (D-M2). Inspects
60
+ // the leading keyword only so an attacker-controlled trailing fragment
61
+ // can't smuggle a false classification. Skips leading whitespace plus
62
+ // SQL line / block comments before reading the keyword.
63
+ var _STATEMENT_CLASS_RE = /^\s*(?:\/\*[\s\S]*?\*\/\s*|--[^\n]*\n\s*)*([A-Za-z]+)/;
64
+ var _STATEMENT_CLASS_MAP = Object.freeze({
65
+ SELECT: "SELECT", WITH: "SELECT", VALUES: "SELECT", TABLE: "SELECT",
66
+ INSERT: "DML", UPDATE: "DML", DELETE: "DML", MERGE: "DML", UPSERT: "DML",
67
+ CREATE: "DDL", DROP: "DDL", ALTER: "DDL", TRUNCATE: "DDL",
68
+ RENAME: "DDL", COMMENT: "DDL",
69
+ GRANT: "DCL", REVOKE: "DCL",
70
+ SET: "SESSION", RESET: "SESSION",
71
+ BEGIN: "TX", START: "TX", COMMIT: "TX", ROLLBACK: "TX",
72
+ SAVEPOINT: "TX", RELEASE: "TX",
73
+ CALL: "ROUTINE", EXECUTE: "ROUTINE",
74
+ COPY: "BULK",
75
+ EXPLAIN: "META", ANALYZE: "META", VACUUM: "META",
76
+ });
77
+
78
+ function _classifyStatement(sql) {
79
+ if (typeof sql !== "string" || sql.length === 0) return "UNKNOWN";
80
+ var m = _STATEMENT_CLASS_RE.exec(sql);
81
+ if (!m) return "UNKNOWN";
82
+ return _STATEMENT_CLASS_MAP[m[1].toUpperCase()] || "OTHER";
83
+ }
84
+
85
+ // Postgres SQLSTATE classes that indicate authentication / authorization
86
+ // failure at the DB level. SOC2 forensic gap (D-M2) — every match emits
87
+ // db.auth.failed with the SQL identity attempted, the database, and
88
+ // the statement class.
89
+ var _AUTH_FAILURE_CODES = Object.freeze({
90
+ "28000": "invalid_authorization_specification",
91
+ "28P01": "invalid_password",
92
+ "42501": "insufficient_privilege",
93
+ });
94
+
95
+ function _emitAuthFailureAudit(backend, role, sql, e) {
96
+ if (!e || !e.code) return;
97
+ var kind = _AUTH_FAILURE_CODES[e.code];
98
+ if (!kind) return;
99
+ audit().safeEmit({
100
+ action: "db.auth.failed",
101
+ actor: {},
102
+ resource: { kind: "db.backend", id: backend.name },
103
+ outcome: "denied",
104
+ reason: kind,
105
+ metadata: {
106
+ backend: backend.name,
107
+ dialect: backend.dialect,
108
+ sqlIdentity: role || null,
109
+ sqlstate: e.code,
110
+ statementClass: _classifyStatement(sql),
111
+ },
112
+ });
113
+ _emitMetric("db.auth.failed", 1, {
114
+ backend: backend.name,
115
+ sqlstate: e.code,
116
+ statementClass: _classifyStatement(sql),
117
+ });
118
+ }
119
+
120
+ // Slow-query bucket emitter (D-L7). Single-shot per query — highest
121
+ // matched bucket wins. Operators dashboard on the `bucket` label
122
+ // rather than separate counters per threshold.
123
+ var _SLOW_QUERY_BUCKETS = Object.freeze([
124
+ { ms: C.TIME.seconds(30), label: "30s" },
125
+ { ms: C.TIME.seconds(5), label: "5s" },
126
+ { ms: C.TIME.seconds(1), label: "1s" },
127
+ ]);
128
+
129
+ function _emitSlowQuery(backendName, role, durationMs, statementClass) {
130
+ if (typeof durationMs !== "number" || !isFinite(durationMs)) return;
131
+ for (var i = 0; i < _SLOW_QUERY_BUCKETS.length; i++) {
132
+ var bucket = _SLOW_QUERY_BUCKETS[i];
133
+ if (durationMs >= bucket.ms) {
134
+ _emitMetric("db.query.slow", durationMs, {
135
+ backend: backendName,
136
+ role: role || "(none)",
137
+ bucket: bucket.label,
138
+ statementClass: statementClass || "UNKNOWN",
139
+ });
140
+ return;
141
+ }
142
+ }
143
+ }
144
+
74
145
  var _err = ExternalDbError.factory;
75
146
 
76
147
  var initialized = false;
@@ -184,6 +255,64 @@ class Pool {
184
255
 
185
256
  // ---- Init ----
186
257
 
258
+ /**
259
+ * @primitive b.externalDb.init
260
+ * @signature b.externalDb.init(opts)
261
+ * @since 0.4.0
262
+ * @related b.externalDb.query, b.externalDb.shutdown, b.externalDb.adapters.connectAs
263
+ *
264
+ * Register one or more app-data backends. Each backend declares its
265
+ * `connect` / `query` driver hooks plus optional pooling, classification,
266
+ * residency, retry, and replica configuration. Throws synchronously on
267
+ * malformed input (missing hooks, unknown dialect, residency mismatch
268
+ * against `db.getDataResidency()`, dotted GUC names that fail
269
+ * identifier validation).
270
+ *
271
+ * Boot-time residency check: when `db.getDataResidency().region` is set,
272
+ * any backend serving `personal` (or `*`) data must carry a
273
+ * `residencyTag` in the allowed-region list — refused with
274
+ * `RESIDENCY_VIOLATION` when not.
275
+ *
276
+ * @opts
277
+ * backends: { [name]: BackendConfig }, // required; one or more named backends
278
+ * defaultBackend?: string, // pool used when no opts.backend / classification / role match (defaults to first)
279
+ * dbRoleBackends?: { [sqlRole]: backendName }, // request-time role → backend mapping for the dbRoleFor middleware
280
+ *
281
+ * // BackendConfig shape:
282
+ * // connect(): async () → driver client (required)
283
+ * // query(client, sql, p): async → { rows, rowCount } (required)
284
+ * // close(client): async → void (optional; default no-op)
285
+ * // ping(client): async → void (optional; default `SELECT 1`)
286
+ * // beginTx / commit / rollback(client): async → void (optional; default `BEGIN`/`COMMIT`/`ROLLBACK`)
287
+ * // dialect: "postgres" | "mysql" | "sqlite" | "mongodb" | "other" (default "postgres")
288
+ * // applicationName: string ≤ 63 bytes, no CR/LF/NUL (Postgres pg_stat_activity tag; default null)
289
+ * // pool: { min, max, idleTimeoutMs } (defaults: 1 / 10 / C.TIME.minutes(1))
290
+ * // classifications: string[] (defaults to ["*"])
291
+ * // residencyTag: "EU" | "US" | "unrestricted" | ... (defaults to "unrestricted")
292
+ * // retry, breaker: passthrough to b.retry / CircuitBreaker
293
+ * // replicas: [{ connect, query, weight?, residencyTag?, allowCrossBorder? }]
294
+ * // replicaFallbackToPrimary: boolean (default true)
295
+ *
296
+ * @example
297
+ * var pg = require("pg");
298
+ * var pool = new pg.Pool({ connectionString: "postgres://app:pw@db.example.com/app" });
299
+ *
300
+ * b.externalDb.init({
301
+ * backends: {
302
+ * main: {
303
+ * dialect: "postgres",
304
+ * applicationName: "blamejs-app",
305
+ * connect: function () { return pool.connect(); },
306
+ * query: function (client, sql, params) { return client.query(sql, params); },
307
+ * close: function (client) { return client.release(); },
308
+ * classifications: ["personal", "operational"],
309
+ * residencyTag: "EU",
310
+ * pool: { min: 2, max: 20, idleTimeoutMs: 60000 },
311
+ * },
312
+ * },
313
+ * defaultBackend: "main",
314
+ * });
315
+ */
187
316
  function init(opts) {
188
317
  if (initialized) return;
189
318
  if (!opts || !opts.backends) throw new Error("externalDb.init({ backends }) is required");
@@ -210,10 +339,70 @@ function init(opts) {
210
339
  "backend '" + name + "': dialect must be one of " +
211
340
  "'postgres' | 'mysql' | 'sqlite' | 'mongodb' | 'other', got '" + dialect + "'", true);
212
341
  }
342
+ // OWASP-3 — application_name normalization for Postgres backends.
343
+ // Always set on every fresh connection (not just connectAs branch)
344
+ // so pg_stat_activity / log_line_prefix / audit log surfaces show
345
+ // a stable identifier instead of falling back to the driver's
346
+ // bare process name. CR / LF / NUL refused at config-time —
347
+ // those characters terminate the SET statement early in some
348
+ // drivers and have no legitimate use in an application_name.
349
+ // OWASP-3 — application_name normalization for Postgres backends.
350
+ // Opt-in via `cfg.applicationName` to surface a stable identifier
351
+ // in pg_stat_activity / log_line_prefix / Postgres audit log
352
+ // surfaces. Default leaves application_name to the driver — issuing
353
+ // a SET on every fresh connection at framework default would
354
+ // double-count queries for operators counting per-pool query
355
+ // activity (and break test fakes that count tracker.query calls).
356
+ var applicationName = cfg.applicationName !== undefined ? cfg.applicationName : null;
357
+ if (applicationName !== null && (typeof applicationName !== "string" || applicationName.length === 0)) {
358
+ throw _err("INVALID_CONFIG",
359
+ "backend '" + name + "': applicationName must be a non-empty string", true);
360
+ }
361
+ if (applicationName !== null) {
362
+ // eslint-disable-next-line no-control-regex
363
+ if (/[\r\n\u0000]/.test(applicationName)) {
364
+ throw _err("INVALID_CONFIG",
365
+ "backend '" + name + "': applicationName must not contain CR, LF, or NUL characters", true);
366
+ }
367
+ if (applicationName.length > C.BYTES.bytes(63)) {
368
+ throw _err("INVALID_CONFIG",
369
+ "backend '" + name + "': applicationName exceeds Postgres 63-byte limit (got " +
370
+ applicationName.length + ")", true);
371
+ }
372
+ }
373
+ var rawConnect = cfg.connect;
374
+ var rawQuery = cfg.query;
375
+ var connectFn = rawConnect;
376
+ if (dialect === "postgres" && applicationName !== null) {
377
+ // IIFE captures per-iteration rawConnect/rawQuery; without this
378
+ // the var-hoisted bindings are shared across the for-loop and
379
+ // every backend's connectFn ends up calling the LAST iteration's
380
+ // rawQuery (classic closure-in-loop bug).
381
+ connectFn = (function (cn, qn, appName) {
382
+ var quotedAppName = "'" + appName.replace(/'/g, "''") + "'";
383
+ return async function () {
384
+ var client = await cn();
385
+ try {
386
+ await qn(client, "SET application_name TO " + quotedAppName, []);
387
+ } catch (_e) {
388
+ // Best-effort. Real Postgres always supports SET
389
+ // application_name; a driver that refuses it is a shim
390
+ // (test fake / non-PG backend mislabeled "postgres") and
391
+ // there's nothing useful to surface — keep the connection
392
+ // and let the operator hit any real query failure
393
+ // immediately afterwards.
394
+ void _e;
395
+ }
396
+ return client;
397
+ };
398
+ })(rawConnect, rawQuery, applicationName);
399
+ }
400
+ var poolCfg = Object.assign({}, cfg, { connect: connectFn });
213
401
  backends[name] = {
214
402
  name: name,
215
403
  dialect: dialect,
216
- pool: new Pool(name, cfg),
404
+ applicationName: applicationName,
405
+ pool: new Pool(name, poolCfg),
217
406
  query: cfg.query,
218
407
  ping: cfg.ping || null,
219
408
  beginTx: cfg.beginTx || function (client) { return cfg.query(client, "BEGIN", []); },
@@ -332,6 +521,39 @@ function _servesClassification(b, cls) {
332
521
 
333
522
  // ---- Public API ----
334
523
 
524
+ /**
525
+ * @primitive b.externalDb.query
526
+ * @signature b.externalDb.query(sql, params, opts)
527
+ * @since 0.4.0
528
+ * @related b.externalDb.transaction, b.externalDb.read.query, b.externalDb.write.query
529
+ *
530
+ * Execute a single statement against the picked backend. Returns the
531
+ * driver-shaped `{ rows, rowCount }` from the backend's `query` hook.
532
+ * Wraps the call in `b.retry.withRetry` for transient driver errors
533
+ * and the per-backend circuit breaker; emits `system.externaldb.query`
534
+ * audit events plus duration / slow-query metrics; surfaces Postgres
535
+ * SQLSTATE 28000 / 28P01 / 42501 as `db.auth.failed` audit rows for
536
+ * SOC2 forensic walks.
537
+ *
538
+ * Backend selection precedence: `opts.backend` (explicit) →
539
+ * `opts.classification` (first backend serving the class) → ALS-bound
540
+ * dbRole + `dbRoleBackends` map (set by `b.middleware.dbRoleFor` or
541
+ * `b.externalDb.runAs`) → the configured `defaultBackend`.
542
+ *
543
+ * @opts
544
+ * backend?: string, // explicit backend name; bypasses classification + role pick
545
+ * classification?: string, // route to first backend whose classifications include this value
546
+ * includeSqlInAudit?: boolean, // emit SQL text in audit metadata (off by default — may carry literal PII)
547
+ *
548
+ * @example
549
+ * var res = await b.externalDb.query(
550
+ * "SELECT id, email FROM users WHERE tenant_id = $1",
551
+ * ["acme"],
552
+ * { classification: "personal" }
553
+ * );
554
+ * res.rowCount; // → 42
555
+ * res.rows[0]; // → { id: 1, email: "ada@example.com" }
556
+ */
335
557
  async function query(sql, params, opts) {
336
558
  _requireInit();
337
559
  opts = opts || {};
@@ -380,6 +602,7 @@ async function query(sql, params, opts) {
380
602
  { backend: b.name, role: role || "(none)" });
381
603
  _emitMetric("externaldb.query.duration_ms", durationMs,
382
604
  { backend: b.name, role: role || "(none)" });
605
+ _emitSlowQuery(b.name, role, durationMs, _classifyStatement(sql));
383
606
  return result;
384
607
  } catch (e) {
385
608
  var failureMs = Date.now() - t0;
@@ -392,6 +615,7 @@ async function query(sql, params, opts) {
392
615
  }, (e && e.message) || String(e));
393
616
  _emitMetric("externaldb.query.failure", 1,
394
617
  { backend: b.name, role: role || "(none)", errorCode: e.code || "(none)" });
618
+ _emitSlowQuery(b.name, role, failureMs, _classifyStatement(sql));
395
619
  // Postgres signals authorization-denied as SQLSTATE 42501
396
620
  // (insufficient_privilege). RLS-shaped writes that violate a
397
621
  // policy and GRANT-denied SELECTs both surface this code. The
@@ -403,10 +627,53 @@ async function query(sql, params, opts) {
403
627
  _emitMetric("db.role.denied", 1,
404
628
  { backend: b.name, role: role || "(none)" });
405
629
  }
630
+ // D-M2 — DB-auth audit visibility. Every 28000 / 28P01 / 42501
631
+ // surfaces an auditable db.auth.failed row tagged with the SQL
632
+ // identity and the statement class so SOC2 reviewers can
633
+ // reconstruct the denial timeline.
634
+ _emitAuthFailureAudit(b, role, sql, e);
406
635
  throw e;
407
636
  }
408
637
  }
409
638
 
639
+ /**
640
+ * @primitive b.externalDb.transaction
641
+ * @signature b.externalDb.transaction(fn, opts)
642
+ * @since 0.4.0
643
+ * @related b.externalDb.query, b.externalDb.write.query
644
+ *
645
+ * Run `fn(tx)` inside a transaction on the picked backend. Wraps the
646
+ * body in `BEGIN` / `COMMIT` / `ROLLBACK` via the backend's hooks;
647
+ * commits on resolve, rolls back on throw. Transient deadlock /
648
+ * serialization failures (Postgres SQLSTATE `40P01` / `40001`) retry
649
+ * automatically with a small jittered backoff (default 3 attempts;
650
+ * tune via `opts.deadlockRetries`).
651
+ *
652
+ * `tx.query(sql, params)` runs against the same client used by
653
+ * `BEGIN`, so RLS state set by `sessionGucs` (`SET LOCAL`) applies for
654
+ * the duration of the transaction and resets at COMMIT/ROLLBACK.
655
+ *
656
+ * @opts
657
+ * backend?: string, // explicit backend name
658
+ * classification?: string, // route by data class
659
+ * sessionGucs?: { [name]: string|number|boolean }, // SET LOCAL bindings (e.g. { "app.tenant_id": "acme" })
660
+ * statementTimeoutMs?: number, // SET LOCAL statement_timeout
661
+ * idleInTransactionTimeoutMs?: number, // SET LOCAL idle_in_transaction_session_timeout
662
+ * deadlockRetries?: number, // retries for 40P01 / 40001 (default 3)
663
+ *
664
+ * @example
665
+ * var summary = await b.externalDb.transaction(async function (tx) {
666
+ * await tx.query("INSERT INTO orders(id, total) VALUES ($1, $2)", ["o-1", 4200]);
667
+ * await tx.query("UPDATE inventory SET qty = qty - 1 WHERE sku = $1", ["sku-7"]);
668
+ * var res = await tx.query("SELECT count(*) AS n FROM orders WHERE id = $1", ["o-1"]);
669
+ * return res.rows[0];
670
+ * }, {
671
+ * classification: "operational",
672
+ * sessionGucs: { "app.tenant_id": "acme" },
673
+ * statementTimeoutMs: 5000,
674
+ * });
675
+ * summary.n; // → 1
676
+ */
410
677
  async function transaction(fn, opts) {
411
678
  _requireInit();
412
679
  if (typeof fn !== "function") throw _err("INVALID_FN", "transaction requires a function", true);
@@ -504,6 +771,11 @@ async function transaction(fn, opts) {
504
771
  _emitMetric("db.role.denied", 1,
505
772
  { backend: b.name, role: role || "(none)" });
506
773
  }
774
+ // D-M2 — DB-auth audit visibility on transaction-shaped denials.
775
+ // Statement class always reads as "TX" since the failure
776
+ // surface inside a transaction body could be any statement;
777
+ // operators correlate via the transaction's audit row.
778
+ _emitAuthFailureAudit(b, role, "BEGIN", txErr);
507
779
  throw txErr;
508
780
  }
509
781
  }
@@ -513,6 +785,28 @@ async function transaction(fn, opts) {
513
785
  });
514
786
  }
515
787
 
788
+ /**
789
+ * @primitive b.externalDb.healthCheck
790
+ * @signature b.externalDb.healthCheck(backendName)
791
+ * @since 0.4.0
792
+ * @related b.externalDb.listBackends, b.externalDb.shutdown
793
+ *
794
+ * Ping a backend by acquiring a client and running its `ping` hook (or
795
+ * `SELECT 1` when none is supplied). Returns `{ ok, breakerState, pool }`
796
+ * for a single backend, or a `{ [name]: result }` map when called with
797
+ * no argument. Connection-shape errors destroy the client; the breaker
798
+ * state is reflected in the returned record so health endpoints can
799
+ * surface circuit-open conditions.
800
+ *
801
+ * @example
802
+ * var all = await b.externalDb.healthCheck();
803
+ * all.main.ok; // → true
804
+ * all.main.breakerState; // → "closed"
805
+ * all.main.pool; // → { idle: 1, active: 0, waiters: 0 }
806
+ *
807
+ * var one = await b.externalDb.healthCheck("main");
808
+ * one.ok; // → true
809
+ */
516
810
  async function healthCheck(backendName) {
517
811
  _requireInit();
518
812
  if (backendName) {
@@ -543,6 +837,25 @@ async function _pingBackend(b) {
543
837
  }
544
838
  }
545
839
 
840
+ /**
841
+ * @primitive b.externalDb.listBackends
842
+ * @signature b.externalDb.listBackends()
843
+ * @since 0.4.0
844
+ * @related b.externalDb.healthCheck, b.externalDb.init
845
+ *
846
+ * Snapshot every registered backend's name, dialect, classifications,
847
+ * residency tag, breaker state, and live pool stats. Returns `[]` when
848
+ * `init()` has not run. Cheap — does not open any new connections.
849
+ *
850
+ * @example
851
+ * var rows = b.externalDb.listBackends();
852
+ * rows[0].name; // → "main"
853
+ * rows[0].dialect; // → "postgres"
854
+ * rows[0].classifications; // → ["personal", "operational"]
855
+ * rows[0].residencyTag; // → "EU"
856
+ * rows[0].breakerState; // → "closed"
857
+ * rows[0].pool; // → { idle: 2, active: 0, waiters: 0 }
858
+ */
546
859
  function listBackends() {
547
860
  if (!initialized) return [];
548
861
  return Object.keys(backends).map(function (name) {
@@ -558,6 +871,24 @@ function listBackends() {
558
871
  });
559
872
  }
560
873
 
874
+ /**
875
+ * @primitive b.externalDb.shutdown
876
+ * @signature b.externalDb.shutdown()
877
+ * @since 0.4.0
878
+ * @related b.externalDb.init, b.externalDb.healthCheck
879
+ *
880
+ * Drain every backend pool (and replica pool), close idle clients,
881
+ * then clear all registry state so a subsequent `init()` starts from
882
+ * scratch. Idempotent — calling before `init()` is a no-op. Wire to
883
+ * `b.appShutdown` so process exit waits for in-flight queries to
884
+ * release their clients.
885
+ *
886
+ * @example
887
+ * process.on("SIGTERM", async function () {
888
+ * await b.externalDb.shutdown();
889
+ * process.exit(0);
890
+ * });
891
+ */
561
892
  async function shutdown() {
562
893
  if (!initialized) return;
563
894
  for (var name in backends) {
@@ -686,12 +1017,46 @@ function _requireInit() {
686
1017
 
687
1018
  var REPLICA_UNHEALTHY_COOLDOWN_MS = C.TIME.seconds(30);
688
1019
 
1020
+ // F-CBT-2 — replica residency-tag compatibility.
1021
+ //
1022
+ // A primary tagged "EU" replicating to a "US" replica is a GDPR
1023
+ // Article 46 cross-border transfer; without an explicit operator
1024
+ // opt-in the framework refuses init under gdpr / dpdp / pipl-cn /
1025
+ // uk-gdpr postures. Operator suppresses the gate per replica via
1026
+ // allowCrossBorder: true (which the framework records in the audit
1027
+ // chain so a compliance reviewer sees the conscious decision).
1028
+ //
1029
+ // Compatible-residency rules:
1030
+ // - Identical tags (EU↔EU, US↔US): always compatible.
1031
+ // - "unrestricted" tag on either side: compatible (operator
1032
+ // declared no constraint).
1033
+ // - Different tags: compatible only when allowCrossBorder is true.
1034
+ var CROSS_BORDER_REGULATED_POSTURES = Object.freeze([
1035
+ "gdpr", "uk-gdpr", "dpdp", "pipl-cn", "lgpd-br", "appi-jp", "pdpa-sg",
1036
+ ]);
1037
+
1038
+ function _residencyCompatible(primaryTag, replicaTag) {
1039
+ if (!primaryTag || !replicaTag) return true;
1040
+ if (primaryTag === replicaTag) return true; // allow:raw-hash-compare — residency tag string, not a secret hash
1041
+ if (primaryTag === "unrestricted" || replicaTag === "unrestricted") return true;
1042
+ return false;
1043
+ }
1044
+
1045
+ function _activePosture() {
1046
+ try {
1047
+ var compliance = require("./compliance"); // allow:inline-require — defensive against optional load
1048
+ return compliance.current();
1049
+ } catch (_e) { return null; }
1050
+ }
1051
+
689
1052
  function _buildReplicas(backendName, cfg) {
690
1053
  if (!cfg.replicas) return null;
691
1054
  if (!Array.isArray(cfg.replicas) || cfg.replicas.length === 0) {
692
1055
  throw _err("INVALID_CONFIG",
693
1056
  "backend '" + backendName + "': replicas must be a non-empty array", true);
694
1057
  }
1058
+ var primaryTag = cfg.residencyTag || "unrestricted";
1059
+ var posture = _activePosture();
695
1060
  var out = [];
696
1061
  for (var i = 0; i < cfg.replicas.length; i++) {
697
1062
  var r = cfg.replicas[i];
@@ -709,11 +1074,33 @@ function _buildReplicas(backendName, cfg) {
709
1074
  throw _err("INVALID_CONFIG",
710
1075
  "backend '" + backendName + "': replicas[" + i + "].weight must be a positive integer", true);
711
1076
  }
1077
+ var replicaTag = r.residencyTag || "unrestricted";
1078
+ var allowCrossBorder = r.allowCrossBorder === true;
1079
+ if (!_residencyCompatible(primaryTag, replicaTag) && !allowCrossBorder) {
1080
+ var underPosture = posture && CROSS_BORDER_REGULATED_POSTURES.indexOf(posture) !== -1;
1081
+ throw _err("RESIDENCY_MISMATCH",
1082
+ "backend '" + backendName + "': replica[" + i +
1083
+ "] residencyTag '" + replicaTag +
1084
+ "' is not compatible with primary residencyTag '" + primaryTag +
1085
+ "'" + (underPosture ? " under '" + posture + "' posture" : "") +
1086
+ ". This is a cross-border data transfer (GDPR Art 46 / DPDP / PIPL " +
1087
+ "category). Pass allowCrossBorder: true on the replica config with a " +
1088
+ "documented legal basis (SCCs / BCRs / adequacy decision) to suppress.", true);
1089
+ }
1090
+ if (!_residencyCompatible(primaryTag, replicaTag) && allowCrossBorder) {
1091
+ _emit("externalDb.replica.cross_border_allowed", "warning",
1092
+ { backend: backendName, replicaIndex: i,
1093
+ primaryTag: primaryTag, replicaTag: replicaTag,
1094
+ legalBasis: r.legalBasis || null,
1095
+ posture: posture || null });
1096
+ }
712
1097
  out.push({
713
1098
  index: i,
714
1099
  pool: new Pool(backendName + ":replica:" + i, r),
715
1100
  query: r.query,
716
1101
  weight: weight,
1102
+ residencyTag: replicaTag,
1103
+ allowCrossBorder: allowCrossBorder,
717
1104
  lastFailureAt: 0,
718
1105
  consecutiveFailures: 0,
719
1106
  });
@@ -807,6 +1194,8 @@ async function _readQuery(sql, params, opts) {
807
1194
  _emitMetric("db.role.denied", 1,
808
1195
  { backend: b.name, role: role || "(none)" });
809
1196
  }
1197
+ // D-M2 — DB-auth audit visibility for read-replica denials too.
1198
+ _emitAuthFailureAudit(b, role, sql, e);
810
1199
  // Fallback to primary on a failed replica read when allowed.
811
1200
  if (b.replicaFallbackToPrimary) {
812
1201
  return query(sql, params, opts);
@@ -815,10 +1204,86 @@ async function _readQuery(sql, params, opts) {
815
1204
  }
816
1205
  }
817
1206
 
1207
+ /**
1208
+ * @primitive b.externalDb.read.query
1209
+ * @signature b.externalDb.read.query(sql, params, opts)
1210
+ * @since 0.4.0
1211
+ * @related b.externalDb.write.query, b.externalDb.query, b.externalDb.init
1212
+ *
1213
+ * Route a read against the backend's declared replicas using weighted
1214
+ * round-robin. A failed replica is sidelined for 30 seconds and the
1215
+ * call falls back to primary when `replicaFallbackToPrimary` is true
1216
+ * (the default). Backends without replicas transparently route to
1217
+ * primary. Same `opts` selection rules as `b.externalDb.query`
1218
+ * (`backend` / `classification` / ALS-bound role).
1219
+ *
1220
+ * @opts
1221
+ * backend?: string, // explicit backend name
1222
+ * classification?: string, // route by data class
1223
+ *
1224
+ * @example
1225
+ * var res = await b.externalDb.read.query(
1226
+ * "SELECT id, total FROM orders WHERE tenant_id = $1",
1227
+ * ["acme"],
1228
+ * { classification: "operational" }
1229
+ * );
1230
+ * res.rowCount; // → 7
1231
+ * res.rows[0]; // → { id: "o-1", total: 4200 }
1232
+ */
818
1233
  var read = {
819
1234
  query: _readQuery,
820
1235
  };
821
1236
 
1237
+ /**
1238
+ * @primitive b.externalDb.write.query
1239
+ * @signature b.externalDb.write.query(sql, params, opts)
1240
+ * @since 0.4.0
1241
+ * @related b.externalDb.read.query, b.externalDb.query, b.externalDb.write.transaction
1242
+ *
1243
+ * Symmetric alias for `b.externalDb.query` — always routes to primary.
1244
+ * Pair with `b.externalDb.read.query` when an operator wants the call
1245
+ * site to express read/write intent without a magic-comment hint.
1246
+ * Same `opts` selection rules as `b.externalDb.query`.
1247
+ *
1248
+ * @opts
1249
+ * backend?: string, // explicit backend name
1250
+ * classification?: string, // route by data class
1251
+ * includeSqlInAudit?: boolean, // emit SQL text in audit metadata
1252
+ *
1253
+ * @example
1254
+ * var res = await b.externalDb.write.query(
1255
+ * "INSERT INTO orders(id, tenant_id, total) VALUES ($1, $2, $3)",
1256
+ * ["o-2", "acme", 1500],
1257
+ * { classification: "operational" }
1258
+ * );
1259
+ * res.rowCount; // → 1
1260
+ */
1261
+ /**
1262
+ * @primitive b.externalDb.write.transaction
1263
+ * @signature b.externalDb.write.transaction(fn, opts)
1264
+ * @since 0.4.0
1265
+ * @related b.externalDb.transaction, b.externalDb.write.query
1266
+ *
1267
+ * Symmetric alias for `b.externalDb.transaction` — always runs against
1268
+ * primary. Same `opts` shape (sessionGucs / statementTimeoutMs /
1269
+ * idleInTransactionTimeoutMs / deadlockRetries) as the canonical form.
1270
+ *
1271
+ * @opts
1272
+ * backend?: string,
1273
+ * classification?: string,
1274
+ * sessionGucs?: { [name]: string|number|boolean },
1275
+ * statementTimeoutMs?: number,
1276
+ * idleInTransactionTimeoutMs?: number,
1277
+ * deadlockRetries?: number,
1278
+ *
1279
+ * @example
1280
+ * var n = await b.externalDb.write.transaction(async function (tx) {
1281
+ * await tx.query("UPDATE counters SET n = n + 1 WHERE k = $1", ["hits"]);
1282
+ * var res = await tx.query("SELECT n FROM counters WHERE k = $1", ["hits"]);
1283
+ * return res.rows[0].n;
1284
+ * }, { sessionGucs: { "app.tenant_id": "acme" } });
1285
+ * typeof n; // → "number"
1286
+ */
822
1287
  // write namespace — alias for the primary path. Lets operators express
823
1288
  // intent symmetrically with read.query without a magic-comment hint.
824
1289
  var write = {
@@ -852,6 +1317,30 @@ function _resetForTest() {
852
1317
  // clients are kept; new acquisitions respect the new max. min is honored
853
1318
  // the next time the pool refills. idleTimeoutMs takes effect on the next
854
1319
  // reaper tick.
1320
+ /**
1321
+ * @primitive b.externalDb.configurePool
1322
+ * @signature b.externalDb.configurePool(backendName, opts)
1323
+ * @since 0.4.0
1324
+ * @related b.externalDb.init, b.externalDb.listBackends
1325
+ *
1326
+ * Resize a registered backend's pool at runtime. New `max` takes effect
1327
+ * on the next acquire; existing idle clients are kept; `min` is honored
1328
+ * when the pool next refills; `idleTimeoutMs` applies on the next
1329
+ * reaper tick. Throws on unknown options or non-positive integers so a
1330
+ * config typo surfaces at the call site.
1331
+ *
1332
+ * @opts
1333
+ * min?: number, // positive integer; floor on idle clients
1334
+ * max?: number, // positive integer; ceiling on total clients (must be >= min)
1335
+ * idleTimeoutMs?: number, // positive integer; reap idle clients after this many ms
1336
+ *
1337
+ * @example
1338
+ * b.externalDb.configurePool("main", {
1339
+ * min: 4,
1340
+ * max: 50,
1341
+ * idleTimeoutMs: 120000,
1342
+ * });
1343
+ */
855
1344
  function configurePool(backendName, opts) {
856
1345
  _requireInit();
857
1346
  if (typeof backendName !== "string" || backendName.length === 0) {
@@ -1031,6 +1520,51 @@ function _connectAs(rawConnect, query, opts) {
1031
1520
  };
1032
1521
  }
1033
1522
 
1523
+ /**
1524
+ * @primitive b.externalDb.adapters.connectAs
1525
+ * @signature b.externalDb.adapters.connectAs(connect, opts)
1526
+ * @since 0.4.0
1527
+ * @related b.externalDb.init, b.externalDb.runAs
1528
+ *
1529
+ * Wrap a Postgres `connect` so every fresh client runs `SET ROLE`,
1530
+ * `SET search_path`, `SET application_name`, `SET statement_timeout`,
1531
+ * and any operator-supplied `gucs` before being handed to the pool.
1532
+ * Identifier inputs (role, schemas, GUC names) are validated via
1533
+ * `safeSql.validateIdentifier` at call time so a bad name throws once
1534
+ * at boot rather than per acquired client. Returns the wrapped
1535
+ * `connect` function suitable for a backend's `connect` hook.
1536
+ *
1537
+ * @opts
1538
+ * query: function, // required — the backend's query function (used to issue SET statements)
1539
+ * role?: string, // SQL identifier; runs SET ROLE "<role>"
1540
+ * searchPath?: string[], // SQL identifiers; runs SET search_path TO "<a>", "<b>", ...
1541
+ * applicationName?: string, // appears in pg_stat_activity
1542
+ * statementTimeoutMs?: number, // positive integer; SET statement_timeout TO <ms>
1543
+ * gucs?: { [name]: string|number }, // raw GUC bindings; finite numbers required for numeric values
1544
+ *
1545
+ * @example
1546
+ * var pg = require("pg");
1547
+ * var pool = new pg.Pool({ connectionString: "postgres://app:pw@db.example.com/app" });
1548
+ * var rawConnect = function () { return pool.connect(); };
1549
+ * var rawQuery = function (client, sql, params) { return client.query(sql, params); };
1550
+ *
1551
+ * b.externalDb.init({
1552
+ * backends: {
1553
+ * analytics: {
1554
+ * dialect: "postgres",
1555
+ * connect: b.externalDb.adapters.connectAs(rawConnect, {
1556
+ * query: rawQuery,
1557
+ * role: "analytics_user",
1558
+ * searchPath: ["analytics", "public"],
1559
+ * applicationName: "blamejs:analytics",
1560
+ * statementTimeoutMs: 30000,
1561
+ * gucs: { idle_in_transaction_session_timeout: "60s" },
1562
+ * }),
1563
+ * query: rawQuery,
1564
+ * },
1565
+ * },
1566
+ * });
1567
+ */
1034
1568
  // Operators import the helper as `b.externalDb.adapters.connectAs(connect, opts)`
1035
1569
  // — declarative wrapping with shared input validation.
1036
1570
  function _adaptersConnectAs(connect, opts) {
@@ -1069,6 +1603,31 @@ function _adaptersConnectAs(connect, opts) {
1069
1603
  //
1070
1604
  // currentRole() returns the active role (or null) — useful for diagnostic
1071
1605
  // logs and observability labels.
1606
+ /**
1607
+ * @primitive b.externalDb.runAs
1608
+ * @signature b.externalDb.runAs(role, fn)
1609
+ * @since 0.4.0
1610
+ * @related b.externalDb.currentRole, b.externalDb.adapters.connectAs
1611
+ *
1612
+ * Bind a SQL role on the deep async-local context for the duration of
1613
+ * `fn()`. Every `b.externalDb.query` / `read.query` / `write.query` /
1614
+ * `transaction` call inside the bound region picks the backend mapped
1615
+ * to `role` via the `dbRoleBackends` map declared at `init()`, so
1616
+ * background workers (cron, queue consumers, CLI commands) get the
1617
+ * same role-aware routing as HTTP requests under
1618
+ * `b.middleware.dbRoleFor`. Pass `null` to clear. Audits role
1619
+ * transitions as `db.role.switched`. Identifier-validates the role at
1620
+ * the call site so a typo throws synchronously.
1621
+ *
1622
+ * @example
1623
+ * await b.externalDb.runAs("analytics_user", async function () {
1624
+ * var res = await b.externalDb.read.query(
1625
+ * "SELECT count(*) AS n FROM events WHERE day = $1",
1626
+ * ["2026-05-09"]
1627
+ * );
1628
+ * return res.rows[0].n;
1629
+ * });
1630
+ */
1072
1631
  function runAs(role, fn) {
1073
1632
  if (typeof fn !== "function") {
1074
1633
  throw _err("INVALID_FN", "externalDb.runAs: fn must be a function", true);
@@ -1104,10 +1663,195 @@ function runAs(role, fn) {
1104
1663
  return dbRoleContext.runWithRole(role || null, fn);
1105
1664
  }
1106
1665
 
1666
+ /**
1667
+ * @primitive b.externalDb.currentRole
1668
+ * @signature b.externalDb.currentRole()
1669
+ * @since 0.4.0
1670
+ * @related b.externalDb.runAs
1671
+ *
1672
+ * Read the SQL role bound on the deep async-local context. Returns
1673
+ * `null` when no role is bound. Useful for diagnostic logs, audit
1674
+ * metadata, and observability labels — the value flows through the
1675
+ * same context that `b.externalDb.query` consults for backend pick.
1676
+ *
1677
+ * @example
1678
+ * await b.externalDb.runAs("analytics_user", async function () {
1679
+ * b.externalDb.currentRole(); // → "analytics_user"
1680
+ * });
1681
+ * b.externalDb.currentRole(); // → null
1682
+ */
1107
1683
  function currentRole() {
1108
1684
  return dbRoleContext.getRole();
1109
1685
  }
1110
1686
 
1687
+ // OWASP-2 — pg_roles enumeration / unrecognized-role guard.
1688
+ //
1689
+ // Boot-time check that compares pg_roles membership to the operator-
1690
+ // declared role list. Operators declare every role they expect to
1691
+ // exist on the cluster (via opts.declaredRoles); the gate refuses or
1692
+ // audits when pg_roles surfaces names not in that list — typical
1693
+ // signal of a forgotten ALTER ROLE / a leftover migration role / a
1694
+ // privileged role added outside change-management.
1695
+ //
1696
+ // await b.externalDb.assertRoleHardening({
1697
+ // backend: "main",
1698
+ // declaredRoles: ["app_user", "analytics_user", "admin"],
1699
+ // mode: "audit", // "audit" | "throw"
1700
+ // ignoreSystem: true, // skip rds_*, pg_*, postgres
1701
+ // });
1702
+ //
1703
+ // Returns { declared, observed, unrecognized, missing }. mode="throw"
1704
+ // raises ROLE_HARDENING_FAIL when unrecognized rows surface; default
1705
+ // "audit" emits db.role.hardening.unrecognized so dashboards see the
1706
+ // drift without breaking boot.
1707
+ /**
1708
+ * @primitive b.externalDb.assertRoleHardening
1709
+ * @signature b.externalDb.assertRoleHardening(opts)
1710
+ * @since 0.7.0
1711
+ * @related b.externalDb.runAs, b.externalDb.adapters.connectAs
1712
+ *
1713
+ * Compare `pg_roles` membership against an operator-declared role
1714
+ * allowlist on a Postgres backend. Surfaces unrecognized roles
1715
+ * (forgotten ALTER ROLE leftovers, migration roles, privileged grants
1716
+ * added outside change-management) and missing roles (declared but not
1717
+ * present). Default `mode: "audit"` emits
1718
+ * `db.role.hardening.unrecognized` / `.ok` so dashboards see drift
1719
+ * without breaking boot; `mode: "throw"` fails boot when unrecognized
1720
+ * roles surface. Non-Postgres dialects emit `db.role.hardening.skipped`
1721
+ * and return empty observed lists.
1722
+ *
1723
+ * @opts
1724
+ * declaredRoles: string[], // required; allowlist of expected role names
1725
+ * backend?: string, // explicit backend name (defaults to defaultBackend)
1726
+ * mode?: "audit" | "throw", // default "audit"
1727
+ * ignoreSystem?: boolean, // skip postgres / pg_* / rds_* / azure_* / cloudsqlsuperuser (default true)
1728
+ *
1729
+ * @example
1730
+ * var report = await b.externalDb.assertRoleHardening({
1731
+ * backend: "main",
1732
+ * declaredRoles: ["app_user", "analytics_user", "admin"],
1733
+ * mode: "audit",
1734
+ * ignoreSystem: true,
1735
+ * });
1736
+ * report.unrecognized; // → []
1737
+ * report.missing; // → []
1738
+ * report.observed; // → ["admin", "analytics_user", "app_user"]
1739
+ */
1740
+ async function assertRoleHardening(opts) {
1741
+ _requireInit();
1742
+ if (!opts || typeof opts !== "object") {
1743
+ throw _err("INVALID_CONFIG",
1744
+ "assertRoleHardening: opts is required ({ declaredRoles, backend?, mode? })", true);
1745
+ }
1746
+ if (!Array.isArray(opts.declaredRoles)) {
1747
+ throw _err("INVALID_CONFIG",
1748
+ "assertRoleHardening: opts.declaredRoles must be an array of role names", true);
1749
+ }
1750
+ for (var i = 0; i < opts.declaredRoles.length; i++) {
1751
+ var r = opts.declaredRoles[i];
1752
+ if (typeof r !== "string" || r.length === 0) {
1753
+ throw _err("INVALID_CONFIG",
1754
+ "assertRoleHardening: declaredRoles[" + i + "] must be a non-empty string", true);
1755
+ }
1756
+ }
1757
+ var mode = opts.mode || "audit";
1758
+ if (mode !== "audit" && mode !== "throw") {
1759
+ throw _err("INVALID_CONFIG",
1760
+ "assertRoleHardening: mode must be 'audit' or 'throw' (got '" + mode + "')", true);
1761
+ }
1762
+ var backendName = opts.backend || defaultBackend;
1763
+ var b = backends[backendName];
1764
+ if (!b) {
1765
+ throw _err("UNKNOWN_BACKEND",
1766
+ "assertRoleHardening: no backend named '" + backendName + "'", true);
1767
+ }
1768
+ if (b.dialect !== "postgres") {
1769
+ // Non-Postgres dialects don't have pg_roles. The check is a no-op
1770
+ // with a clear audit row so operators see the skip rather than
1771
+ // assume hardening ran.
1772
+ audit().safeEmit({
1773
+ action: "db.role.hardening.skipped",
1774
+ actor: {},
1775
+ resource: { kind: "db.backend", id: backendName },
1776
+ outcome: "success",
1777
+ metadata: { dialect: b.dialect, reason: "non-postgres" },
1778
+ });
1779
+ return { declared: opts.declaredRoles.slice(), observed: [], unrecognized: [], missing: [] };
1780
+ }
1781
+ var ignoreSystem = opts.ignoreSystem !== false; // default true
1782
+ var rows;
1783
+ try {
1784
+ var res = await query(
1785
+ "SELECT rolname FROM pg_roles ORDER BY rolname",
1786
+ [],
1787
+ { backend: backendName }
1788
+ );
1789
+ rows = (res && res.rows) || [];
1790
+ } catch (e) {
1791
+ audit().safeEmit({
1792
+ action: "db.role.hardening.unreadable",
1793
+ actor: {},
1794
+ resource: { kind: "db.backend", id: backendName },
1795
+ outcome: "failure",
1796
+ reason: (e && e.message) || String(e),
1797
+ metadata: { backend: backendName },
1798
+ });
1799
+ throw _err("ROLE_HARDENING_UNREADABLE",
1800
+ "assertRoleHardening: could not read pg_roles on backend '" + backendName + "': " +
1801
+ ((e && e.message) || String(e)), true);
1802
+ }
1803
+ var observed = rows.map(function (r) { return r.rolname; });
1804
+ if (ignoreSystem) {
1805
+ observed = observed.filter(function (n) {
1806
+ return !(n === "postgres" || n.indexOf("pg_") === 0 || n.indexOf("rds_") === 0 ||
1807
+ n.indexOf("rdsadmin") === 0 || n.indexOf("azure_") === 0 ||
1808
+ n.indexOf("cloudsqlsuperuser") === 0);
1809
+ });
1810
+ }
1811
+ var declaredSet = {};
1812
+ opts.declaredRoles.forEach(function (n) { declaredSet[n] = true; });
1813
+ var observedSet = {};
1814
+ observed.forEach(function (n) { observedSet[n] = true; });
1815
+ var unrecognized = observed.filter(function (n) { return !declaredSet[n]; });
1816
+ var missing = opts.declaredRoles.filter(function (n) { return !observedSet[n]; });
1817
+ if (unrecognized.length > 0 || missing.length > 0) {
1818
+ audit().safeEmit({
1819
+ action: "db.role.hardening.unrecognized",
1820
+ actor: {},
1821
+ resource: { kind: "db.backend", id: backendName },
1822
+ outcome: unrecognized.length > 0 ? "denied" : "failure",
1823
+ metadata: {
1824
+ backend: backendName,
1825
+ unrecognized: unrecognized,
1826
+ missing: missing,
1827
+ observedCount: observed.length,
1828
+ },
1829
+ });
1830
+ if (mode === "throw" && unrecognized.length > 0) {
1831
+ throw _err("ROLE_HARDENING_FAIL",
1832
+ "assertRoleHardening: pg_roles surfaces " + unrecognized.length +
1833
+ " unrecognized role(s) on backend '" + backendName + "': " +
1834
+ unrecognized.join(", ") + ". Either add them to declaredRoles after " +
1835
+ "review, REVOKE them, or set mode: 'audit' to downgrade to audit-only.",
1836
+ true);
1837
+ }
1838
+ } else {
1839
+ audit().safeEmit({
1840
+ action: "db.role.hardening.ok",
1841
+ actor: {},
1842
+ resource: { kind: "db.backend", id: backendName },
1843
+ outcome: "success",
1844
+ metadata: { backend: backendName, observedCount: observed.length },
1845
+ });
1846
+ }
1847
+ return {
1848
+ declared: opts.declaredRoles.slice(),
1849
+ observed: observed,
1850
+ unrecognized: unrecognized,
1851
+ missing: missing,
1852
+ };
1853
+ }
1854
+
1111
1855
  module.exports = {
1112
1856
  init: init,
1113
1857
  query: query,
@@ -1115,11 +1859,12 @@ module.exports = {
1115
1859
  healthCheck: healthCheck,
1116
1860
  listBackends: listBackends,
1117
1861
  shutdown: shutdown,
1118
- configurePool: configurePool,
1119
- read: read,
1120
- write: write,
1121
- runAs: runAs,
1122
- currentRole: currentRole,
1862
+ configurePool: configurePool,
1863
+ read: read,
1864
+ write: write,
1865
+ runAs: runAs,
1866
+ currentRole: currentRole,
1867
+ assertRoleHardening: assertRoleHardening,
1123
1868
  adapters: {
1124
1869
  connectAs: _adaptersConnectAs,
1125
1870
  },