@blamejs/core 0.14.19 → 0.14.21

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 (41) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +1 -1
  3. package/lib/auth/oauth.js +736 -1
  4. package/lib/auth/oid4vci.js +124 -5
  5. package/lib/auth/oid4vp.js +14 -4
  6. package/lib/auth/sd-jwt-vc-holder.js +46 -1
  7. package/lib/break-glass.js +1 -2
  8. package/lib/config.js +28 -31
  9. package/lib/crypto-field.js +274 -17
  10. package/lib/dora.js +8 -5
  11. package/lib/dsr.js +2 -2
  12. package/lib/flag-evaluation-context.js +7 -0
  13. package/lib/guard-html-wcag-aria.js +4 -2
  14. package/lib/guard-html-wcag-forms.js +4 -2
  15. package/lib/guard-html-wcag-tables.js +4 -2
  16. package/lib/guard-html-wcag-tagwalk.js +20 -0
  17. package/lib/guard-html-wcag.js +1 -1
  18. package/lib/honeytoken.js +27 -20
  19. package/lib/mail-auth.js +333 -0
  20. package/lib/mail-deploy.js +1 -1
  21. package/lib/mail-send-deliver.js +13 -4
  22. package/lib/middleware/api-encrypt.js +140 -13
  23. package/lib/middleware/asyncapi-serve.js +3 -0
  24. package/lib/middleware/csp-report.js +13 -9
  25. package/lib/middleware/fetch-metadata.js +115 -14
  26. package/lib/middleware/openapi-serve.js +3 -0
  27. package/lib/middleware/scim-server.js +297 -19
  28. package/lib/middleware/security-headers.js +47 -0
  29. package/lib/middleware/security-txt.js +1 -2
  30. package/lib/middleware/trace-log-correlation.js +1 -2
  31. package/lib/network-smtp-policy.js +4 -4
  32. package/lib/object-store/sigv4-bucket-ops.js +11 -2
  33. package/lib/observability-tracer.js +1 -1
  34. package/lib/observability.js +39 -1
  35. package/lib/problem-details.js +56 -11
  36. package/lib/pubsub-cluster.js +16 -3
  37. package/lib/queue-sqs.js +20 -2
  38. package/lib/redis-client.js +32 -4
  39. package/lib/safe-redirect.js +16 -2
  40. package/package.json +1 -1
  41. package/sbom.cdx.json +6 -6
@@ -45,9 +45,19 @@
45
45
  var lazyRequire = require("./lazy-require");
46
46
  var vault = require("./vault");
47
47
  var vaultAad = require("./vault-aad");
48
+ var validateOpts = require("./validate-opts");
49
+ var numericBounds = require("./numeric-bounds");
50
+ var { defineClass } = require("./framework-error");
48
51
  var { sha3Hash, kdf } = require("./crypto");
49
52
  var { HASH_PREFIX, VAULT_PREFIX, TIME } = require("./constants");
50
53
 
54
+ // Typed refusal raised when a (actor, table, column) tuple exceeds the
55
+ // opt-in unseal-failure rate cap and is in cooldown. alwaysPermanent —
56
+ // the caller does not retry; the cooldown is a deliberate, time-bounded
57
+ // circuit-breaker, not a transient backend hiccup.
58
+ var CryptoFieldRateError = defineClass("CryptoFieldRateError", { alwaysPermanent: true });
59
+ var CryptoFieldError = defineClass("CryptoFieldError", { alwaysPermanent: true });
60
+
51
61
  var compliance = lazyRequire(function () { return require("./compliance"); });
52
62
  var db = lazyRequire(function () { return require("./db"); });
53
63
  var audit = lazyRequire(function () { return require("./audit"); });
