@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
@@ -79,15 +79,68 @@ function _b64uDecodeStr(s) {
79
79
  return Buffer.from(s, "base64url").toString("utf8");
80
80
  }
81
81
 
82
- async function _verifyProofJwt(proofJwt, expectedAud, expectedCNonce, expectedClientId, supportedAlgs, proofMaxAgeMs, resolveKid) {
82
+ // Linear trailing-`=` strip (charCodeAt + slice) a regex-based
83
+ // padding strip is polynomial-ReDoS-shaped per CodeQL
84
+ // js/polynomial-redos; mirrors lib/argon2-builtin.js. The comparison
85
+ // below is standard base64 (RFC 7515 §4.1.6), so b.crypto.toBase64Url
86
+ // would produce the wrong alphabet.
87
+ function _stripBase64Pad(s) {
88
+ var end = s.length;
89
+ while (end > 0 && s.charCodeAt(end - 1) === 61) end--; // 61 = "="
90
+ return s.slice(0, end);
91
+ }
92
+
93
+ // RFC 7515 §4.1.6 — x5c is an array of base64 (NOT base64url) DER
94
+ // certificate strings, leaf first. Parse + shape-validate the chain into
95
+ // node:crypto X509Certificate objects; refuse a malformed array (empty,
96
+ // non-string entries, non-base64, or a leaf that won't parse) with a
97
+ // typed AuthError matching the module error style.
98
+ function _parseX5cChain(x5c) {
99
+ if (!Array.isArray(x5c) || x5c.length === 0) {
100
+ throw new AuthError("auth-oid4vci/bad-x5c",
101
+ "credential issuance: proof JWT `x5c` must be a non-empty array of base64 DER certificate strings (RFC 7515 §4.1.6)");
102
+ }
103
+ var derBuffers = [];
104
+ var certs = [];
105
+ for (var i = 0; i < x5c.length; i++) {
106
+ var entry = x5c[i];
107
+ if (typeof entry !== "string" || entry.length === 0) {
108
+ throw new AuthError("auth-oid4vci/bad-x5c",
109
+ "credential issuance: proof JWT `x5c[" + i + "]` must be a non-empty base64 string");
110
+ }
111
+ // Standard base64 (not base64url) per RFC 7515 §4.1.6. Reject
112
+ // entries carrying base64url-only chars or that don't round-trip.
113
+ if (/[^A-Za-z0-9+/=]/.test(entry)) {
114
+ throw new AuthError("auth-oid4vci/bad-x5c",
115
+ "credential issuance: proof JWT `x5c[" + i + "]` is not valid base64 (RFC 7515 §4.1.6 mandates standard base64, not base64url)");
116
+ }
117
+ var der = Buffer.from(entry, "base64");
118
+ if (der.length === 0 || _stripBase64Pad(der.toString("base64")) !== _stripBase64Pad(entry)) {
119
+ throw new AuthError("auth-oid4vci/bad-x5c",
120
+ "credential issuance: proof JWT `x5c[" + i + "]` is not valid base64 (RFC 7515 §4.1.6)");
121
+ }
122
+ var cert;
123
+ try { cert = new nodeCrypto.X509Certificate(der); }
124
+ catch (e) {
125
+ throw new AuthError("auth-oid4vci/bad-x5c",
126
+ "credential issuance: proof JWT `x5c[" + i + "]` is not a parseable DER certificate: " + ((e && e.message) || String(e)));
127
+ }
128
+ derBuffers.push(der);
129
+ certs.push(cert);
130
+ }
131
+ return { derBuffers: derBuffers, certs: certs };
132
+ }
133
+
134
+ async function _verifyProofJwt(proofJwt, expectedAud, expectedCNonce, expectedClientId, supportedAlgs, proofMaxAgeMs, resolveKid, validateX5c) {
83
135
  // OID4VCI §7.2.1.1: the proof JWT MUST:
84
136
  // - typ = "openid4vci-proof+jwt"
85
137
  // - alg in supported list (issuer publishes these)
86
138
  // - aud = credential issuer URL (this issuer's `credential_issuer`)
87
139
  // - iat = recent
88
140
  // - nonce = c_nonce previously issued to the wallet
89
- // - jwk (inline) OR kid (resolved via resolveKid) in the header
90
- // pointing at the holder key to bind cnf to (RFC 7515 §4.1.3/§4.1.4)
141
+ // - jwk (inline), kid (resolved via resolveKid), OR x5c (leaf-cert
142
+ // SPKI) in the header pointing at the holder key to bind cnf to
143
+ // (RFC 7515 §4.1.3 / §4.1.4 / §4.1.6; OID4VCI §8.2.1.1)
91
144
  if (typeof proofJwt !== "string" || proofJwt.length === 0 || proofJwt.length > MAX_PROOF_BYTES) {
92
145
  throw new AuthError("auth-oid4vci/bad-proof",
93
146
  "credential issuance: proof JWT is empty or exceeds " + MAX_PROOF_BYTES + " bytes");
@@ -135,6 +188,18 @@ async function _verifyProofJwt(proofJwt, expectedAud, expectedCNonce, expectedCl
135
188
  throw new AuthError("auth-oid4vci/wrong-proof-aud",
136
189
  "credential issuance: proof JWT aud \"" + payload.aud + "\" mismatch (expected \"" + expectedAud + "\")");
137
190
  }
191
+ // c_nonce expectation has three states the caller distinguishes:
192
+ // null → no nonce check expected (caller deliberately skips it).
193
+ // string → the c_nonce the wallet must echo (compared below).
194
+ // undefined → a nonce WAS expected but the store missed/expired it
195
+ // (cNonceStore.get returns undefined on miss/expiry, and
196
+ // the c_nonce TTL is shorter than the access token's).
197
+ // Refuse with a typed code — comparing against undefined
198
+ // would otherwise throw a raw TypeError from timingSafeEqual.
199
+ if (expectedCNonce === undefined) {
200
+ throw new AuthError("auth-oid4vci/c-nonce-expired",
201
+ "credential issuance: c_nonce expected but missing/expired — wallet must request a fresh c_nonce (the /token response's c_nonce TTL elapsed before /credential was called)");
202
+ }
138
203
  if (expectedCNonce !== null) {
139
204
  // Constant-time c_nonce compare — secret-shaped value vs
140
205
  // attacker-controlled wallet payload.
@@ -172,7 +237,7 @@ async function _verifyProofJwt(proofJwt, expectedAud, expectedCNonce, expectedCl
172
237
  "credential issuance: proof JWT iss does not match the access-token client_id");
173
238
  }
174
239
 
175
- // Resolve the holder key the proof is signed with. Two paths:
240
+ // Resolve the holder key the proof is signed with. Three paths:
176
241
  // - inline `jwk` (RFC 7515 §4.1.3) — the wallet ships the public
177
242
  // key in the header; bind `cnf` to it directly.
178
243
  // - `kid` (RFC 7515 §4.1.4) without inline `jwk` — the wallet
@@ -181,6 +246,15 @@ async function _verifyProofJwt(proofJwt, expectedAud, expectedCNonce, expectedCl
181
246
  // supplies `resolveKid(kid, header)` to map the kid → public key.
182
247
  // With no resolver configured the issuer keeps the clear refusal
183
248
  // (back-compat): a kid-only proof can't be verified without one.
249
+ // - `x5c` (RFC 7515 §4.1.6) without inline `jwk`/`kid` — the wallet
250
+ // ships a base64 DER certificate chain; the LEAF cert's SPKI is
251
+ // the holder key (OID4VCI §8.2.1.1). Like inline `jwk`, the chain
252
+ // is self-asserted, so leaf-SPKI extraction at the same trust
253
+ // level is the correct parity — the proof signature check binds
254
+ // the key. Chain trust beyond that is operator policy: an optional
255
+ // `validateX5c(chainDerBuffers, header)` callback may throw to
256
+ // refuse (PKI anchoring, EKU checks, revocation, attestation-CA
257
+ // allowlist) before the SPKI is trusted.
184
258
  var holderKeyJwk = header.jwk || null;
185
259
  var keyObj;
186
260
  if (!holderKeyJwk && header.kid) {
@@ -228,6 +302,42 @@ async function _verifyProofJwt(proofJwt, expectedAud, expectedCNonce, expectedCl
228
302
  throw new AuthError("auth-oid4vci/bad-resolved-key",
229
303
  "credential issuance: resolveKid-returned key is not importable as a public key: " + ((e && e.message) || String(e)));
230
304
  }
305
+ } else if (!holderKeyJwk && header.x5c) {
306
+ // RFC 7515 §4.1.6 / OID4VCI §8.2.1.1 — the wallet ships a base64 DER
307
+ // certificate chain; the LEAF (first) cert's SPKI is the holder key.
308
+ var chain = _parseX5cChain(header.x5c);
309
+ // Operator chain-trust policy runs BEFORE the SPKI is trusted. A
310
+ // throw refuses the proof (wrapped in a stable AuthError code so the
311
+ // /credential handler returns a typed refusal rather than an
312
+ // unhandled rejection; the callback is operator code, so its own
313
+ // message is allowed through for operator-side debugging).
314
+ if (typeof validateX5c === "function") {
315
+ try {
316
+ await validateX5c(chain.derBuffers.slice(), header);
317
+ } catch (e) {
318
+ if (e instanceof AuthError) throw e;
319
+ throw new AuthError("auth-oid4vci/x5c-rejected",
320
+ "credential issuance: validateX5c rejected the proof JWT certificate chain: " + ((e && e.message) || String(e)));
321
+ }
322
+ }
323
+ // Extract the leaf SPKI as a JWK to use as the holder key, exactly
324
+ // parallel to the inline-jwk path. publicKey is a node:crypto
325
+ // KeyObject; export to JWK for the cnf binding sdJwtIssuer.issue
326
+ // expects.
327
+ try { holderKeyJwk = chain.certs[0].publicKey.export({ format: "jwk" }); }
328
+ catch (e) {
329
+ throw new AuthError("auth-oid4vci/bad-x5c",
330
+ "credential issuance: proof JWT `x5c` leaf certificate public key does not export to JWK: " + ((e && e.message) || String(e)));
331
+ }
332
+ // CVE-2026-22817 — same alg/kty cross-check the inline path applies.
333
+ // A leaf cert holding an RSA key against a proof declaring an HMAC
334
+ // alg would otherwise be verified as an HMAC secret.
335
+ jwtExternal._assertAlgKtyMatch(header.alg, holderKeyJwk);
336
+ try { keyObj = nodeCrypto.createPublicKey({ key: holderKeyJwk, format: "jwk" }); }
337
+ catch (e) {
338
+ throw new AuthError("auth-oid4vci/bad-x5c",
339
+ "credential issuance: proof JWT `x5c` leaf public key is not importable: " + ((e && e.message) || String(e)));
340
+ }
231
341
  } else {
232
342
  if (!holderKeyJwk) {
233
343
  throw new AuthError("auth-oid4vci/no-jwk-in-header",
@@ -292,6 +402,7 @@ async function _verifyProofJwt(proofJwt, expectedAud, expectedCNonce, expectedCl
292
402
  * supportedCredentials: { [id]: { format, vct, claims, ... } },
293
403
  * proofAlgorithms: string[], // default ["ES256", "ES384", "EdDSA"]
294
404
  * resolveKid?: function(kid, header), // resolve a kid-only proof's holder key (JWK | KeyObject); without it, kid-only proofs are refused
405
+ * validateX5c?: function(chainDerBuffers, header), // x5c (RFC 7515 §4.1.6) chain-trust policy; throw to refuse. Absent → leaf-cert SPKI binds at the same self-asserted trust as inline `jwk`
295
406
  * preAuthCodeTtlMs?: number, // default 5m
296
407
  * accessTokenTtlMs?: number, // default 15m
297
408
  * cNonceTtlMs?: number, // default 5m
@@ -358,6 +469,14 @@ function create(opts) {
358
469
  var resolveKid = validateOpts.optionalFunction(opts.resolveKid,
359
470
  "issuer.create: resolveKid", AuthError, "auth-oid4vci/bad-resolve-kid");
360
471
 
472
+ // Optional x5c chain-trust policy for x5c proofs (RFC 7515 §4.1.6 /
473
+ // OID4VCI §8.2.1.1). Config-time throw if supplied but not a function.
474
+ // Absent → the leaf-cert SPKI binds at the same self-asserted trust
475
+ // level as an inline `jwk` (the proof signature binds the key); chain
476
+ // anchoring beyond that is the operator's to enforce via this callback.
477
+ var validateX5c = validateOpts.optionalFunction(opts.validateX5c,
478
+ "issuer.create: validateX5c", AuthError, "auth-oid4vci/bad-validate-x5c");
479
+
361
480
  var preAuthTtl = opts.preAuthCodeTtlMs || DEFAULT_PRE_AUTH_TTL_MS;
362
481
  var accessTokenTtl = opts.accessTokenTtlMs || DEFAULT_ACCESS_TOKEN_TTL;
363
482
  var cNonceTtl = opts.cNonceTtlMs || DEFAULT_C_NONCE_TTL_MS;
@@ -612,7 +731,7 @@ function create(opts) {
612
731
  }
613
732
 
614
733
  var expectedCNonce = await cNonceStore.get(iopts.accessToken);
615
- var verified = await _verifyProofJwt(iopts.proof, opts.credentialIssuerUrl, expectedCNonce, null, proofAlgs, proofMaxAgeMs, resolveKid);
734
+ var verified = await _verifyProofJwt(iopts.proof, opts.credentialIssuerUrl, expectedCNonce, null, proofAlgs, proofMaxAgeMs, resolveKid, validateX5c);
616
735
 
617
736
  if (!iopts.claims || typeof iopts.claims !== "object") {
618
737
  throw new AuthError("auth-oid4vci/no-claims",
@@ -65,10 +65,11 @@ var _emitMetric = emit.metric;
65
65
 
66
66
  /**
67
67
  * Validate a DCQL query against the spec shape. Refuses unknown
68
- * top-level keys, missing credential id, missing claim paths, or
69
- * malformed credential_sets options. Throws AuthError on first
70
- * failure (config-time validation the verifier author is the one
71
- * who needs to see the error).
68
+ * top-level keys, missing credential id, missing claim paths,
69
+ * numeric claim-path segments that are not non-negative integer
70
+ * array indices (OpenID4VP 1.0 §7.1.1), or malformed credential_sets
71
+ * options. Throws AuthError on first failure (config-time validation
72
+ * — the verifier author is the one who needs to see the error).
72
73
  */
73
74
  function _validateDcql(dcql) {
74
75
  if (!dcql || typeof dcql !== "object" || Array.isArray(dcql)) {
@@ -113,6 +114,15 @@ function _validateDcql(dcql) {
113
114
  throw new AuthError("auth-oid4vp/bad-claim-segment",
114
115
  "DCQL: claim path segments must be string|number|null");
115
116
  }
117
+ // OpenID4VP 1.0 §7.1.1 — a numeric segment is an array index;
118
+ // it MUST be a non-negative integer. Reject -1 / 1.5 / NaN /
119
+ // Infinity here (config-time / entry-point tier) so the
120
+ // verifier author sees the typo at build, not a silent
121
+ // non-match at verify time.
122
+ if (typeof segment === "number" && (!Number.isInteger(segment) || segment < 0)) {
123
+ throw new AuthError("auth-oid4vp/bad-claim-segment",
124
+ "DCQL: numeric claim path segment must be a non-negative integer (OpenID4VP 1.0 §7.1.1)");
125
+ }
116
126
  });
117
127
  if (claim.values !== undefined && !Array.isArray(claim.values)) {
118
128
  throw new AuthError("auth-oid4vp/bad-claim-values",
@@ -43,6 +43,7 @@
43
43
  * tests — production operators wire b.db / b.objectStore.
44
44
  */
45
45
 
46
+ var nodeCrypto = require("node:crypto");
46
47
  var lazyRequire = require("../lazy-require");
47
48
  var validateOpts = require("../validate-opts");
48
49
  var { AuthError } = require("../framework-error");
@@ -51,6 +52,50 @@ var sdJwtVcCore = lazyRequire(function () { return require("./sd-jwt-vc"); });
51
52
  var audit = lazyRequire(function () { return require("../audit"); });
52
53
  var observability = lazyRequire(function () { return require("../observability"); });
53
54
 
55
+ // EC curve → the KB-JWT alg the sd-jwt-vc core supports for it. P-521
56
+ // has no entry — the core's SUPPORTED_ALGS stops at ES384.
57
+ var _HOLDER_EC_CURVE_ALG = { prime256v1: "ES256", secp384r1: "ES384" };
58
+
59
+ // Resolve the KB-JWT signing alg from the holder key when the operator
60
+ // gives no explicit `algorithm`. A fixed default (the old "ES256") signed
61
+ // a non-EC-P256 holder key under a header alg that disagreed with the key
62
+ // — un-signable (Ed25519 / EC-P384) or a self-invalid KB-JWT a verifier
63
+ // rejects (any key whose sign succeeds under the wrong digest). Inferring
64
+ // from the key keeps the common EC-P256 → ES256 case unchanged while
65
+ // producing a self-consistent KB-JWT for every other supported key, and
66
+ // refuses a key type the core has no alg for (e.g. RSA) instead of
67
+ // emitting a broken presentation. An explicit `algorithm` is honoured and
68
+ // validated by the core against SUPPORTED_ALGS.
69
+ function _resolveHolderAlg(holderKey, explicitAlg) {
70
+ if (explicitAlg) return explicitAlg;
71
+ var keyObj = null;
72
+ try {
73
+ if (holderKey instanceof nodeCrypto.KeyObject) {
74
+ keyObj = holderKey;
75
+ } else if (typeof holderKey === "string" || Buffer.isBuffer(holderKey)) {
76
+ keyObj = nodeCrypto.createPrivateKey({ key: holderKey, format: "pem" });
77
+ } else if (holderKey && typeof holderKey === "object" && holderKey.kty) {
78
+ keyObj = nodeCrypto.createPrivateKey({ key: holderKey, format: "jwk" });
79
+ }
80
+ } catch (_e) {
81
+ keyObj = null; // unreadable key — let the signer surface the real error
82
+ }
83
+ if (!keyObj) return "ES256"; // preserve the historical default when the type can't be read
84
+ var kty = keyObj.asymmetricKeyType;
85
+ if (kty === "ec") {
86
+ var curve = (keyObj.asymmetricKeyDetails && keyObj.asymmetricKeyDetails.namedCurve) || "";
87
+ var ecAlg = _HOLDER_EC_CURVE_ALG[curve];
88
+ if (ecAlg) return ecAlg;
89
+ throw new AuthError("auth-sd-jwt-vc/holder-key-unsupported",
90
+ "holder.create: EC curve '" + curve + "' has no KB-JWT algorithm (use P-256 / P-384, Ed25519, or ML-DSA-87 / ML-DSA-65)");
91
+ }
92
+ if (kty === "ed25519" || kty === "ed448") return "EdDSA";
93
+ if (kty === "ml-dsa-87") return "ML-DSA-87";
94
+ if (kty === "ml-dsa-65") return "ML-DSA-65";
95
+ throw new AuthError("auth-sd-jwt-vc/holder-key-unsupported",
96
+ "holder.create: key type '" + String(kty) + "' has no KB-JWT algorithm (use EC P-256 / P-384, Ed25519, or ML-DSA-87 / ML-DSA-65; RSA is not supported for KB-JWT)");
97
+ }
98
+
54
99
  function _validateStorage(storage) {
55
100
  if (!storage || typeof storage !== "object") return false;
56
101
  return ["put", "get", "list", "delete"].every(function (m) {
@@ -96,7 +141,7 @@ function create(opts) {
96
141
  throw new AuthError("auth-sd-jwt-vc/no-key",
97
142
  "holder.create: holderKey required");
98
143
  }
99
- var algorithm = opts.algorithm || "ES256";
144
+ var algorithm = _resolveHolderAlg(opts.holderKey, opts.algorithm);
100
145
  var auditOn = opts.auditOn !== false;
101
146
 
102
147
  function _emitAudit(action, outcome, metadata) {
@@ -425,7 +425,6 @@ async function migrate(table, opts) {
425
425
  * has run.
426
426
  *
427
427
  * @opts
428
- * now: number, // testing-only override of Date.now (fixtures)
429
428
  * trustProxy: boolean, // honor X-Forwarded-For when populating grant.ip (default false)
430
429
  *
431
430
  * @example
@@ -434,7 +433,7 @@ async function migrate(table, opts) {
434
433
  */
435
434
  function init(opts) {
436
435
  opts = opts || {};
437
- validateOpts(opts, ["now", "trustProxy"], "breakGlass.init");
436
+ validateOpts(opts, ["trustProxy"], "breakGlass.init");
438
437
  initialized = true;
439
438
  policyCache.clear();
440
439
  _factorLockout = null;
package/lib/config.js CHANGED
@@ -258,7 +258,8 @@ function create(opts) {
258
258
  * Rows whose transform throws or returns a non-string
259
259
  * are skipped with a `config.reload.failed` audit so a
260
260
  * single bad row never crashes the poller),
261
- * audit: boolean (default true; reserved for future per-poll audit),
261
+ * audit: boolean (default true; set false to silence the
262
+ * per-poll config.reload.* audit emissions),
262
263
  *
263
264
  * @example
264
265
  * var s = b.safeSchema;
@@ -322,6 +323,12 @@ function loadDbBacked(opts) {
322
323
  var transformValue = validateOpts.optionalFunction(
323
324
  opts.transformValue, "loadDbBacked: opts.transformValue",
324
325
  ConfigError, "config/bad-transform-value") || null;
326
+ var auditOn = opts.audit !== false;
327
+ function _emitReloadAudit(record) {
328
+ if (!auditOn) return;
329
+ try { audit().safeEmit(record); }
330
+ catch (_e) { /* audit best-effort */ }
331
+ }
325
332
  var cfg = create({ schema: opts.schema, env: opts.env, redactKeys: opts.redactKeys });
326
333
  var stopped = false;
327
334
  // Concurrency guard. _tick() runs `await opts.fetchRows()` + per-row
@@ -348,12 +355,10 @@ function loadDbBacked(opts) {
348
355
  var rows;
349
356
  try { rows = await opts.fetchRows(); }
350
357
  catch (e) {
351
- try {
352
- audit().safeEmit({
353
- action: "config.reload.failed", outcome: "failure",
354
- metadata: { phase: "fetch", reason: e && e.message },
355
- });
356
- } catch (_e) { /* audit best-effort */ }
358
+ _emitReloadAudit({
359
+ action: "config.reload.failed", outcome: "failure",
360
+ metadata: { phase: "fetch", reason: e && e.message },
361
+ });
357
362
  return;
358
363
  }
359
364
  if (!Array.isArray(rows)) return;
@@ -366,21 +371,17 @@ function loadDbBacked(opts) {
366
371
  try {
367
372
  value = await transformValue(row);
368
373
  } catch (e) {
369
- try {
370
- audit().safeEmit({
371
- action: "config.reload.failed", outcome: "failure",
372
- metadata: { phase: "transform", key: row.key, reason: e && e.message },
373
- });
374
- } catch (_e) { /* audit best-effort */ }
374
+ _emitReloadAudit({
375
+ action: "config.reload.failed", outcome: "failure",
376
+ metadata: { phase: "transform", key: row.key, reason: e && e.message },
377
+ });
375
378
  continue;
376
379
  }
377
380
  if (typeof value !== "string") {
378
- try {
379
- audit().safeEmit({
380
- action: "config.reload.failed", outcome: "failure",
381
- metadata: { phase: "transform", key: row.key, reason: "transformValue did not return a string" },
382
- });
383
- } catch (_e) { /* audit best-effort */ }
381
+ _emitReloadAudit({
382
+ action: "config.reload.failed", outcome: "failure",
383
+ metadata: { phase: "transform", key: row.key, reason: "transformValue did not return a string" },
384
+ });
384
385
  continue;
385
386
  }
386
387
  }
@@ -389,12 +390,10 @@ function loadDbBacked(opts) {
389
390
  // Drop-stale: a tick that started after me has already finished and
390
391
  // applied its newer fetch — my overlay would clobber fresher data.
391
392
  if (mySeq <= ticksAppliedMax) {
392
- try {
393
- audit().safeEmit({
394
- action: "config.reload.skipped", outcome: "success",
395
- metadata: { phase: "stale-tick", mySeq: mySeq, appliedMax: ticksAppliedMax },
396
- });
397
- } catch (_e) { /* audit best-effort */ }
393
+ _emitReloadAudit({
394
+ action: "config.reload.skipped", outcome: "success",
395
+ metadata: { phase: "stale-tick", mySeq: mySeq, appliedMax: ticksAppliedMax },
396
+ });
398
397
  return;
399
398
  }
400
399
  // Advance the watermark ONLY after a successful reload. A newer
@@ -407,12 +406,10 @@ function loadDbBacked(opts) {
407
406
  ticksAppliedMax = mySeq;
408
407
  }
409
408
  catch (e) {
410
- try {
411
- audit().safeEmit({
412
- action: "config.reload.failed", outcome: "failure",
413
- metadata: { phase: "validate", reason: e && e.message },
414
- });
415
- } catch (_e) { /* audit best-effort */ }
409
+ _emitReloadAudit({
410
+ action: "config.reload.failed", outcome: "failure",
411
+ metadata: { phase: "validate", reason: e && e.message },
412
+ });
416
413
  }
417
414
  }
418
415
  // Fire one immediate hydration before the interval kicks in so