@blamejs/core 0.6.6 → 0.6.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,12 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.6.x
10
10
 
11
+ - **0.6.11** (2026-05-01) — wiki example-execution validator: fixture init no longer reaches across module realms (unblocks the npm-publish workflow's wiki-e2e gate, which `npm install --install-links` copies the framework into the wiki's node_modules — two distinct singletons before this fix)
12
+ - **0.6.10** (2026-05-01) — README / SECURITY / CONTRIBUTING / wiki: removed stale version stamps and an inaccurate vendored-dep list; SECURITY now points at `lib/vendor/MANIFEST.json` for the authoritative vendor list; supported-versions table no longer pins to a specific minor; wiki archive example names the digest variable correctly (was `sha256`, output is SHA3-512 hex)
13
+ - **0.6.9** (2026-05-01) — b.archive.zip().digest() returns a SHA3-512 hex string (was SHA-256); operators reconciling against an external SHA-256 must hash the bytes themselves
14
+ - **0.6.8** (2026-05-01) — wiki primitive-section validator: presence + opts diff + example execution against canonical fixture
15
+ - **0.6.7** (2026-05-01) — db.role.switched audit + per-role metrics + API snapshot baseline
16
+ - **0.6.6** (2026-05-01) — request-time DB role binding + Postgres RLS migrations
11
17
  - **0.6.5** (2026-05-01) — b.db.declareView + b.externalDb.migrate
12
18
  - **0.6.4** (2026-05-01) — wiki schema docs realigned with the actual lib API
13
19
  - **0.6.3** (2026-05-01) — externalDb pool tuning + role-aware connect + read-replica routing
@@ -72,7 +78,7 @@ upgrading across more than a few patches at a time.
72
78
 
73
79
  ## v0.3.x
74
80
 
75
- - **0.3.39** (2026-04-29) — MIGRATING.md generator scans deprecate() calls in lib/
81
+ - **0.3.39** (2026-04-29) — MIGRATING.md generator scans deprecate calls in lib/
76
82
  - **0.3.38** (2026-04-29) — LTS-CALENDAR.md publishes the major-cadence + algorithm posture
77
83
  - **0.3.37** (2026-04-29) — bundled pure-JS mTLS engine + vendor-update.sh + CHANGELOG + thanks page
78
84
  - **0.3.36** (2026-04-29) — prepack guard refuses to publish any path that matches a gitignore rule
@@ -87,15 +93,15 @@ upgrading across more than a few patches at a time.
87
93
  - **0.3.27** (2026-04-29) — Dockerfile pre-builds public/dist in deps stage so runtime container boots cleanly under --cap-drop ALL
88
94
  - **0.3.26** (2026-04-29) — operator-facing env-var surface, GitHub-side ops docs, contribution standards
89
95
  - **0.3.25** (2026-04-29) — env-var surface documented across Dockerfile, docker-compose.prod, DEPLOY.md, README; smoke-test GHCR login fix
90
- - **0.3.24** (2026-04-29) — CI + release-container workflows brought to hermitstash parity (structured output, multi-arch, cosign, post-publish smoke)
96
+ - **0.3.24** (2026-04-29) — CI + release-container workflows brought (structured output, multi-arch, cosign, post-publish smoke)
91
97
  - **0.3.23** (2026-04-29) — _runVault and _runBackup refactored to use b.cliHelpers (sweep complete across the three CLI commands the helper was designed for)
92
98
  - **0.3.22** (2026-04-29) — blamejs api-key CLI; b.cliHelpers shared primitive for headless app + reporter shape
93
99
  - **0.3.21** (2026-04-29) — blamejs backup CLI: inspect / verify / extract; backup encryption format documented
94
100
  - **0.3.20** (2026-04-29) — drop redundant Windows-flaky audit.flush perf assertion
95
101
  - **0.3.19** (2026-04-29) — wiki docs for the unwired primitives surface
96
102
  - **0.3.18** (2026-04-29) — blamejs vault CLI: seal / unseal / rotate / status