@@ -187,7 +197,8 @@ function registerTable(name, opts) {
187
197
  var schemaVersion = opts.schemaVersion != null ? String(opts.schemaVersion) : "1";
188
198
  var derivedHashMode = opts.derivedHashMode || "salted-sha3";
189
199
  if (derivedHashMode !== "salted-sha3" && derivedHashMode !== "hmac-shake256") {
190
- throw new Error("registerTable: derivedHashMode must be 'salted-sha3' (default) or " +
200
+ throw new CryptoFieldError("crypto-field/bad-derived-hash-mode",
201
+ "registerTable: derivedHashMode must be 'salted-sha3' (default) or " +
191
202
  "'hmac-shake256', got " + JSON.stringify(derivedHashMode));
192
203
  }
193
204
  var derivedHashes = Object.assign({}, opts.derivedHashes || {});
@@ -195,7 +206,8 @@ function registerTable(name, opts) {
195
206
  if (!Object.prototype.hasOwnProperty.call(derivedHashes, col)) continue;
196
207
  var colMode = derivedHashes[col] && derivedHashes[col].mode;
197
208
  if (colMode !== undefined && colMode !== "salted-sha3" && colMode !== "hmac-shake256") {
198
- throw new Error("registerTable: derivedHashes." + col + ".mode must be " +
209
+ throw new CryptoFieldError("crypto-field/bad-derived-hash-col-mode",
210
+ "registerTable: derivedHashes." + col + ".mode must be " +
199
211
  "'salted-sha3' or 'hmac-shake256', got " + JSON.stringify(colMode));
200
212
  }
201
213
  }
@@ -285,14 +297,16 @@ function computeNamespacedHash(ns, value, opts) {
285
297
  opts = opts || {};
286
298
  var mode = opts.mode || "salted-sha3";
287
299
  if (mode !== "salted-sha3" && mode !== "hmac-shake256") {
288
- throw new Error("computeNamespacedHash: opts.mode must be 'salted-sha3' " +
300
+ throw new CryptoFieldError("crypto-field/bad-namespaced-hash-mode",
301
+ "computeNamespacedHash: opts.mode must be 'salted-sha3' " +
289
302
  "(default) or 'hmac-shake256', got " + JSON.stringify(mode));
290
303
  }
291
304
  var truncateBytes = opts.truncateBytes;
292
305
  if (truncateBytes !== undefined) {
293
306
  if (typeof truncateBytes !== "number" || !isFinite(truncateBytes) ||
294
307
  truncateBytes <= 0 || Math.floor(truncateBytes) !== truncateBytes) {
295
- throw new Error("computeNamespacedHash: opts.truncateBytes must be a " +
308
+ throw new CryptoFieldError("crypto-field/bad-truncate-bytes",
309
+ "computeNamespacedHash: opts.truncateBytes must be a " +
296
310
  "positive integer (bytes), got " + JSON.stringify(truncateBytes));
297
311
  }
298
312
  }
@@ -422,6 +436,202 @@ function computeDerived(table, sourceField, sourceValue) {
422
436
  return null;
423
437
  }
424
438
 
439
+ // ---- Unseal-failure rate cap (CWE-307) ----
440
+ //
441
+ // Opt-in brute-force / decryption-oracle throttle for the unsealRow read
442
+ // path. A DB-write attacker who can write `vault:<crafted>` /
443
+ // `vault.aad:<crafted>` payloads to sealed columns can force KEM
444
+ // decapsulation / AEAD verify on attacker-controlled bytes on every read.
445
+ // unsealRow already nulls the field + emits system.crypto.unseal_failed,
446
+ // but absent a cap the attacker can hammer the oracle indefinitely and
447
+ // only an off-band operator alert rule catches the burst. This adds an
448
+ // in-process per-(actor, table, column) sliding-window failure cap: past
449
+ // `threshold` failures inside `windowMs`, further unseal attempts for that
450
+ // tuple are refused for `cooldownMs` with a typed CryptoFieldRateError and
451
+ // a distinct system.crypto.unseal_rate_exceeded audit row.
452
+ //
453
+ // Default OFF — when no cap is configured, unsealRow behaves exactly as
454
+ // before (null-the-field + audit-only). Composes the same timestamp-array
455
+ // sliding-window shape used by b.mail.server.rateLimit (_pruneWindow):
456
+ // count-based, lazily pruned on read, no background timer.
457
+ //
458
+ // CWE-307 (Improper Restriction of Excessive Authentication Attempts —
459
+ // generalized here to excessive decryption-oracle attempts); OWASP ASVS
460
+ // v5 §2.2.1 (anti-automation); NIST SP 800-63B §5.2.2 (rate limiting).
461
+ var _rateCap = null; // null = disabled
462
+ var _rateFailWindows = new Map(); // "actor\x00table\x00column" → [tsMs, ...]
463
+ var _rateCooldowns = new Map(); // same key → cooldownUntilMs
464
+
465
+ // Tuple key. \x00 is not a legal column / table identifier byte and is
466
+ // vanishingly unlikely in an actor id, so the join is unambiguous; the
467
+ // composite is only ever a Map key (never an object property), so no
468
+ // prototype-pollution surface.
469
+ function _rateKey(actor, table, column) {
470
+ return String(actor) + "\x00" + table + "\x00" + column;
471
+ }
472
+
473
+ /**
474
+ * @primitive b.cryptoField.configureUnsealRateCap
475
+ * @signature b.cryptoField.configureUnsealRateCap(opts)
476
+ * @since 0.14.20
477
+ * @compliance hipaa, gdpr, pci-dss
478
+ * @related b.cryptoField.unsealRow, b.cryptoField.clearRateCapForTest
479
+ *
480
+ * Opt into a per-(actor, table, column) cap on sealed-column unseal
481
+ * FAILURES. By default (unconfigured) `unsealRow` only nulls the field
482
+ * and emits `system.crypto.unseal_failed` on a forged-ciphertext read —
483
+ * an attacker who can write `vault:<crafted>` payloads can hammer the
484
+ * KEM-decapsulation / AEAD-verify oracle indefinitely, and only an
485
+ * off-band operator alert rule catches the burst. With a cap configured,
486
+ * once a single tuple accrues `threshold` failures inside `windowMs`,
487
+ * every subsequent `unsealRow` touching that tuple is REFUSED for
488
+ * `cooldownMs` with a `CryptoFieldRateError` and a distinct
489
+ * `system.crypto.unseal_rate_exceeded` audit row, bounding the oracle.
490
+ *
491
+ * Pass `null` (or `{ disabled: true }`) to turn the cap back off. This is
492
+ * a behaviour-changing refusal gate, so it is opt-in: unconfigured
493
+ * deployments keep today's audit-only behaviour with full back-compat.
494
+ * Validation is config-time / entry-point tier — bad `threshold` /
495
+ * `windowMs` / `cooldownMs` THROW so an operator catches the typo at
496
+ * boot rather than silently disabling the cap.
497
+ *
498
+ * CWE-307 (excessive-attempt restriction); OWASP ASVS v5 §2.2.1;
499
+ * NIST SP 800-63B §5.2.2.
500
+ *
501
+ * @opts
502
+ * threshold: number, // failures within the window before refusal kicks in (positive int)
503
+ * windowMs: number, // sliding-window width in ms (positive int; default 60000)
504
+ * cooldownMs: number, // refusal duration once tripped (positive int; default windowMs)
505
+ * disabled: boolean, // pass true to turn the cap off (same as configureUnsealRateCap(null))
506
+ * now: function, // injected clock returning epoch ms; default Date.now (test seam)
507
+ * onAudit: function, // optional sink({ action, outcome, metadata }) for the rate audit (test seam)
508
+ *
509
+ * @example
510
+ * b.cryptoField.configureUnsealRateCap({ threshold: 5, windowMs: 60000, cooldownMs: 300000 });
511
+ * // ...after 5 forged-ciphertext unseal failures for one (actor, table, column):
512
+ * try { b.cryptoField.unsealRow("patients", forgedRow, "actor-42"); }
513
+ * catch (e) { e.code; } // → "crypto-field/unseal-rate-exceeded"
514
+ *
515
+ * b.cryptoField.configureUnsealRateCap(null); // → disable again
516
+ */
517
+ function configureUnsealRateCap(opts) {
518
+ if (opts === null || opts === undefined || opts.disabled === true) {
519
+ _rateCap = null;
520
+ _rateFailWindows.clear();
521
+ _rateCooldowns.clear();
522
+ return null;
523
+ }
524
+ validateOpts(opts, ["threshold", "windowMs", "cooldownMs", "disabled", "now", "onAudit"],
525
+ "cryptoField.configureUnsealRateCap");
526
+ // threshold is required (no default); presence-guard first, then validate
527
+ // the three positive-int bounds through the shared batch helper so the
528
+ // get-or-default and the bound checks each live in one place.
529
+ if (opts.threshold === undefined) {
530
+ throw new CryptoFieldRateError("crypto-field/bad-threshold",
531
+ "cryptoField.configureUnsealRateCap: opts.threshold is required and must be a positive finite integer");
532
+ }
533
+ numericBounds.requireAllPositiveFiniteIntIfPresent(opts,
534
+ ["threshold", "windowMs", "cooldownMs"],
535
+ "cryptoField.configureUnsealRateCap", CryptoFieldRateError, "crypto-field/bad-rate-cap-opt");
536
+ validateOpts.optionalFunction(opts.now,
537
+ "cryptoField.configureUnsealRateCap: opts.now", CryptoFieldRateError,
538
+ "crypto-field/bad-now");
539
+ validateOpts.optionalFunction(opts.onAudit,
540
+ "cryptoField.configureUnsealRateCap: opts.onAudit", CryptoFieldRateError,
541
+ "crypto-field/bad-on-audit");
542
+
543
+ var windowMs = opts.windowMs === undefined ? TIME.minutes(1) : opts.windowMs;
544
+ _rateCap = {
545
+ threshold: opts.threshold,
546
+ windowMs: windowMs,
547
+ cooldownMs: opts.cooldownMs === undefined ? windowMs : opts.cooldownMs,
548
+ now: typeof opts.now === "function" ? opts.now : function () { return Date.now(); },
549
+ onAudit: typeof opts.onAudit === "function" ? opts.onAudit : null,
550
+ };
551
+ // Re-arming with a fresh config drops any in-flight windows/cooldowns
552
+ // so a config change can't leave a tuple stuck in an old cooldown.
553
+ _rateFailWindows.clear();
554
+ _rateCooldowns.clear();
555
+ return { threshold: _rateCap.threshold, windowMs: _rateCap.windowMs, cooldownMs: _rateCap.cooldownMs };
556
+ }
557
+
558
+ // Emit the distinct rate-exceeded audit. Drop-silent (hot-path sink): a
559
+ // throwing audit on the read path must not crash the request that
560
+ // triggered it. Honors the injected onAudit test sink when present,
561
+ // otherwise routes through the framework audit chain.
562
+ function _emitRateAudit(metadata) {
563
+ try {
564
+ if (_rateCap && _rateCap.onAudit) {
565
+ _rateCap.onAudit({ action: "system.crypto.unseal_rate_exceeded", outcome: "denied", metadata: metadata });
566
+ return;
567
+ }
568
+ audit().safeEmit({ action: "system.crypto.unseal_rate_exceeded", outcome: "denied", metadata: metadata });
569
+ } catch (_e) { /* drop-silent — audit best-effort */ }
570
+ }
571
+
572
+ // Pre-unseal gate. Returns true when the tuple is currently in cooldown
573
+ // (caller must refuse). No-op (returns false) when the cap is disabled.
574
+ // Prunes an expired cooldown lazily so the Map can't grow unbounded.
575
+ function _rateInCooldown(actor, table, column) {
576
+ if (!_rateCap) return false;
577
+ var key = _rateKey(actor, table, column);
578
+ var until = _rateCooldowns.get(key);
579
+ if (until === undefined) return false;
580
+ if (_rateCap.now() >= until) {
581
+ _rateCooldowns.delete(key);
582
+ _rateFailWindows.delete(key);
583
+ return false;
584
+ }
585
+ return true;
586
+ }
587
+
588
+ // Post-failure accounting. Records one failure timestamp for the tuple,
589
+ // prunes the sliding window, and arms a cooldown when the count reaches
590
+ // the threshold. Returns true when this failure tripped the cap (so the
591
+ // caller can emit the rate audit exactly once on the transition).
592
+ function _rateNoteFailure(actor, table, column) {
593
+ if (!_rateCap) return false;
594
+ var nowMs = _rateCap.now();
595
+ var key = _rateKey(actor, table, column);
596
+ var arr = _rateFailWindows.get(key);
597
+ if (!arr) { arr = []; _rateFailWindows.set(key, arr); }
598
+ // Prune entries older than the window (sliding-window via timestamp
599
+ // array — same shape as b.mail.server.rateLimit._pruneWindow).
600
+ var cutoff = nowMs - _rateCap.windowMs;
601
+ var drop = 0;
602
+ while (drop < arr.length && arr[drop] < cutoff) drop += 1;
603
+ if (drop > 0) arr.splice(0, drop);
604
+ arr.push(nowMs);
605
+ if (arr.length >= _rateCap.threshold) {
606
+ _rateCooldowns.set(key, nowMs + _rateCap.cooldownMs);
607
+ return true;
608
+ }
609
+ return false;
610
+ }
611
+
612
+ /**
613
+ * @primitive b.cryptoField.clearRateCapForTest
614
+ * @signature b.cryptoField.clearRateCapForTest()
615
+ * @since 0.14.20
616
+ * @status experimental
617
+ * @related b.cryptoField.configureUnsealRateCap
618
+ *
619
+ * Test-only helper. Disables the unseal-failure rate cap and drops every
620
+ * in-flight sliding-window + cooldown entry so a fixture can re-configure
621
+ * the cap between cases. Operator code never calls this — production
622
+ * deployments configure the cap once at boot.
623
+ *
624
+ * @example
625
+ * b.cryptoField.configureUnsealRateCap({ threshold: 3 });
626
+ * b.cryptoField.clearRateCapForTest();
627
+ * // cap is off again; windows + cooldowns cleared
628
+ */
629
+ function clearRateCapForTest() {
630
+ _rateCap = null;
631
+ _rateFailWindows.clear();
632
+ _rateCooldowns.clear();
633
+ }
634
+
425
635
  // ---- Row sealing / unsealing ----
426
636
 
427
637
  /**
@@ -485,7 +695,8 @@ function sealRow(table, row) {
485
695
  if (s.aad) {
486
696
  var rowId = out[s.rowIdField];
487
697
  if (rowId === undefined || rowId === null || String(rowId).length === 0) {
488
- throw new Error("cryptoField.sealRow: table '" + table +
698
+ throw new CryptoFieldError("crypto-field/seal-row-aad-rowid-missing",
699
+ "cryptoField.sealRow: table '" + table +
489
700
  "' is AAD-bound (registerTable({aad:true})); the row's identity " +
490
701
  "column '" + s.rowIdField + "' must be populated BEFORE sealRow. " +
491
702
  "Generate the primary key first (e.g. uuid / sequence INSERT … RETURNING), " +
@@ -533,10 +744,10 @@ function _aadParts(schema, table, column, row) {
533
744
 
534
745
  /**
535
746
  * @primitive b.cryptoField.unsealRow
536
- * @signature b.cryptoField.unsealRow(table, row)
747
+ * @signature b.cryptoField.unsealRow(table, row, actor?)
537
748
  * @since 0.4.0
538
749
  * @compliance hipaa, gdpr, pci-dss
539
- * @related b.cryptoField.sealRow, b.vault.unseal
750
+ * @related b.cryptoField.sealRow, b.vault.unseal, b.cryptoField.configureUnsealRateCap
540
751
  *
541
752
  * Returns a copy of `row` with every sealed column unwrapped via
542
753
  * `vault.unseal()`. Round-trips with `sealRow`. When `vault.unseal`
@@ -547,21 +758,45 @@ function _aadParts(schema, table, column, row) {
547
758
  * so downstream code sees "no value" instead of crashing the request.
548
759
  * The input row is never mutated.
549
760
  *
761
+ * When an unseal-failure rate cap is configured via
762
+ * `configureUnsealRateCap` (default off), repeated forged-ciphertext
763
+ * failures for a single `(actor, table, column)` tuple trip a cooldown:
764
+ * once tripped, this call THROWS `CryptoFieldRateError` and emits a
765
+ * distinct `system.crypto.unseal_rate_exceeded` audit instead of
766
+ * exercising the decryption oracle again (CWE-307). `actor` identifies
767
+ * the caller for that tuple (e.g. session subject / API key id); it
768
+ * defaults to an anonymous bucket when omitted, and is ignored entirely
769
+ * when no cap is configured (full back-compat for the 2-arg call).
770
+ *
550
771
  * @example
551
772
  * b.cryptoField.registerTable("patients", { sealedFields: ["ssn"] });
552
773
  * var sealed = b.cryptoField.sealRow("patients", { id: 1, ssn: "123-45-6789" });
553
774
  * var clear = b.cryptoField.unsealRow("patients", sealed);
554
775
  * clear.ssn; // → "123-45-6789"
555
776
  */
556
- function unsealRow(table, row) {
777
+ function unsealRow(table, row, actor) {
557
778
  if (!row) return row;
558
779
  var s = schemas[table];
559
780
  if (!s || s.sealedFields.length === 0) return row;
560
781
  var out = Object.assign({}, row);
782
+ var capActor = (actor === undefined || actor === null || String(actor).length === 0)
783
+ ? "_anon" : String(actor);
561
784
 
562
785
  for (var i = 0; i < s.sealedFields.length; i++) {
563
786
  var field = s.sealedFields[i];
564
787
  if (out[field]) {
788
+ // Opt-in cap: if this (actor, table, column) tuple is in cooldown
789
+ // from prior forged-ciphertext failures, refuse before touching the
790
+ // decryption oracle again (CWE-307). No-op when the cap is disabled.
791
+ if (_rateInCooldown(capActor, table, field)) {
792
+ _emitRateAudit({
793
+ table: table, field: field, actor: capActor, shape: s.aad ? "aad" : "plain",
794
+ threshold: _rateCap.threshold, windowMs: _rateCap.windowMs, cooldownMs: _rateCap.cooldownMs,
795
+ });
796
+ throw new CryptoFieldRateError("crypto-field/unseal-rate-exceeded",
797
+ "cryptoField.unsealRow: unseal-failure rate cap tripped for (actor, '" + table +
798
+ "', '" + field + "') — refusing further unseal attempts during cooldown");
799
+ }
565
800
  var unsealed;
566
801
  try {
567
802
  // Auto-detect the envelope shape so an AAD-bound table that
@@ -599,6 +834,16 @@ function unsealRow(table, row) {
599
834
  },
600
835
  });
601
836
  } catch (_e) { /* drop-silent */ }
837
+ // Opt-in rate cap: account this failure against the (actor,
838
+ // table, column) tuple. When it trips the threshold, arm the
839
+ // cooldown + emit the distinct rate-exceeded audit once on the
840
+ // transition. No-op when the cap is disabled.
841
+ if (_rateNoteFailure(capActor, table, field)) {
842
+ _emitRateAudit({
843
+ table: table, field: field, actor: capActor, shape: s.aad ? "aad" : "plain",
844
+ threshold: _rateCap.threshold, windowMs: _rateCap.windowMs, cooldownMs: _rateCap.cooldownMs,
845
+ });
846
+ }
602
847
  unsealed = null;
603
848
  }
604
849
  // Assign unconditionally. `unsealed` already carries the right value
@@ -806,21 +1051,25 @@ function lookupHash(table, field, value) {
806
1051
  */
807
1052
  function declareColumnResidency(table, opts) {
808
1053
  if (typeof table !== "string" || table.length === 0) {
809
- throw new Error("declareColumnResidency: table must be a non-empty string");
1054
+ throw new CryptoFieldError("crypto-field/residency-table-empty",
1055
+ "declareColumnResidency: table must be a non-empty string");
810
1056
  }
811
1057
  if (opts === null || opts === undefined || typeof opts !== "object" || Array.isArray(opts)) {
812
- throw new Error("declareColumnResidency: opts must be a plain object");
1058
+ throw new CryptoFieldError("crypto-field/residency-opts-not-object",
1059
+ "declareColumnResidency: opts must be a plain object");
813
1060
  }
814
1061
  var map = opts.columnResidency;
815
1062
  if (!map || typeof map !== "object" || Array.isArray(map)) {
816
- throw new Error("declareColumnResidency: opts.columnResidency must be an object");
1063
+ throw new CryptoFieldError("crypto-field/residency-map-not-object",
1064
+ "declareColumnResidency: opts.columnResidency must be an object");
817
1065
  }
818
1066
  var entry = Object.create(null);
819
1067
  for (var col in map) {
820
1068
  if (!Object.prototype.hasOwnProperty.call(map, col)) continue;
821
1069
  var tag = map[col];
822
1070
  if (typeof tag !== "string" || tag.length === 0) {
823
- throw new Error("declareColumnResidency: column '" + col +
1071
+ throw new CryptoFieldError("crypto-field/residency-tag-empty",
1072
+ "declareColumnResidency: column '" + col +
824
1073
  "' residency tag must be a non-empty string");
825
1074
  }
826
1075
  entry[col] = tag;
@@ -943,17 +1192,20 @@ function assertColumnResidency(table, row, args) {
943
1192
  */
944
1193
  function declarePerRowKey(table, opts) {
945
1194
  if (typeof table !== "string" || table.length === 0) {
946
- throw new Error("declarePerRowKey: table must be a non-empty string");
1195
+ throw new CryptoFieldError("crypto-field/per-row-key-table-empty",
1196
+ "declarePerRowKey: table must be a non-empty string");
947
1197
  }
948
1198
  opts = opts || {};
949
1199
  var keySize = opts.keySize === undefined ? 32 : opts.keySize; // XChaCha20-Poly1305 key length in bytes
950
1200
  if (typeof keySize !== "number" || !isFinite(keySize) ||
951
1201
  keySize < 16 || Math.floor(keySize) !== keySize) { // minimum AES-128 key length in bytes
952
- throw new Error("declarePerRowKey: opts.keySize must be an integer >= 16 (bytes)");
1202
+ throw new CryptoFieldError("crypto-field/per-row-key-bad-size",
1203
+ "declarePerRowKey: opts.keySize must be an integer >= 16 (bytes)");
953
1204
  }
954
1205
  var info = opts.info || ("blamejs-per-row-key:" + table);
955
1206
  if (typeof info !== "string" || info.length === 0) {
956
- throw new Error("declarePerRowKey: opts.info must be a non-empty string");
1207
+ throw new CryptoFieldError("crypto-field/per-row-key-info-empty",
1208
+ "declarePerRowKey: opts.info must be a non-empty string");
957
1209
  }
958
1210
  perRowKeyTables[table] = { keySize: keySize, info: info };
959
1211
  return { table: table, keySize: keySize, info: info };
@@ -1010,7 +1262,8 @@ function materializePerRowKey(table, rowId, dbHandle) {
1010
1262
  var spec = perRowKeyTables[table];
1011
1263
  if (!spec) return null;
1012
1264
  if (!dbHandle || typeof dbHandle.prepare !== "function") {
1013
- throw new Error("materializePerRowKey: dbHandle (b.db) is required");
1265
+ throw new CryptoFieldError("crypto-field/materialize-per-row-key-no-db",
1266
+ "materializePerRowKey: dbHandle (b.db) is required");
1014
1267
  }
1015
1268
  // Existing key? Re-use to support idempotent UPSERTs.
1016
1269
  var existing = dbHandle.prepare(
@@ -1066,7 +1319,8 @@ function materializePerRowKey(table, rowId, dbHandle) {
1066
1319
  function destroyPerRowKey(table, rowId, dbHandle) {
1067
1320
  if (!perRowKeyTables[table]) return { destroyed: 0 };
1068
1321
  if (!dbHandle || typeof dbHandle.prepare !== "function") {
1069
- throw new Error("destroyPerRowKey: dbHandle (b.db) is required");
1322
+ throw new CryptoFieldError("crypto-field/destroy-per-row-key-no-db",
1323
+ "destroyPerRowKey: dbHandle (b.db) is required");
1070
1324
  }
1071
1325
  var result = dbHandle.prepare(
1072
1326
  'DELETE FROM "_blamejs_per_row_keys" WHERE tableName = ? AND rowId = ?'
@@ -1105,6 +1359,9 @@ module.exports = {
1105
1359
  getSealedFields: getSealedFields,
1106
1360
  sealRow: sealRow,
1107
1361
  unsealRow: unsealRow,
1362
+ configureUnsealRateCap: configureUnsealRateCap,
1363
+ clearRateCapForTest: clearRateCapForTest,
1364
+ CryptoFieldRateError: CryptoFieldRateError,
1108
1365
  // _aadParts — the column-AAD builder the seal/unseal path uses. Exported
1109
1366
  // (internal) so the vault-key rotation pipeline reconstructs the IDENTICAL
1110
1367
  // AAD tuple a cell was sealed under — one source of truth, no drift
package/lib/dora.js CHANGED
@@ -248,8 +248,8 @@ function _validateReportInput(input) {
248
248
  *
249
249
  * @opts
250
250
  * audit: boolean (default true; set false to skip audit emits),
251
- * observability: boolean (reserved observability counter is always
252
- * best-effort and ignored on failure),
251
+ * observability: boolean (default true; set false to skip the
252
+ * best-effort observability counter on report),
253
253
  *
254
254
  * @example
255
255
  * var dora = b.dora.create({ audit: true });
@@ -276,6 +276,7 @@ function create(opts) {
276
276
  opts = opts || {};
277
277
  validateOpts(opts, ["audit", "observability"], "dora.create");
278
278
  var auditOn = opts.audit !== false;
279
+ var obsOn = opts.observability !== false;
279
280
 
280
281
  function _emit(action, info) {
281
282
  if (!auditOn) return;
@@ -354,9 +355,11 @@ function create(opts) {
354
355
  stage: record.stage,
355
356
  },
356
357
  });
357
- try { observability().count("dora.incident.reported", 1, {
358
- classification: record.classification, stage: record.stage,
359
- }); } catch (_e) { /* obs best-effort */ }
358
+ if (obsOn) {
359
+ observability().safeEvent("dora.incident.reported", 1, {
360
+ classification: record.classification, stage: record.stage,
361
+ });
362
+ }
360
363
  return record;
361
364
  }
362
365
 
package/lib/dsr.js CHANGED
@@ -279,8 +279,8 @@ function create(opts) {
279
279
  validateOpts(opts, [
280
280
  "ticketStore", "posture", "identityResolver",
281
281
  "sources", "audit", "retentionFloorMs",
282
- "deadlineMs", "observability",
283
- "verificationLevel", "verifyContext",
282
+ "deadlineMs",
283
+ "verificationLevel",
284
284
  "receiptSigner", "minVerificationByType",
285
285
  ], "dsr.create");
286
286
 
@@ -77,6 +77,13 @@ function fromRequest(req, opts) {
77
77
  if (typeof req.user.email === "string") ctx.email = req.user.email;
78
78
  if (req.user.tenantId != null) ctx.tenantId = req.user.tenantId;
79
79
  }
80
+ // Explicit tenantKey overrides the tenant id derived from req.user —
81
+ // the sibling of userKey for the tenant axis. Operators behind a
82
+ // gateway that resolves tenancy out-of-band (subdomain, mTLS SAN,
83
+ // signed header) pass it directly rather than depending on req.user.
84
+ if (typeof opts.tenantKey === "string" && opts.tenantKey.length > 0) {
85
+ ctx.tenantId = opts.tenantKey;
86
+ }
80
87
  var headers = req.headers || {};
81
88
  if (typeof headers["accept-language"] === "string") {
82
89
  ctx.locale = headers["accept-language"].split(",")[0].split(";")[0].trim();
@@ -58,8 +58,10 @@ function audit(html, opts) {
58
58
  ? KNOWN_ROLES.concat(opts.allowedRoles)
59
59
  : KNOWN_ROLES;
60
60
 
61
- var findings = [];
62
- function _add(f) { findings.push(f); }
61
+ // Per-finding scopeUrl stamping — shared collector in tagwalk.
62
+ var collector = tagwalk.makeScopedFindings(opts.scopeUrl);
63
+ var findings = collector.findings;
64
+ var _add = collector.add;
63
65
 
64
66
  var declaredIds = Object.create(null);
65
67
  var idRe = /\bid\s*=\s*["']([^"']+)["']/gi;
@@ -55,8 +55,10 @@ function audit(html, opts) {
55
55
  ? AUTOCOMPLETE_TOKENS.concat(opts.allowedAutocomplete)
56
56
  : AUTOCOMPLETE_TOKENS;
57
57
 
58
- var findings = [];
59
- function _add(f) { findings.push(f); }
58
+ // Per-finding scopeUrl stamping — shared collector in tagwalk.
59
+ var collector = tagwalk.makeScopedFindings(opts.scopeUrl);
60
+ var findings = collector.findings;
61
+ var _add = collector.add;
60
62
 
61
63
  // Pre-scan: is there a <legend> inside any <fieldset>?
62
64
  // We track fieldset → has-legend by forward-scanning each fieldset.
@@ -29,8 +29,10 @@ function audit(html, opts) {
29
29
  throw new TypeError("tables.audit: html must be a string");
30
30
  }
31
31
 
32
- var findings = [];
33
- function _add(f) { findings.push(f); }
32
+ // Per-finding scopeUrl stamping — shared collector in tagwalk.
33
+ var collector = tagwalk.makeScopedFindings(opts.scopeUrl);
34
+ var findings = collector.findings;
35
+ var _add = collector.add;
34
36
 
35
37
  // Walk the tag stream, tracking nesting state for tables + their
36
38
  // children. We don't build a full DOM; we track the open-tag stack
@@ -36,9 +36,29 @@ function lineColAt(html, offset) {
36
36
  return { line: line, column: offset - lastNl };
37
37
  }
38
38
 
39
+ // Shared findings collector for the sub-scanners' audit(html, opts)
40
+ // entry points. scopeUrl annotates every finding with the page it came
41
+ // from so a direct caller of a sub-scanner (aria/forms/tables) can
42
+ // correlate a finding back to its source document; the parent
43
+ // wcag.audit also records scopeUrl at report level, but stamping
44
+ // per-finding keeps the value useful when a sub-scanner is invoked on
45
+ // its own. Returns { findings, add } — push findings through add() so
46
+ // the stamp applies uniformly.
47
+ function makeScopedFindings(scopeUrlOpt) {
48
+ var scopeUrl = (typeof scopeUrlOpt === "string" && scopeUrlOpt.length > 0)
49
+ ? scopeUrlOpt : null;
50
+ var findings = [];
51
+ function add(f) {
52
+ if (scopeUrl !== null) f.scopeUrl = scopeUrl;
53
+ findings.push(f);
54
+ }
55
+ return { findings: findings, add: add };
56
+ }
57
+
39
58
  module.exports = {
40
59
  TAG_RE: TAG_RE,
41
60
  ATTR_RE: ATTR_RE,
42
61
  parseAttrs: parseAttrs,
43
62
  lineColAt: lineColAt,
63
+ makeScopedFindings: makeScopedFindings,
44
64
  };
@@ -345,7 +345,7 @@ function _checkAnchors(html, scheduled, report) {
345
345
  function audit(html, opts) {
346
346
  opts = opts || {};
347
347
  validateOpts(opts, [
348
- "level", "ignore", "checkAll", "scopeUrl",
348
+ "level", "ignore", "scopeUrl",
349
349
  "skipAria", "allowedRoles", "skipTables",
350
350
  "skipForms", "allowedAutocomplete",
351
351
  ], "guardHtml.wcag.audit");
package/lib/honeytoken.js CHANGED
@@ -89,6 +89,17 @@ function create(opts) {
89
89
  opts = opts || {};
90
90
  validateOpts(opts, ["audit"], "honeytoken.create");
91
91
 
92
+ // Honor the operator-supplied audit sink when present (the documented
93
+ // `audit: b.audit` injection); fall back to the module's lazyRequire so
94
+ // a caller that omits the sink still emits to the default audit log.
95
+ var auditSink = (opts.audit && typeof opts.audit.safeEmit === "function")
96
+ ? opts.audit : null;
97
+ function _emit(record) {
98
+ var sink = auditSink || audit();
99
+ try { sink.safeEmit(record); }
100
+ catch (_e) { /* audit best-effort */ }
101
+ }
102
+
92
103
  var registry = new Map(); // value → { id, kind, metadata, issuedAt }
93
104
 
94
105
  function issue(spec) {
@@ -110,13 +121,11 @@ function create(opts) {
110
121
  issuedAt: Date.now(),
111
122
  });
112
123
  registry.set(value, record);
113
- try {
114
- audit().safeEmit({
115
- action: "honeytoken.issued",
116
- outcome: "success",
117
- metadata: { id: id, kind: kind },
118
- });
119
- } catch (_e) { /* audit best-effort */ }
124
+ _emit({
125
+ action: "honeytoken.issued",
126
+ outcome: "success",
127
+ metadata: { id: id, kind: kind },
128
+ });
120
129
  return { id: id, value: value };
121
130
  }
122
131
 
@@ -124,19 +133,17 @@ function create(opts) {
124
133
  if (typeof value !== "string" || value.length === 0) return null;
125
134
  var record = registry.get(value);
126
135
  if (!record) return null;
127
- try {
128
- audit().safeEmit({
129
- action: "honeytoken.tripped",
130
- outcome: "failure",
131
- metadata: {
132
- id: record.id,
133
- kind: record.kind,
134
- metadata: record.metadata,
135
- observedAt: Date.now(),
136
- observedActor: observedActor || null,
137
- },
138
- });
139
- } catch (_e) { /* audit best-effort */ }
136
+ _emit({
137
+ action: "honeytoken.tripped",
138
+ outcome: "failure",
139
+ metadata: {
140
+ id: record.id,
141
+ kind: record.kind,
142
+ metadata: record.metadata,
143
+ observedAt: Date.now(),
144
+ observedActor: observedActor || null,
145
+ },
146
+ });
140
147
  return record;
141
148
  }
142
149