97
- - **0.3.17** (2026-04-29) — Tier-A validateOpts coverage extended to remaining operator-facing primitives (i18n, bundler, migrations, seeders, template, jobs, cookies, log, mtlsCa, staticServe, handlers, websocket, pqcGate, tracing, metrics — 15 more factories now reject typo'd opts at boot). Coverage is now ~39/lib primitives with the helper; the remaining handful (app-shutdown, chain-writer, dev, error-page, nonce-store, protocol-dispatcher, render, session, pqc-agent) are either internal-only — operators don't construct them directly — or have a single required-key surface where typo guards add no value.
98
- - **0.3.16** (2026-04-29) — Tier-A config validation, CSRF cookie mode, HTML balance, dynamic Cache-Control, URL canonicalization
103
+ - **0.3.17** (2026-04-29) — validateOpts coverage extended to remaining operator-facing primitives (i18n, bundler, migrations, seeders, template, jobs, cookies, log, mtlsCa, staticServe, handlers, websocket, pqcGate, tracing, metrics — 15 more factories now reject typo'd opts at boot). Coverage is now ~39/lib primitives with the helper; the remaining handful (app-shutdown, chain-writer, dev, error-page, nonce-store, protocol-dispatcher, render, session, pqc-agent) are either internal-only — operators don't construct them directly — or have a single required-key surface where typo guards add no value.
104
+ - **0.3.16** (2026-04-29) — config validation, CSRF cookie mode, HTML balance, dynamic Cache-Control, URL canonicalization
99
105
  - **0.3.15** (2026-04-29) — cors: accept Origin: null when Sec-Fetch-Site is same-origin (browsers send opaque Origin: null on form-navigation POSTs from a page whose response sets Referrer-Policy: no-referrer; the Fetch-metadata Sec-Fetch-Site header is the matching disambiguation signal — same-origin or none means accept, anything else stays refused)
100
106
  - **0.3.14** (2026-04-29) — Dockerfile hotfix + lint as release gate
101
107
  - **0.3.13** (2026-04-29) — CI lint scans + Wolfi container + Trivy two-step + lint cleanup
@@ -132,7 +138,7 @@ upgrading across more than a few patches at a time.
132
138
  - **0.2.25** (2026-04-28) — b.slug
133
139
  - **0.2.24** (2026-04-28) — b.retry primitive
134
140
  - **0.2.23** (2026-04-28) — test helpers consolidate
135
- - **0.2.22** (2026-04-28) — tier-aware input validation
141
+ - **0.2.22** (2026-04-28) — input validation
136
142
  - **0.2.21** (2026-04-28) — defineClass.factory replaces per-module _err wrappers
137
143
  - **0.2.20** (2026-04-28) — b.requestHelpers + metrics _normalizeLabelArg
138
144
  - **0.2.19** (2026-04-28) — audit.safeEmit replaces per-module _emit wrappers
@@ -194,22 +200,22 @@ upgrading across more than a few patches at a time.
194
200
  - **0.1.78** (2026-04-27) — pqc-agent: locked-posture HTTPS agent + http-client refactor — slice 2
195
201
  - **0.1.77** (2026-04-27) — pqc-gate: TCP-level PQC enforcement on ClientHello — slice 1
196
202
  - **0.1.76** (2026-04-27) — bundler: content-hashed asset pipeline + manifest — complete
197
- - **0.1.75** (2026-04-27) — dev: file-watch + child-process restart engine — slice 6 ( complete)
203
+ - **0.1.75** (2026-04-27) — dev: file-watch + child-process restart engine — slice 6 (complete)
198
204
  - **0.1.74** (2026-04-27) — cli: bin/blamejs + migrate up/down/status — slice 5
199
205
  - **0.1.73** (2026-04-27) — migrations: public up/down/status runner — slice 4
200
206
  - **0.1.72** (2026-04-27) — cookies: parse/serialize + sealed-value access gate — slice 3
201
207
  - **0.1.71** (2026-04-27) — errors-page: rich dev page + safe prod page; middleware shim — slice 2
202
208
  - **0.1.70** (2026-04-27) — log: structured JSON logging with request-id correlation — slice 1
203
- - **0.1.69** (2026-04-27) — scheduler: cron + interval over jobs/queue — slice 5 ( complete)
209
+ - **0.1.69** (2026-04-27) — scheduler: cron + interval over jobs/queue — slice 5 (complete)
204
210
  - **0.1.68** (2026-04-27) — mail: generalize http transport, demote resend to thin preset
205
211
  - **0.1.67** (2026-04-27) — mail: contract + console/memory/smtp/resend transports — slice 4
206
212
  - **0.1.66** (2026-04-26) — jobs (define + enqueue + in-process worker) + createApp wiring — slices 2/3
207
213
  - **0.1.65** (2026-04-26) — createApp factory — slice 1
208
- - **0.1.64** (2026-04-26) — forms + csrfProtect — slice 4 ( complete)
214
+ - **0.1.64** (2026-04-26) — forms + csrfProtect — slice 4 (complete)
209
215
  - **0.1.63** (2026-04-26) — staticServe: file serving + ETag + SRI integrity — slice 3
210
216
  - **0.1.62** (2026-04-26) — render: HTTP response helpers — slice 2
211
217
  - **0.1.61** (2026-04-26) — template engine (eval-free interpreter) — slice 1
212
- - **0.1.60** (2026-04-26) — auth.jwt with SLH-DSA-SHAKE-256f default — slice 5/5 ( complete)
218
+ - **0.1.60** (2026-04-26) — auth.jwt with SLH-DSA-SHAKE-256f default — slice 5/5 (complete)
213
219
  - **0.1.59** (2026-04-26) — middleware.attachUser + middleware.requireAuth — slice 4
214
220
  - **0.1.58** (2026-04-26) — auth.passkey (WebAuthn / FIDO2) — slice 3
215
221
  - **0.1.57** (2026-04-26) — TOTP defaults match spec (HMAC-SHA512 / 128-byte secret / 8 digits / 30s) + crypto.random truncation fix
@@ -220,7 +226,7 @@ upgrading across more than a few patches at a time.
220
226
  - **0.1.52** (2026-04-26) — consent_log integrity parity with audit_log (closes the cluster spec storage move)
221
227
  - **0.1.51** (2026-04-26) — queue jobs move to external-db in cluster mode
222
228
  - **0.1.50** (2026-04-26) — sessions move to external-db in cluster mode
223
- - **0.1.49** (2026-04-26) — boot-time vault-key consistency check (closes cluster spec phase 4)
229
+ - **0.1.49** (2026-04-26) — boot-time vault-key consistency check (closes cluster spec)
224
230
  - **0.1.48** (2026-04-26) — boot-time audit-tip rollback detection in cluster mode
225
231
  - **0.1.47** (2026-04-26) — fix handlers.drain unbounded loop on recursive emit (cluster-mode audit hang)
226
232
  - **0.1.46** (2026-04-26) — cluster-mode audit-tip fencing-token guard
@@ -277,7 +283,7 @@ upgrading across more than a few patches at a time.
277
283
  - **0.0.18** (2026-04-26) — strip internal-process narrative from framework comments
278
284
  - **0.0.17** (2026-04-25) — internal adoption sweep + functional scale helpers
279
285
  - **0.0.16** (2026-04-25) — atomic file I/O + safe parsers (XML, CSV)
280
- - **0.0.15** (2026-04-25) — HTTP middleware: request lifecycle hardening ( begin)
286
+ - **0.0.15** (2026-04-25) — HTTP middleware: request lifecycle hardening (begin)
281
287
  - **0.0.14** (2026-04-25) — external DB (Shape B: app-data-only, bring-your-own-client)
282
288
  - **0.0.13** (2026-04-25) — log streaming + redaction + bidirectional command channel
283
289
  - **0.0.12** (2026-04-25) — queue dispatcher + local SQLite-backed protocol
package/README.md CHANGED
@@ -17,7 +17,7 @@ The modern Node app is a 1,200-package supply-chain liability with no LTS calend
17
17
 
18
18
  ## Status
19
19
 
20
- Pre-1.0. Usable end-to-end — operators can build production apps on it today; the surface is still subject to change before 1.0. Recent line is **v0.6.3** ([releases](https://github.com/blamejs/blamejs/releases) · [npm](https://www.npmjs.com/package/@blamejs/core) · [container](https://github.com/blamejs/blamejs/pkgs/container/blamejs-wiki)).
20
+ Pre-1.0. Usable end-to-end — operators can build production apps on it today; the surface is still subject to change before 1.0. The latest release lives on [GitHub](https://github.com/blamejs/blamejs/releases), [npm](https://www.npmjs.com/package/@blamejs/core), and the [container registry](https://github.com/blamejs/blamejs/pkgs/container/blamejs-wiki).
21
21
 
22
22
  ```js
23
23
  var b = require("@blamejs/core");
@@ -41,10 +41,10 @@ var b = require("@blamejs/core");
41
41
 
42
42
  The framework bundles the surface a typical Node app reaches for. Every primitive listed is callable today; nothing is a stub.
43
43
 
44
- - **Data layer** — SQLite with sealed-by-default columns (`b.db`), migrations, seeders, atomic-file writes; S3 / R2 / B2 / GCS / Azure object store with multipart upload + SSE + bucket ops (`b.storage`, `b.objectStore`); durable queue with priority + cron + flows (`b.queue`, `b.jobs`); cluster-shared cache (`b.cache`).
45
- - **Identity & access** — passwords (Argon2id), passkeys (WebAuthn), TOTP, JWT (PQ-default), OAuth, sessions, brute-force lockout (`b.auth.*`, `b.session`); RBAC (`b.permissions`); API keys with rotation (`b.apiKey`); break-glass column gates with second-factor + audit (`b.breakGlass`).
44
+ - **Data layer** — SQLite with sealed-by-default columns (`b.db`), migrations, seeders, atomic-file writes; bring-your-own external Postgres / MySQL / etc. with pool tuning + role-aware connect + read-replica routing (`b.externalDb`); declarative role-narrowed views and Postgres row-level-security migrations (`b.db.declareView`, `b.db.declareRowPolicy`); S3 / R2 / B2 / GCS / Azure object store with multipart upload + SSE + bucket ops (`b.storage`, `b.objectStore`); durable queue with priority + cron + flows (`b.queue`, `b.jobs`); cluster-shared cache (`b.cache`).
45
+ - **Identity & access** — passwords (Argon2id), passkeys (WebAuthn), TOTP, JWT (PQ-default), OAuth, sessions, brute-force lockout (`b.auth.*`, `b.session`); RBAC with optional per-role DB binding (`b.permissions`, role-spec `dbRole` field); API keys with rotation (`b.apiKey`); break-glass column gates with second-factor + audit (`b.breakGlass`).
46
46
  - **Crypto** — envelope-versioned PQC at rest (ML-KEM-1024 + P-384 hybrid, XChaCha20-Poly1305, SHAKE256), vault sealing, field-level crypto, signed webhooks (SLH-DSA-SHAKE-256f), ECIES API encryption (`b.crypto`, `b.vault`, `b.webhook`); pure-JS mTLS CA, PQC TLS gates inbound + outbound (`b.mtlsCa`, `b.pqcGate`, `b.pqcAgent`).
47
- - **HTTP** — router with schema-validated routes + OpenAPI publication; full middleware stack (CSRF, CORS, rate-limit, security headers, CSP nonce, body parser, compression, SSE, request log) wired by `createApp`; HTTP/1.1 + HTTP/2 outbound client with SSRF gate, redirects, multipart, interceptors, progress, encrypted cookie jar (`b.httpClient`, `b.ssrfGuard`, `b.safeUrl`).
47
+ - **HTTP** — router with schema-validated routes + OpenAPI publication; full middleware stack (CSRF, CORS, rate-limit, security headers, CSP nonce, body parser, compression, SSE, request log, request-time DB role binding via `b.middleware.dbRoleFor`) wired by `createApp`; HTTP/1.1 + HTTP/2 outbound client with SSRF gate, redirects, multipart, interceptors, progress, encrypted cookie jar (`b.httpClient`, `b.ssrfGuard`, `b.safeUrl`).
48
48
  - **Defensive parsers** — `b.safeJson`, `b.safeBuffer`, `b.safeSql`, `b.safeSchema`, `b.parsers` (XML / TOML / YAML / .env), `b.config` (schema-validated env).
49
49
  - **Communication** — WebSockets with channel/room fan-out across cluster replicas (`b.websocket`, `b.websocketChannels`); mail with multipart + attachments + DKIM + calendar invites + bounce intake (`b.mail`, `b.mailBounce`); generic notification dispatcher with operator-supplied transports (`b.notify`).
50
50
  - **Observability** — tamper-evident audit chain with SLH-DSA-signed checkpoints, metrics, tracing (OTel pass-through when wired), PII redaction, log-stream sinks, OTLP/HTTP-JSON exporter for any OTel-compatible backend (`b.audit`, `b.metrics`, `b.tracing`, `b.redact`, `b.logStream`, `b.otelExport`).
package/lib/api-key.js CHANGED
@@ -43,14 +43,14 @@
43
43
  * Same dual-storage pattern as sessions — local SQLite in single-node
44
44
  * mode, external-db in cluster mode, dispatched via cluster-storage.
45
45
  *
46
- * Validation tiers:
46
+ * Validation policy:
47
47
  *
48
- * - apiKey.create opts → Tier A (throw)
49
- * - registry.issue opts → Tier A (throw ApiKeyError)
50
- * - registry.rotate(id) on missing/revoked → Tier A (throw)
51
- * - registry.verify(token) on any failure → Tier C (return null)
52
- * - registry.revoke(id) on missing → Tier C (return false)
53
- * - registry.getById(id) on missing → Tier C (return null)
48
+ * - apiKey.create opts → throw at config time
49
+ * - registry.issue opts → throw ApiKeyError at call site
50
+ * - registry.rotate(id) on missing/revoked → throw ApiKeyError at call site
51
+ * - registry.verify(token) on any failure → return null (tolerant read)
52
+ * - registry.revoke(id) on missing → return false (tolerant read)
53
+ * - registry.getById(id) on missing → return null (tolerant read)
54
54
  */
55
55
 
56
56
  var crypto = require("./crypto");
@@ -69,7 +69,7 @@ var observability = lazyRequire(function () { return require("./observability");
69
69
 
70
70
  function _emitEvent(name, value, labels) {
71
71
  try { observability().event(name, value, labels || {}); }
72
- catch (_e) { /* Tier B: hot-path observability sink */ }
72
+ catch (_e) { /* hot-path observability sink — drops silent on internal throws */ }
73
73
  }
74
74
 
75
75
  var _err = ApiKeyError.factory;
@@ -127,7 +127,7 @@ var DEFAULTS = Object.freeze({
127
127
  hashAlgo: "shake256",
128
128
  });
129
129
 
130
- // ---- Tier-A validation helpers ----
130
+ // ---- Config-time validation helpers (throw on bad input) ----
131
131
 
132
132
  function _isPositiveInt(n) {
133
133
  return typeof n === "number" && isFinite(n) && n >= 1 && Math.floor(n) === n;
package/lib/archive.js CHANGED
@@ -216,11 +216,11 @@ function zip() {
216
216
  }
217
217
 
218
218
  function digest() {
219
- // SHA-256 of the produced archive bytes — useful for operator-side
220
- // integrity logging on exported bundles. Not vendor-locked to any
221
- // particular hash; SHA-256 is universally recognized for content
222
- // addressing.
223
- return nodeCrypto.createHash("sha256").update(toBuffer()).digest("hex");
219
+ // SHA3-512 of the produced archive bytes — useful for operator-side
220
+ // integrity logging on exported bundles. Matches the framework's
221
+ // PQC-first hash posture; operators reconciling against an external
222
+ // SHA-256 can hash the bytes themselves.
223
+ return nodeCrypto.createHash("sha3-512").update(toBuffer()).digest("hex");
224
224
  }
225
225
 
226
226
  return {
package/lib/audit.js CHANGED
@@ -203,6 +203,8 @@ var FRAMEWORK_NAMESPACES = [
203
203
  "backup", // b.backup
204
204
  "breakglass", // b.breakGlass — column-policy / row-enforcement step-up auth (audit namespace lowercased per the validator's `namespace.verb` rule, same convention as b.apiKey → apikey.*)
205
205
  "cache", // b.cache
206
+ "db", // b.db / b.middleware.dbRoleFor / b.externalDb.runAs
207
+ // (role-switching, RLS-shaped events)
206
208
  "dkim", // b.mail.dkim (DKIM-Signature generation events)
207
209
  "mail", // b.mail (b.mail-bounce uses "system.mail.*")
208
210
  "notify", // b.notify
@@ -709,8 +711,8 @@ function emit(event) {
709
711
  // audit outage doesn't crash the request handler. This is that helper,
710
712
  // hoisted out so each module can stop redefining it.
711
713
  //
712
- // Tier B (drop-silent): malformed input is dropped without throwing, by
713
- // design. safeEmit is called from request hot paths where throwing on a
714
+ // Drop-silent on malformed input by design. safeEmit is called from
715
+ // request hot paths where throwing on a
714
716
  // missing `action` would mean a malformed audit attempt crashes the
715
717
  // request that triggered it — strictly worse than the missing audit
716
718
  // row. Operators who need a hard guarantee the event landed should call
package/lib/auth/jwt.js CHANGED
@@ -247,10 +247,10 @@ async function verify(token, opts) {
247
247
  if (typeof opts.clockToleranceSec !== "number" ||
248
248
  !isFinite(opts.clockToleranceSec) ||
249
249
  opts.clockToleranceSec < 0) {
250
- // Tier-A: a negative tolerance over-tightens the window (legit
251
- // tokens get rejected as expired or not-yet-valid). A non-finite
252
- // value would NaN-poison the comparison. Catch both at config
253
- // time rather than producing surprise rejections in production.
250
+ // Throw at config time: a negative tolerance over-tightens the
251
+ // window (legit tokens get rejected as expired or not-yet-valid)
252
+ // and a non-finite value would NaN-poison the comparison. Catch
253
+ // both here rather than producing surprise rejections in production.
254
254
  throw new AuthError("auth-jwt/bad-clock-tolerance",
255
255
  "verify: clockToleranceSec must be a non-negative finite number, got " +
256
256
  JSON.stringify(opts.clockToleranceSec));
@@ -17,10 +17,10 @@
17
17
  *
18
18
  * Spec: memory/specs/blamejs-break-glass-spec.md
19
19
  *
20
- * v0.5.0 ships Model A (policy gate) + TOTP factor + Tier-A validation +
21
- * 14 error codes + audit chain integration. Model B (cryptographic
22
- * gate via per-row K_row), passkey factor, service-account bypass,
23
- * and admin tools land in v0.5.1 / v0.5.2 per the phasing plan.
20
+ * Ships Model A (policy gate) + TOTP factor + config-time input
21
+ * validation + 14 error codes + audit chain integration. Model B
22
+ * (cryptographic gate via per-row K_row), passkey factor, service-
23
+ * account bypass, and admin tools land in subsequent patches.
24
24
  *
25
25
  * Public API:
26
26
  *
@@ -59,8 +59,9 @@ var lockout = lazyRequire(function () { return require("./auth/lockout"); });
59
59
  var passkey = lazyRequire(function () { return require("./auth/passkey"); });
60
60
 
61
61
  // Errors — all 14 codes documented in the spec. `permanent: true`
62
- // means caller's input is bad (Tier-A); `permanent: false` means
63
- // transient (factor failed, rate-limited) — caller may retry.
62
+ // means caller's input is bad (config-time / call-site reject);
63
+ // `permanent: false` means transient (factor failed, rate-limited)
64
+ // — caller may retry.
64
65
  var BreakGlassError = defineClass("BreakGlassError", { alwaysPermanent: false });
65
66
 
66
67
  // ---- Defaults (matched to operator-locked decisions) ----
package/lib/cache.js CHANGED
@@ -51,14 +51,14 @@
51
51
  * { get, set, del, clear, size, close } — operator-supplied custom
52
52
  * backend (Redis, Memcached, …). All methods async.
53
53
  *
54
- * Validation tiers:
54
+ * Validation policy:
55
55
  *
56
- * - create() opts → Tier A (throw at boot)
57
- * - get/set/del/has/wrap key arg type → Tier A (throw programming bug)
58
- * - set value type → C (operator decides what to store)
59
- * - per-call ttlMs override → Tier A (throw bad ttl is silent footgun)
60
- * - audit / observability emit failures → Tier B (drop silent)
61
- * - method called after close() → Tier A (throw BAD_STATE)
56
+ * - create() opts → throw at boot
57
+ * - get/set/del/has/wrap key arg type → throw at call site (programming bug)
58
+ * - set value type → tolerant (operator decides what to store)
59
+ * - per-call ttlMs override → throw at call site (bad ttl is silent footgun)
60
+ * - audit / observability emit failures → drop silent (hot-path sink)
61
+ * - method called after close() → throw BAD_STATE at call site
62
62
  *
63
63
  * Security defaults:
64
64
  *
@@ -114,7 +114,7 @@ var DEFAULTS = Object.freeze({
114
114
  auditClear: true,
115
115
  });
116
116
 
117
- // ---- Tier-A validation helpers ----
117
+ // ---- Config-time validation helpers (throw on bad input) ----
118
118
 
119
119
  function _isFiniteNonNegative(n) {
120
120
  return typeof n === "number" && isFinite(n) && n >= 0;
@@ -669,7 +669,7 @@ function create(opts) {
669
669
  try {
670
670
  if (operatorObs) operatorObs.event(name, 1, labels || {});
671
671
  else observability().event(name, 1, labels || {});
672
- } catch (_e) { /* Tier B: hot-path observability sink */ }
672
+ } catch (_e) { /* hot-path observability sink — drops silent on internal throws */ }
673
673
  }
674
674
 
675
675
  function emitAudit(action, info) {
package/lib/constants.js CHANGED
@@ -18,10 +18,10 @@ var pkg = require("../package.json");
18
18
  // reader sees the unit at the call site (`C.TIME.minutes(45)` instead of
19
19
  // adding a new FORTY_FIVE_MIN constant). All return integer milliseconds.
20
20
  //
21
- // Tier A validation: these are config-time helpers. Throw on non-finite
22
- // or negative input so a typo (`C.TIME.minutes(opts.x)` where opts.x is
23
- // undefined) surfaces at boot instead of silently becoming `NaN` or `0`
24
- // and shipping a 0ms timeout into production.
21
+ // Throw on non-finite or negative input — these are config-time helpers,
22
+ // so a typo (`C.TIME.minutes(opts.x)` where opts.x is undefined) should
23
+ // surface at boot instead of silently becoming `NaN` or `0` and shipping
24
+ // a 0ms timeout into production.
25
25
  function _validateDuration(unit, n) {
26
26
  if (typeof n !== "number" || !isFinite(n) || n < 0) {
27
27
  throw new TypeError("C.TIME." + unit + ": expected non-negative finite number, got " +
@@ -42,8 +42,8 @@ var TIME = Object.freeze({
42
42
  // (KiB/MiB/GiB), since every existing byte literal in the framework
43
43
  // already uses 1024 multiplication.
44
44
  //
45
- // Tier A validation: same rationale as TIME bad input surfaces at the
46
- // call site, not as a silent NaN cap that disables size limits.
45
+ // Throw on bad input — same rationale as TIME: bad input surfaces at
46
+ // the call site, not as a silent NaN cap that disables size limits.
47
47
  function _validateBytes(unit, n) {
48
48
  if (typeof n !== "number" || !isFinite(n) || n < 0) {
49
49
  throw new TypeError("C.BYTES." + unit + ": expected non-negative finite number, got " +
@@ -61,13 +61,13 @@
61
61
  * - Transparent rehash via needsRehash() drains old algos at the
62
62
  * pace of organic verify traffic.
63
63
  *
64
- * Validation tiers:
64
+ * Validation policy:
65
65
  *
66
- * - hash() opts (algo, params) → Tier A (throw)
67
- * - hash() secret type / length → Tier A (throw)
68
- * - verify() envelope shape unparsable → Tier C (return false)
69
- * - verify() unknown algo ID → Tier C (return false)
70
- * - inspect() bad envelope → Tier C (return null)
66
+ * - hash() opts (algo, params) → throw at call site
67
+ * - hash() secret type / length → throw at call site
68
+ * - verify() envelope shape unparsable → return false (tolerant read)
69
+ * - verify() unknown algo ID → return false (tolerant read)
70
+ * - inspect() bad envelope → return null (tolerant read)
71
71
  */
72
72
 
73
73
  var crypto = require("./crypto");
@@ -79,7 +79,7 @@ var observability = lazyRequire(function () { return require("./observability");
79
79
 
80
80
  function _emitEvent(name, value, labels) {
81
81
  try { observability().event(name, value, labels || {}); }
82
- catch (_e) { /* Tier B: hot-path observability sink */ }
82
+ catch (_e) { /* hot-path observability sink — drops silent on internal throws */ }
83
83
  }
84
84
 
85
85
  // Default SHAKE256 output size for newly-issued credentials. 128
@@ -135,7 +135,7 @@ var ID_TO_NAME = Object.freeze({
135
135
  0x02: "argon2id",
136
136
  });
137
137
 
138
- // ---- Tier-A validation ----
138
+ // ---- Call-site validation (throw on bad input) ----
139
139
 
140
140
  function _validateSecret(secret) {
141
141
  if (typeof secret !== "string" && !Buffer.isBuffer(secret)) {
@@ -223,7 +223,7 @@ async function hash(secret, opts) {
223
223
  }
224
224
 
225
225
  async function verify(secret, envelope) {
226
- // Tier C: any malformed input → false. Lets operators write
226
+ // Tolerant read: any malformed envelope → false. Lets operators write
227
227
  // if (!await ch.verify(s, row.hash)) return res.status(401);
228
228
  // without try/catch ceremony. We still reject obvious caller bugs
229
229
  // (non-string-or-Buffer secret) loudly because that signals broken
@@ -39,7 +39,7 @@
39
39
  * typos surface as clear errors at the migrate command, not as silent
40
40
  * empty views or grant-to-nonexistent-role footguns.
41
41
  *
42
- * Tier-A validation at declareView() call time:
42
+ * Throw at declareView() call time on:
43
43
  * - schema, name, source segments → safeSql.validateIdentifier
44
44
  * - column names in redactColumns / sealedColumns / hashColumns →
45
45
  * safeSql.validateIdentifier
@@ -303,7 +303,7 @@ function declareView(opts) {
303
303
  var qSource = safeSql.quoteQualified([spec.source.schema, spec.source.name], "postgres");
304
304
 
305
305
  async function up(xdb, ctx) {
306
- // Tier-A boundary: confirm we're on Postgres before any DDL leaves the process.
306
+ // Boundary throw: confirm we're on Postgres before any DDL leaves the process.
307
307
  if (ctx && ctx.externalDb && ctx.backendName) {
308
308
  _ensureBackendIsPostgres(ctx.externalDb, ctx.backendName);
309
309
  }
@@ -208,7 +208,7 @@ function _emit(audit, action, outcome, info, reason) {
208
208
  metadata: info || {},
209
209
  reason: reason || null,
210
210
  });
211
- } catch (_e) { /* Tier B: drop-silent */ }
211
+ } catch (_e) { /* drop-silent — audit emit failure must not crash the migration */ }
212
212
  }
213
213
 
214
214
  // ---- Backend resolution ----
@@ -58,8 +58,14 @@ var safeAsync = require("./safe-async");
58
58
  var safeSql = require("./safe-sql");
59
59
  var { ExternalDbError } = require("./framework-error");
60
60
 
61
- var audit = lazyRequire(function () { return require("./audit"); });
62
- var db = lazyRequire(function () { return require("./db"); });
61
+ var audit = lazyRequire(function () { return require("./audit"); });
62
+ var db = lazyRequire(function () { return require("./db"); });
63
+ var observability = lazyRequire(function () { return require("./observability"); });
64
+
65
+ function _emitMetric(name, value, labels) {
66
+ try { observability().event(name, value, labels || {}); }
67
+ catch (_e) { /* hot-path observability sink — drop silent by design */ }
68
+ }
63
69
 
64
70
  var _err = ExternalDbError.factory;
65
71
 
@@ -104,13 +110,21 @@ class Pool {
104
110
  throw e;
105
111
  }
106
112
  }
107
- // At max — wait for a release
108
- return new Promise((function (self) {
109
- return function (resolve, reject) {
110
- var entry = { resolve: resolve, reject: reject };
111
- self.waiters.push(entry);
112
- };
113
- })(this));
113
+ // At max — wait for a release. The waiter's clock starts now;
114
+ // when release() resolves the waiter we emit the wait duration so
115
+ // operators can see backpressure on the pool.
116
+ var self = this;
117
+ var waitStartedAt = Date.now();
118
+ return new Promise(function (resolve, reject) {
119
+ self.waiters.push({
120
+ resolve: function (client) {
121
+ _emitMetric("externaldb.pool.acquire_wait", Date.now() - waitStartedAt,
122
+ { backend: self.name });
123
+ resolve(client);
124
+ },
125
+ reject: reject,
126
+ });
127
+ });
114
128
  }
115
129
 
116
130
  release(client) {
@@ -318,6 +332,7 @@ async function query(sql, params, opts) {
318
332
  _requireInit();
319
333
  opts = opts || {};
320
334
  var b = _pickBackend(opts);
335
+ var role = dbRoleContext.getRole();
321
336
 
322
337
  var t0 = Date.now();
323
338
  try {
@@ -344,9 +359,11 @@ async function query(sql, params, opts) {
344
359
  });
345
360
  }, b.retryConfig);
346
361
 
362
+ var durationMs = Date.now() - t0;
347
363
  _emit("system.externaldb.query", "success", {
348
364
  backend: b.name,
349
- durationMs: Date.now() - t0,
365
+ role: role,
366
+ durationMs: durationMs,
350
367
  classification: opts.classification || null,
351
368
  rowCount: result && result.rowCount,
352
369
  // SQL is NOT logged by default — may contain sensitive literal values
@@ -355,14 +372,33 @@ async function query(sql, params, opts) {
355
372
  // field-crypto on the audit row).
356
373
  sql: opts.includeSqlInAudit ? sql : null,
357
374
  });
375
+ _emitMetric("externaldb.query.success", 1,
376
+ { backend: b.name, role: role || "(none)" });
377
+ _emitMetric("externaldb.query.duration_ms", durationMs,
378
+ { backend: b.name, role: role || "(none)" });
358
379
  return result;
359
380
  } catch (e) {
381
+ var failureMs = Date.now() - t0;
360
382
  _emit("system.externaldb.query", "failure", {
361
383
  backend: b.name,
362
- durationMs: Date.now() - t0,
384
+ role: role,
385
+ durationMs: failureMs,
363
386
  classification: opts.classification || null,
364
387
  errorCode: e.code || null,
365
388
  }, (e && e.message) || String(e));
389
+ _emitMetric("externaldb.query.failure", 1,
390
+ { backend: b.name, role: role || "(none)", errorCode: e.code || "(none)" });
391
+ // Postgres signals authorization-denied as SQLSTATE 42501
392
+ // (insufficient_privilege). RLS-shaped writes that violate a
393
+ // policy and GRANT-denied SELECTs both surface this code. The
394
+ // operator's role-views recipe relies on this signal: a row of
395
+ // db.role.denied means a request-time role attempted something its
396
+ // grant or RLS policy forbids — the highest-signal compliance event
397
+ // the externalDb layer can emit.
398
+ if (e && e.code === "42501") {
399
+ _emitMetric("db.role.denied", 1,
400
+ { backend: b.name, role: role || "(none)" });
401
+ }
366
402
  throw e;
367
403
  }
368
404
  }
@@ -372,6 +408,7 @@ async function transaction(fn, opts) {
372
408
  if (typeof fn !== "function") throw _err("INVALID_FN", "transaction requires a function", true);
373
409
  opts = opts || {};
374
410
  var b = _pickBackend(opts);
411
+ var role = dbRoleContext.getRole();
375
412
 
376
413
  // sessionGucs — per-transaction `SET LOCAL "name" = value` plumbing.
377
414
  // Each name validates as a SQL identifier (Postgres GUC names follow
@@ -398,16 +435,30 @@ async function transaction(fn, opts) {
398
435
  var result = await fn(txClient);
399
436
  await b.commit(client);
400
437
  committed = true;
438
+ var durationMs = Date.now() - t0;
401
439
  _emit("system.externaldb.transaction", "success", {
402
- backend: b.name, durationMs: Date.now() - t0, classification: opts.classification || null,
440
+ backend: b.name, role: role, durationMs: durationMs,
441
+ classification: opts.classification || null,
403
442
  });
443
+ _emitMetric("externaldb.transaction.success", 1,
444
+ { backend: b.name, role: role || "(none)" });
445
+ _emitMetric("externaldb.transaction.duration_ms", durationMs,
446
+ { backend: b.name, role: role || "(none)" });
404
447
  return result;
405
448
  } catch (e) {
406
449
  try { if (!committed) await b.rollback(client); } catch (_e) { /* best effort */ }
450
+ var failureMs = Date.now() - t0;
407
451
  _emit("system.externaldb.transaction", "failure", {
408
- backend: b.name, durationMs: Date.now() - t0, classification: opts.classification || null,
452
+ backend: b.name, role: role, durationMs: failureMs,
453
+ classification: opts.classification || null,
409
454
  errorCode: e.code || null,
410
455
  }, (e && e.message) || String(e));
456
+ _emitMetric("externaldb.transaction.failure", 1,
457
+ { backend: b.name, role: role || "(none)", errorCode: e.code || "(none)" });
458
+ if (e && e.code === "42501") {
459
+ _emitMetric("db.role.denied", 1,
460
+ { backend: b.name, role: role || "(none)" });
461
+ }
411
462
  throw e;
412
463
  } finally {
413
464
  b.pool.release(client);
@@ -651,6 +702,7 @@ async function _readQuery(sql, params, opts) {
651
702
  throw _err("ALL_REPLICAS_UNHEALTHY",
652
703
  "backend '" + b.name + "': all replicas unhealthy and fallback disabled", true);
653
704
  }
705
+ var role = dbRoleContext.getRole();
654
706
  var t0 = Date.now();
655
707
  try {
656
708
  var client = await replica.pool.acquire();
@@ -658,12 +710,18 @@ async function _readQuery(sql, params, opts) {
658
710
  var res = await replica.query(client, sql, params || []);
659
711
  replica.pool.release(client);
660
712
  replica.consecutiveFailures = 0;
713
+ var durationMs = Date.now() - t0;
661
714
  _emit("system.externaldb.read", "success", {
662
715
  backend: b.name,
716
+ role: role,
663
717
  replicaIdx: replica.index,
664
- durationMs: Date.now() - t0,
718
+ durationMs: durationMs,
665
719
  rowCount: res && res.rowCount,
666
720
  });
721
+ _emitMetric("externaldb.read.success", 1,
722
+ { backend: b.name, role: role || "(none)", replicaIdx: replica.index });
723
+ _emitMetric("externaldb.read.duration_ms", durationMs,
724
+ { backend: b.name, role: role || "(none)", replicaIdx: replica.index });
667
725
  return res;
668
726
  } catch (e) {
669
727
  // Connection-shape errors mark unhealthy + destroy.
@@ -681,10 +739,17 @@ async function _readQuery(sql, params, opts) {
681
739
  } catch (e) {
682
740
  _emit("system.externaldb.read", "failure", {
683
741
  backend: b.name,
742
+ role: role,
684
743
  replicaIdx: replica.index,
685
744
  durationMs: Date.now() - t0,
686
745
  errorCode: e.code || null,
687
746
  }, (e && e.message) || String(e));
747
+ _emitMetric("externaldb.read.failure", 1,
748
+ { backend: b.name, role: role || "(none)", errorCode: e.code || "(none)" });
749
+ if (e && e.code === "42501") {
750
+ _emitMetric("db.role.denied", 1,
751
+ { backend: b.name, role: role || "(none)" });
752
+ }
688
753
  // Fallback to primary on a failed replica read when allowed.
689
754
  if (b.replicaFallbackToPrimary) {
690
755
  return query(sql, params, opts);
@@ -936,6 +1001,27 @@ function runAs(role, fn) {
936
1001
  }
937
1002
  safeSql.validateIdentifier(role, { allowReserved: false });
938
1003
  }
1004
+ // Audit the role transition. runAs has no req, so the actor 5 W's
1005
+ // come from whatever the caller has bound on the audit-context ALS
1006
+ // (log.js requestId, plus any request-bound actor that was set in
1007
+ // an outer scope). Same audit shape as the dbRoleFor middleware
1008
+ // path — forensic walkers can reconstruct the role timeline whether
1009
+ // the binding came from request middleware or a job runner.
1010
+ var previousRole = dbRoleContext.getRole();
1011
+ var newRole = role || null;
1012
+ if (previousRole !== newRole) {
1013
+ audit().safeEmit({
1014
+ action: "db.role.switched",
1015
+ actor: {},
1016
+ resource: { kind: "db.role", id: newRole || "(none)" },
1017
+ outcome: "success",
1018
+ metadata: {
1019
+ previousRole: previousRole,
1020
+ newRole: newRole,
1021
+ source: "runAs",
1022
+ },
1023
+ });
1024
+ }
939
1025
  return dbRoleContext.runWithRole(role || null, fn);
940
1026
  }
941
1027
 
@@ -480,8 +480,8 @@ function request(opts) {
480
480
  return Promise.reject(_makeError(opts && opts.errorClass, "BAD_ARG", "url is required", true));
481
481
  }
482
482
 
483
- // Validate before/after shapes early — Tier-A throw if the operator
484
- // passed something un-callable so the bug surfaces at the call site
483
+ // Validate before/after shapes early — throw at call site if the
484
+ // operator passed something un-callable so the bug surfaces here
485
485
  // rather than inside the request loop.
486
486
  if (opts.before !== undefined) {
487
487
  if (!Array.isArray(opts.before) || !opts.before.every(function (f) { return typeof f === "function"; })) {
@@ -896,7 +896,7 @@ function _requestH1(transport, u, opts) {
896
896
  var bodyBuf = Buffer.isBuffer(opts.body) ? opts.body : Buffer.from(opts.body, "utf8");
897
897
  if (onUploadProgress) {
898
898
  // Chunked write so progress reports land before req.end().
899
- var CHUNK = 64 * 1024;
899
+ var CHUNK = C.BYTES.kib(64);
900
900
  var off = 0;
901
901
  while (off < bodyBuf.length) {
902
902
  var slice = bodyBuf.slice(off, Math.min(off + CHUNK, bodyBuf.length));