@blamejs/core 0.18.45 → 0.18.47

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,44 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.18.x
10
10
 
11
+ - v0.18.47 (2026-08-22) — **A misspelled character policy was accepted at boot and quietly meant something else.** `bidiPolicy: "rejct"` was accepted by every door the framework offers — the guard's own option resolver and the gate built from it — and then read leniently at runtime: the scan runs because the value is not `allow`, an issue is raised, and the disposition falls through to refuse. Nothing is let through, so it was never a hole; the operator simply asked for one disposition, got another, and was told nothing at the moment a boot error would have cost them a restart. Ninety-three settings across the guard family behaved that way. **Fixed:** *A character policy refuses a value outside its vocabulary* — The five character-threat policies every guard shares — `bidiPolicy`, `nullBytePolicy`, `controlPolicy`, `zeroWidthPolicy`, `tagsPolicy` — now accept only `allow`, `audit`, `audit-only`, `reject`, and `strip` where the guard can carry a repair out. Anything else is refused where options are resolved, which is boot, not the first request that trips the scan. Every entry point takes the check: the generated resolver, and the `gate()` and `validate()` of the guards that bind their own.
12
+
13
+ The check is derived from each guard's own declared defaults rather than hand-listed per guard, for the same reason the size caps beside it are. Hand-listing is what produced the gap: `b.guardCountry` added this for its own three options after `{ reservedPolicy: "rejcet" }` served a reserved country code and reported success, and the other two hundred and fifty-nine policy options across the family never followed. A guard that declares its own vocabulary explicitly still wins, so that one is unchanged.
14
+
15
+ Whether a guard can perform a repair is now DECLARED rather than inferred from whether it exports a `sanitize`. An identifier guard exports one that validates and throws — a UUID with a zero-width character in it is not a UUID, and there is nothing to repair — so inferring the capability let `b.guardRegex`, `b.guardJwt`, `b.guardShell` and `b.guardTemplate` accept `strip` at construction and then refuse at runtime, which is the same silent substitution. Guards that repair say so, and the claim is held from both ends: `b.gateContract.defineGuard` refuses a spec that declares repair without a `sanitize` — or a `sanitizeTransform` for one to be built from — and the family sweep separately checks that a guard claiming it actually removes the character, while one that does not claim it refuses the instruction.
16
+
17
+ What this does not yet cover: the guard-specific policies — formula injection, homoglyphs, DOCTYPE, aliases, and the rest — still accept anything. Their vocabularies genuinely differ from one another, and the set a profile happens to select is not the set the code accepts, so inferring them would refuse values that work today. Each needs its own list, read from the guard rather than guessed. · *`audit-only` did not mean what `audit` means* — The two have been synonyms wherever a policy is turned into a disposition. The severity calculation recognised only the literal `audit`, so on the public validation path they disagreed: `zeroWidthPolicy: "audit"` returned a pass with a warning, while `audit-only` returned a failure with a high-severity issue. The same split affected the control-character and Unicode Tags policies.
18
+
19
+ One predicate decides it now, in the module that owns the character scanning, and the disposition mapper asks the same one. Two spellings of a single setting is how they come to disagree; asking one question is how they stop. · *The DNSSEC algorithm table called RSASHA512 current* — RFC 9904 replaced RFC 8624 as the DNSSEC algorithm requirements, and it reads RSASHA512 as NOT RECOMMENDED for signing while still MUST for validation. `b.network.dns.classifyDnskeyAlgorithm(10)` reported it as `current`, so an operator asking which algorithm to sign a zone with got the opposite of what the current document says.
20
+
21
+ It is not marked deprecated, and deliberately: validation remains mandatory, and a caller using that flag to decide whether to ACCEPT a zone must keep accepting it. One boolean cannot carry a requirement that differs between signing and validation, so the reason names both levels and the flag follows the side an operator can still choose.
22
+
23
+ The citations moved with it — DSA, DSA-NSEC3-SHA1 and ECC-GOST to RFC 9904's Table 2, the DS digest vocabulary and GOST R 34.11-94 to Table 3, and RSAMD5 off RFC 6944, which had been obsoleted twice over, onto RFC 6725. Nothing asserted on algorithm 10 before, which is how a stale classification sat in a table everything else in the file kept current.
24
+
25
+ - v0.18.46 (2026-08-22) — **A failing test reported the handle it leaked instead of the check that failed.** Thirty test files ran their assertions inside a `try` whose `finally` drained open handles. A throw from a `finally` replaces the error the body raised, so when a test failed and its skipped teardown left a socket behind, what surfaced was "a handle leaked" — the consequence — while the check that actually failed was discarded. That is the shape of failure that reads as an unexplained flake, and it was reachable in every suite that opens a server. **Fixed:** *A drain no longer replaces the failure it followed* — `try { ...tests... } finally { await drain() }` reads as correct. It is not. When the body throws, every teardown after the throw is skipped, so the drain finds the servers those teardowns would have closed and throws too — and a throw from a `finally` replaces the body's error. The run reports a leaked handle, which is true and useless: the check that failed is gone, and the leak looks like the bug.
26
+
27
+ The drain now runs through one helper that keeps the body's error and appends the others to it, so a run that failed for a reason reports that reason, and a run that only leaked still reports the leak. Thirty files moved onto it.
28
+
29
+ Six of those thirty had their own cleanup — destroying a keep-alive agent, closing websocket clients and the sockets an upgrade detached from the server, resetting module state — sitting in the same `finally`. They were the worse instance rather than the exception: more throwable work in the place that replaces the failure. The helper took an optional teardown so their work runs inside the structure that preserves the error, instead of those six taking an exemption.
30
+
31
+ One file drained fifty-six times, once per section. A drain is a leak VERDICT rather than cleanup, so that was fifty-six places a real assertion failure could surface as a leak. Its sections now clean up and the verdict is taken once, at the end, where nothing is left to mask. The trade is that a leak there is reported against the file rather than the section; what the drain names is the surviving handles with their addresses and states, which is the attribution that matters. · *The helper that makes that promise is now tested* — Thirty files rely on "the body's error wins", and nothing checked it. A helper can stop doing the one thing it exists for while every suite using it still passes, because the suites only exercise it on the path where nothing goes wrong.
32
+
33
+ It is now driven directly: a body that throws, a teardown that throws after it, both throwing at once, a teardown that throws when the body passed, and a body that throws a string rather than an Error — which used to lose the appended note entirely, since assigning to a property of a primitive is a silent no-op outside strict mode.
34
+
35
+ The suite was confirmed to fail against the behaviour it forbids before it was kept. · *Two ways the failure-preserving helper could still lose the failure* — A thrown `null` was not a failure. Both helpers decided whether something had failed by asking whether the value it threw was truthy, so `throw null`, `throw ""` and `throw 0` read as passes: the drain helper returned normally, and the polling helper dropped "last predicate threw" from its timeout message, leaving "the condition never came true" where the truth was "the condition could not be evaluated". Both now track whether a throw happened as its own flag, separate from what was thrown.
36
+
37
+ And annotating an error could destroy it, or be invisible. When a run fails and its teardown fails after, the second failure is appended to the first — by mutating the error's message. An error can refuse to be mutated: a frozen one, or a `message` defined non-writable. That assignment throws, and the throw escapes from the annotator and replaces the failure it was called to annotate. Appending is now attempted and, when refused, the same information is carried in a new error that keeps the original as its cause.
38
+
39
+ The note also has to reach the place anybody reads. A runner prints `.stack`, not `.message`, and a stack string is formatted once on first read and then cached — so an error that some assertion or logger had already touched carried a stack headed by the original message, and the appended note never appeared. The note now goes on the stack as well, guarded on whether it is already there rather than on whether the stack was cached, so it lands exactly once.
40
+
41
+ All of it is the swallowing this release is about, one level further in: the helper that exists to stop a failure being lost had several ways of its own to lose one. · *A polling timeout blamed an exception the predicate had stopped raising* — The polling helper names the last exception in its timeout message, which is the difference between "the condition never came true" and "the condition could not be evaluated". It reported whatever had most recently been thrown, including from a poll many rounds earlier — so a predicate that raised while the thing it watches was still settling, and then answered cleanly, blamed the timeout on an exception it had stopped raising.
42
+
43
+ A throw during polling is not the verdict. The predicate is being asked repeatedly precisely because the state is still settling, so the loop now treats a throw as "not yet" and keeps nothing from it. The diagnostic speaks for one attempt — the one at the deadline — and answers the question worth asking: was the predicate still failing to evaluate when the wait gave up?
44
+
45
+ The timeout diagnostic now has its own tests, which is what surfaced this: whether the helper eventually returns is exercised by every suite that polls, and the message it produces when it does not was exercised by none of them. · *Two test files said they were outside the smoke run* — Both carried a header stating they were not part of the smoke surface. The runner takes every `*.test.js` under the layer directory, and the only opt-out is a `STANDALONE_ONLY` marker in the first two kilobytes, which neither file carries — so both have been running all along. A comment that says a failure here will not block is the kind that gets believed. **Detectors:** *A handle drain may not sit in a `finally`* — The shape is refused wherever it reappears. It matches both spellings — a direct call and a local wrapper — because a word boundary alone misses the underscore-prefixed form, which is exactly how six of the thirty files went uncounted when the population was first measured.
46
+
47
+ Anchored on the `finally` and the drain together and tempered so it cannot cross a function-closing brace; the quantifier is a backstop far above any real `finally` body rather than the precision mechanism. It was confirmed firing on all thirty before any of them were converted, and silent after the last one.
48
+
11
49
  - v0.18.45 (2026-08-21) — **`b.publicSuffix` returned names that cannot exist — a 64-octet label, and `ex*ample.com`.** `canonicalDomain` is where the framework decides what a domain IS, and two kinds of non-domain got through it. A label longer than the 63 octets RFC 1035 allows came back as an organizational domain, and 17 of 31 non-LDH characters survived because `domainToASCII` maps a name without validating it as a host. Both answers then travelled: into DMARC alignment, cookie-domain comparisons and residency routing, where a name no resolver will ever return is compared against one that exists. **Changed:** *Vendored `@blamejs/pki` 0.5.23 to 0.5.25* — The toolkit behind `b.mtlsCa` and `b.auth.passkey`. Two releases land together.
12
50
 
13
51
  0.5.24 binds an ACME certificate download to the order that asked for it. The end-entity certificate that comes back must carry the public key the order's CSR asked to have certified, and its identifier set must equal the order's — its dNSName and iPAddress subject alternative names, with the subject common name read only where it asserts none. `downloadCertificate` now requires `expectedSpki`, `identifiers`, or both, and refuses a call supplying neither rather than returning a certificate nothing looked at. An identifier that maps to no certificate name is refused rather than dropped from the comparison, which is what let an order for a name plus one other identifier be satisfied by a certificate covering only the name.
@@ -207,6 +207,33 @@ function firstInRanges(text, ranges, from) {
207
207
  // The shape the detectors want: `{ index, char, codePoint }` for the first
208
208
  // codepoint of `text` in `ranges`, or null. `char` is the WHOLE codepoint,
209
209
  // which for an astral hit is a surrogate pair.
210
+ /**
211
+ * @primitive b.codepointClass.isAuditPolicy
212
+ * @signature b.codepointClass.isAuditPolicy(policy)
213
+ * @since 0.18.47
214
+ * @status stable
215
+ * @related b.gateContract.policyDisposition, b.gateContract.charPolicyEnums
216
+ *
217
+ * Whether a policy value asks for an AUDIT — reporting the finding rather than
218
+ * refusing or repairing. `audit-only` is the framework's own synonym for
219
+ * `audit`, and this is the one place that decides so.
220
+ *
221
+ * It exists because the two spellings disagreed. The disposition mapper treated
222
+ * them as the same thing while the severity calculation recognised only the
223
+ * literal `audit`, so `zeroWidthPolicy: "audit-only"` produced a high-severity
224
+ * issue and a failed validation where `audit` produced a warning and a pass.
225
+ * Two spellings of one setting is how they come to disagree; asking one
226
+ * predicate is how they stop.
227
+ *
228
+ * @example
229
+ * b.codepointClass.isAuditPolicy("audit"); // → true
230
+ * b.codepointClass.isAuditPolicy("audit-only"); // → true
231
+ * b.codepointClass.isAuditPolicy("reject"); // → false
232
+ */
233
+ function isAuditPolicy(policy) {
234
+ return policy === "audit" || policy === "audit-only";
235
+ }
236
+
210
237
  function _firstHit(text, ranges) {
211
238
  var i = firstInRanges(text, ranges);
212
239
  if (i === -1) return null;
@@ -817,7 +844,7 @@ function detectCharThreats(text, opts, codePrefix) {
817
844
  // reported, so the audit setting did nothing an operator could
818
845
  // observe except the thing they asked it not to do.
819
846
  severity: opts.controlPolicy === "reject" ? "high"
820
- : opts.controlPolicy === "audit" ? "warn" : "high",
847
+ : isAuditPolicy(opts.controlPolicy) ? "warn" : "high",
821
848
  ruleId: codePrefix + ".control",
822
849
  location: ctrlMatch.index,
823
850
  // DEL is a control character here but is not in the C0 block, so the
@@ -849,7 +876,7 @@ function detectCharThreats(text, opts, codePrefix) {
849
876
  issues.push({
850
877
  kind: "zero-width",
851
878
  severity: opts.zeroWidthPolicy === "reject" ? "critical"
852
- : opts.zeroWidthPolicy === "audit" ? "warn" : "high",
879
+ : isAuditPolicy(opts.zeroWidthPolicy) ? "warn" : "high",
853
880
  ruleId: codePrefix + ".zero-width",
854
881
  location: zwMatch.index,
855
882
  snippet: "zero-width / invisible-formatting char U+" +
@@ -877,7 +904,7 @@ function detectCharThreats(text, opts, codePrefix) {
877
904
  issues.push({
878
905
  kind: "unicode-tags",
879
906
  severity: tagsPolicy === "reject" ? "critical"
880
- : tagsPolicy === "audit" ? "warn" : "high",
907
+ : isAuditPolicy(tagsPolicy) ? "warn" : "high",
881
908
  ruleId: codePrefix + ".unicode-tags",
882
909
  location: tagMatch.index,
883
910
  snippet: "Unicode Tags block char U+" +
@@ -1724,6 +1751,7 @@ module.exports = {
1724
1751
  hex4: hex4,
1725
1752
  charClass: charClass,
1726
1753
  inRanges: inRanges,
1754
+ isAuditPolicy: isAuditPolicy,
1727
1755
  firstInRanges: firstInRanges,
1728
1756
  stripRanges: stripRanges,
1729
1757
  replaceRanges: replaceRanges,
@@ -2143,13 +2143,97 @@ function buildContentGate(spec) {
2143
2143
  * b.gateContract.policyDisposition("audit-only"); // → "audit"
2144
2144
  * b.gateContract.policyDisposition("rejet"); // → "refuse" (fail closed)
2145
2145
  */
2146
+ // The character-threat policies every guard in the family shares, and the
2147
+ // vocabulary each accepts. A value outside it is a typo, and a typo is a
2148
+ // CONFIG-TIME error: read leniently at runtime it takes whichever branch is not
2149
+ // the strict one — the scan runs because the value is not "allow", an issue is
2150
+ // raised, and policyDisposition falls through to "refuse". That fails closed,
2151
+ // so it is not a hole, but the operator asked for one disposition and silently
2152
+ // got another. This turns it into a boot error instead.
2153
+ //
2154
+ // `strip` is an instruction to repair, so it is legal only where the guard has
2155
+ // a `sanitize` to repair with — the same rule the family's performable-actions
2156
+ // invariant asserts. An `entries` guard has none by design: a hostile archive
2157
+ // member cannot be made safe.
2158
+ var CHAR_POLICY_KEYS = Object.freeze([
2159
+ "bidiPolicy", "nullBytePolicy", "controlPolicy", "zeroWidthPolicy", "tagsPolicy",
2160
+ ]);
2161
+ var CHAR_POLICY_BASE = Object.freeze(["allow", "audit", "audit-only", "reject"]);
2162
+
2163
+ /**
2164
+ * @primitive b.gateContract.charPolicyEnums
2165
+ * @signature b.gateContract.charPolicyEnums(defaults, opts?)
2166
+ * @since 0.18.47
2167
+ * @status stable
2168
+ * @related b.gateContract.resolveProfileAndPosture, b.gateContract.defineGuard
2169
+ *
2170
+ * The `enumOpts` entry for every character-threat policy present in `defaults`,
2171
+ * so a guard binding its own resolver holds callers to the same vocabulary the
2172
+ * generated one does. `opts.canRepair` adds `strip`, which is an instruction to
2173
+ * repair and so legal only where the guard has a `sanitize` to perform it.
2174
+ *
2175
+ * Pass the result as `enumOpts` to `resolveProfileAndPosture`. A guard whose
2176
+ * gate or validate binds its own resolver and omits this accepts a misspelled
2177
+ * policy on that entry point while the generated path refuses it — the two
2178
+ * doors disagreeing is how the check came to cover only one of them.
2179
+ *
2180
+ * @opts
2181
+ * canRepair: boolean, // default: false — adds `strip` to every entry
2182
+ *
2183
+ * @example
2184
+ * gateContract.resolveProfileAndPosture(opts, {
2185
+ * profiles: PROFILES,
2186
+ * defaults: DEFAULTS,
2187
+ * enumOpts: gateContract.charPolicyEnums(DEFAULTS, { canRepair: true }),
2188
+ * });
2189
+ */
2190
+ function charPolicyEnums(defaults, opts) {
2191
+ var canRepair = !!(opts && opts.canRepair);
2192
+ var out = null;
2193
+ for (var i = 0; i < CHAR_POLICY_KEYS.length; i += 1) {
2194
+ var key = CHAR_POLICY_KEYS[i];
2195
+ // `tagsPolicy` is usually ABSENT from a guard's defaults because it
2196
+ // INHERITS from zeroWidthPolicy (resolveTagsPolicy reads one when the other
2197
+ // is unset). It is still a supported inline override, so a guard exposing
2198
+ // the inherited behaviour must hold a typo in it to the same vocabulary —
2199
+ // keying only on presence left `{ tagsPolicy: "strp" }` unchecked on every
2200
+ // guard that inherits, and the Tags detector then read the typo as a
2201
+ // high-severity refusal instead of the promised boot error.
2202
+ var governs = defaults && (typeof defaults[key] === "string" ||
2203
+ (key === "tagsPolicy" && typeof defaults.zeroWidthPolicy === "string"));
2204
+ if (!governs) continue;
2205
+ if (!out) out = {};
2206
+ out[key] = canRepair ? CHAR_POLICY_BASE.concat(["strip"]) : CHAR_POLICY_BASE.slice();
2207
+ }
2208
+ return out;
2209
+ }
2210
+
2211
+ function _withCharPolicyEnums(defaults, spec) {
2212
+ var declared = spec.enumOpts && typeof spec.enumOpts === "object" ? spec.enumOpts : null;
2213
+ // DECLARED, not inferred from the presence of a `sanitize`. An identifier
2214
+ // guard exports one that validates and throws — there is nothing to repair,
2215
+ // the value either is a UUID or is not — so inferring the capability let
2216
+ // guardRegex, guardJwt, guardShell and guardTemplate accept `strip` at
2217
+ // construction and refuse at runtime, which is the silent substitution this
2218
+ // check exists to stop. A guard says whether it can repair a character, and
2219
+ // the family sweep holds it to the claim.
2220
+ var derived = charPolicyEnums(defaults, { canRepair: spec.charRepair === true });
2221
+ if (!derived) return declared || null;
2222
+ var out = declared ? Object.assign({}, declared) : {};
2223
+ Object.keys(derived).forEach(function (key) {
2224
+ if (declared && declared[key]) return; // an explicit declaration wins
2225
+ out[key] = derived[key];
2226
+ });
2227
+ return out;
2228
+ }
2229
+
2146
2230
  var MITIGATION_POLICIES = Object.freeze({
2147
2231
  strip: true, "prefix-tab": true, "prefix-quote": true,
2148
2232
  "wrap-with-quotes-and-prefix": true, allowlist: true, redact: true, trim: true,
2149
2233
  });
2150
2234
  function policyDisposition(policy) {
2151
2235
  if (policy === "reject") return "refuse";
2152
- if (policy === "audit" || policy === "audit-only") return "audit";
2236
+ if (codepointClass.isAuditPolicy(policy)) return "audit";
2153
2237
  if (MITIGATION_POLICIES[policy] === true) return "sanitize";
2154
2238
  return "refuse";
2155
2239
  }
@@ -3168,7 +3252,12 @@ function defineGuard(spec) {
3168
3252
  // validate() below.
3169
3253
  nonNegativeOpts: _capKeys(defaults, spec.intOpts),
3170
3254
  // Options restricted to a fixed vocabulary, checked at the same funnel.
3171
- enumOpts: spec.enumOpts || null,
3255
+ // The character policies are derived rather than hand-listed, for the
3256
+ // reason the caps above are: declared per guard, they were declared by
3257
+ // one. guard-country wrote `enumOpts` for its own three opts after
3258
+ // `{ reservedPolicy: "rejcet" }` served a reserved code and reported ok,
3259
+ // and the other 259 policy opts across the family never followed.
3260
+ enumOpts: _withCharPolicyEnums(defaults, spec),
3172
3261
  });
3173
3262
  };
3174
3263
  if (typeof spec.detect === "function") {
@@ -3360,6 +3449,10 @@ function defineGuard(spec) {
3360
3449
  var out = {
3361
3450
  NAME: spec.name,
3362
3451
  KIND: spec.kind,
3452
+ // Surfaced so a caller — and the family sweep — can ask whether `strip` is
3453
+ // an instruction this guard can carry out, rather than inferring it from
3454
+ // the presence of a `sanitize` that may only validate and throw.
3455
+ CHAR_REPAIR: spec.charRepair === true,
3363
3456
  // The options this guard DECLARES to be caps, where zero is refused. Every
3364
3457
  // other numeric option is derived from the defaults and held only to
3365
3458
  // "non-negative integer", because derivation cannot tell a cap from a
@@ -3379,6 +3472,20 @@ function defineGuard(spec) {
3379
3472
  out.EXTENSIONS = Object.freeze((spec.extensions || []).slice());
3380
3473
  }
3381
3474
  if (spec.integrationFixtures) out.INTEGRATION_FIXTURES = spec.integrationFixtures;
3475
+ // A declaration has to be backed by something. `charRepair: true` says the
3476
+ // guard can carry out `strip`, which puts that value in its character-policy
3477
+ // vocabulary — so a spec that declares it without a `sanitize` (or a
3478
+ // `sanitizeTransform` for one to be built from) would accept the instruction
3479
+ // at boot and refuse it at runtime, which is the substitution the declaration
3480
+ // was introduced to stop. Checked HERE rather than earlier because
3481
+ // sanitizeTransform generates the sanitize further up, and checking before
3482
+ // that would refuse a spec that does supply a repair path.
3483
+ if (spec.charRepair === true && typeof spec.sanitize !== "function") {
3484
+ throw ErrorClass.factory(prefix + ".bad-spec",
3485
+ prefix + ": charRepair is declared but the guard has no sanitize — " +
3486
+ "`strip` on a character policy is an instruction to repair, so declare a " +
3487
+ "sanitize or a sanitizeTransform, or drop charRepair.");
3488
+ }
3382
3489
  if (typeof spec.sanitize === "function") out.sanitize = spec.sanitize;
3383
3490
  out.gate = gateFn;
3384
3491
  // Error class exported under its own constructor name (GuardCsvError etc.)
@@ -3713,6 +3820,7 @@ module.exports = {
3713
3820
  // defineGuard: the cap keys to declare, derived from its defaults so the
3714
3821
  // list cannot drift away from them.
3715
3822
  capKeysOf: capKeysOf,
3823
+ charPolicyEnums: charPolicyEnums,
3716
3824
  identitySanitize: identitySanitize,
3717
3825
  ctxValueFrom: ctxValueFrom,
3718
3826
  runIssueValidator: runIssueValidator,
@@ -1118,6 +1118,7 @@ var INTEGRATION_FIXTURES = Object.freeze({
1118
1118
  module.exports = gateContract.defineGuard({
1119
1119
  name: "email",
1120
1120
  kind: "content",
1121
+ charRepair: true,
1121
1122
  errorClass: GuardEmailError,
1122
1123
  profiles: PROFILES,
1123
1124
  defaults: DEFAULTS,
@@ -1306,6 +1306,7 @@ var INTEGRATION_FIXTURES = Object.freeze({
1306
1306
  module.exports = gateContract.defineGuard({
1307
1307
  name: "filename",
1308
1308
  kind: "filename",
1309
+ charRepair: true,
1309
1310
  errorClass: GuardFilenameError,
1310
1311
  profiles: PROFILES,
1311
1312
  defaults: DEFAULTS,
package/lib/guard-html.js CHANGED
@@ -983,6 +983,10 @@ var INTEGRATION_FIXTURES = Object.freeze({
983
983
  module.exports = gateContract.defineGuard({
984
984
  name: "html",
985
985
  kind: "content",
986
+ // sanitize rewrites the document, so `strip` on a character policy is an
987
+ // instruction this guard can carry out. Declared rather than inferred from
988
+ // the presence of a sanitize — an identifier guard has one that only throws.
989
+ charRepair: true,
986
990
  errorClass: GuardHtmlError,
987
991
  profiles: PROFILES,
988
992
  defaults: DEFAULTS,
package/lib/guard-json.js CHANGED
@@ -1086,6 +1086,7 @@ var INTEGRATION_FIXTURES = Object.freeze({
1086
1086
  module.exports = gateContract.defineGuard({
1087
1087
  name: "json",
1088
1088
  kind: "content",
1089
+ charRepair: true,
1089
1090
  errorClass: GuardJsonError,
1090
1091
  profiles: PROFILES,
1091
1092
  defaults: DEFAULTS,
@@ -1905,6 +1905,7 @@ function render(source, opts) {
1905
1905
  module.exports = gateContract.defineGuard({
1906
1906
  name: "markdown",
1907
1907
  kind: "content",
1908
+ charRepair: true,
1908
1909
  errorClass: GuardMarkdownError,
1909
1910
  profiles: PROFILES,
1910
1911
  base: 256,
package/lib/guard-svg.js CHANGED
@@ -1008,6 +1008,7 @@ var INTEGRATION_FIXTURES = Object.freeze({
1008
1008
  var _guard = module.exports = gateContract.defineGuard({
1009
1009
  name: "svg",
1010
1010
  kind: "content",
1011
+ charRepair: true,
1011
1012
  errorClass: GuardSvgError,
1012
1013
  profiles: PROFILES,
1013
1014
  defaults: DEFAULTS,
package/lib/guard-text.js CHANGED
@@ -129,9 +129,13 @@ function _resolveOpts(opts) {
129
129
  defaults: DEFAULTS,
130
130
  errorClass: GuardTextError,
131
131
  errCodePrefix: "text",
132
- // Own resolver, own cap declaration — see guard-image.gate.
132
+ // Own resolver, own cap declaration — see guard-image.gate. The policy
133
+ // vocabulary comes with it for the same reason: a hand-bound resolver that
134
+ // omits it accepts `bidiPolicy: "rejct"` on gate() and validate() while the
135
+ // generated path refuses it.
133
136
  intOpts: INT_OPTS,
134
137
  nonNegativeOpts: gateContract.capKeysOf(DEFAULTS),
138
+ enumOpts: gateContract.charPolicyEnums(DEFAULTS, { canRepair: true }),
135
139
  });
136
140
  }
137
141
 
@@ -573,6 +577,7 @@ var INTEGRATION_FIXTURES = Object.freeze({
573
577
  module.exports = gateContract.defineGuard({
574
578
  name: "text",
575
579
  kind: "content",
580
+ charRepair: true,
576
581
  errorClass: GuardTextError,
577
582
  profiles: PROFILES,
578
583
  defaults: DEFAULTS,
package/lib/guard-xml.js CHANGED
@@ -736,6 +736,7 @@ var INTEGRATION_FIXTURES = Object.freeze({
736
736
  var _guard = module.exports = gateContract.defineGuard({
737
737
  name: "xml",
738
738
  kind: "content",
739
+ charRepair: true,
739
740
  errorClass: GuardXmlError,
740
741
  profiles: PROFILES,
741
742
  defaults: DEFAULTS,
package/lib/guard-yaml.js CHANGED
@@ -648,9 +648,12 @@ function parse(input, opts) {
648
648
  defaults: DEFAULTS,
649
649
  errorClass: GuardYamlError,
650
650
  errCodePrefix: "yaml",
651
- // Own resolver, own cap declaration — see guard-image.gate.
651
+ // Own resolver, own cap declaration — see guard-image.gate. The policy
652
+ // vocabulary comes with it: a hand-bound resolver that omits it accepts
653
+ // `bidiPolicy: "rejct"` here while the generated path refuses it.
652
654
  intOpts: INT_OPTS,
653
655
  nonNegativeOpts: gateContract.capKeysOf(DEFAULTS),
656
+ enumOpts: gateContract.charPolicyEnums(DEFAULTS, { canRepair: true }),
654
657
  });
655
658
  if (typeof input !== "string") {
656
659
  throw _err("yaml.bad-input", "parse requires string input");
@@ -797,9 +800,12 @@ function gate(opts) {
797
800
  defaults: DEFAULTS,
798
801
  errorClass: GuardYamlError,
799
802
  errCodePrefix: "yaml",
800
- // Own resolver, own cap declaration — see guard-image.gate.
803
+ // Own resolver, own cap declaration — see guard-image.gate. The policy
804
+ // vocabulary comes with it: a hand-bound resolver that omits it accepts
805
+ // `bidiPolicy: "rejct"` here while the generated path refuses it.
801
806
  intOpts: INT_OPTS,
802
807
  nonNegativeOpts: gateContract.capKeysOf(DEFAULTS),
808
+ enumOpts: gateContract.charPolicyEnums(DEFAULTS, { canRepair: true }),
803
809
  });
804
810
  return gateContract.buildContentGate({
805
811
  name: opts.name || "guardYaml:" + (opts.profile || "default"),
@@ -813,6 +819,7 @@ function gate(opts) {
813
819
  module.exports = gateContract.defineGuard({
814
820
  name: "yaml",
815
821
  kind: "content",
822
+ charRepair: true,
816
823
  errorClass: GuardYamlError,
817
824
  profiles: PROFILES,
818
825
  defaults: DEFAULTS,
@@ -2086,8 +2086,13 @@ function isNullMx(mxRecords) {
2086
2086
  * Classify a DNSKEY / RRSIG algorithm number against the IANA DNS
2087
2087
  * Security Algorithm Numbers registry, flagging SHA-1-based and
2088
2088
  * other deprecated algorithms per RFC 9905 (Deprecating DNSSEC
2089
- * SHA-1 Usage), RFC 8624 (Algorithm Implementation Requirements),
2090
- * and RFC 6944 / RFC 6725 (RSAMD5 deprecation).
2089
+ * SHA-1 Usage), RFC 9904 (Algorithm Implementation Requirements,
2090
+ * which obsoletes RFC 8624), and RFC 6725 (RSAMD5 deprecation).
2091
+ *
2092
+ * `deprecated` answers "should this still be accepted", so it follows
2093
+ * the VALIDATION requirement. Where a spelling differs between signing
2094
+ * and validation — RSASHA512 is NOT RECOMMENDED to sign with but MUST
2095
+ * still validate — the `reason` names both, since one boolean cannot.
2091
2096
  *
2092
2097
  * Returns `{ algorithm, name, deprecated, reason, known }` for any
2093
2098
  * IANA-assigned number; `known: false` for unassigned numbers
@@ -2123,18 +2128,22 @@ function isNullMx(mxRecords) {
2123
2128
  // auditing DNSSEC chain-of-trust evidence know they cannot validate
2124
2129
  // the entry against a public algorithm registry.
2125
2130
  var DNSKEY_ALGORITHMS = Object.freeze({
2126
- 1: { name: "RSAMD5", deprecated: true, reason: "MD5 broken (RFC 6944 §2.1, RFC 6725)" },
2131
+ 1: { name: "RSAMD5", deprecated: true, reason: "MD5 broken; deprecated for zone signing (RFC 6725 §2.2)" },
2127
2132
  2: { name: "DH", deprecated: true, reason: "Diffie-Hellman key (RFC 2539) — never widely deployed; superseded by signature algorithms" },
2128
- 3: { name: "DSA", deprecated: true, reason: "DSA deprecated (RFC 8624 §3.1)" },
2133
+ 3: { name: "DSA", deprecated: true, reason: "DSA MUST NOT be used for signing or validation (RFC 9904 Table 2)" },
2129
2134
  4: { name: "Reserved", deprecated: true, reason: "Reserved (RFC 4034 §A.1) — not for production use" },
2130
2135
  5: { name: "RSASHA1", deprecated: true, reason: "SHA-1 deprecated (RFC 9905 §3)" },
2131
- 6: { name: "DSA-NSEC3-SHA1", deprecated: true, reason: "SHA-1 deprecated (RFC 9905 §3); DSA deprecated (RFC 8624 §3.1)" },
2136
+ 6: { name: "DSA-NSEC3-SHA1", deprecated: true, reason: "SHA-1 deprecated (RFC 9905 §3); DSA MUST NOT be used (RFC 9904 Table 2)" },
2132
2137
  7: { name: "RSASHA1-NSEC3-SHA1", deprecated: true, reason: "SHA-1 deprecated (RFC 9905 §3)" },
2133
2138
  8: { name: "RSASHA256", deprecated: false, reason: "current — RFC 5702" }, // IANA DNSKEY algorithm number
2134
2139
  9: { name: "Reserved", deprecated: true, reason: "Reserved (RFC 5155) — not for production use" },
2135
- 10: { name: "RSASHA512", deprecated: false, reason: "current RFC 5702" },
2140
+ // Not deprecated — validation is still MUST, so a caller deciding whether to
2141
+ // ACCEPT a zone must keep accepting it. But it is NOT RECOMMENDED to sign
2142
+ // with, and reporting it as plainly "current" answered a signing question
2143
+ // with the opposite of what the current RFC says.
2144
+ 10: { name: "RSASHA512", deprecated: false, reason: "defined in RFC 5702; RFC 9904 Table 2 — signing NOT RECOMMENDED, validation MUST" },
2136
2145
  11: { name: "Reserved", deprecated: true, reason: "Reserved (RFC 5155) — not for production use" },
2137
- 12: { name: "ECC-GOST", deprecated: true, reason: "deprecated (RFC 8624 §3.1)" },
2146
+ 12: { name: "ECC-GOST", deprecated: true, reason: "MUST NOT be used for signing (RFC 9904 Table 2)" },
2138
2147
  13: { name: "ECDSAP256SHA256", deprecated: false, reason: "current — RFC 6605" },
2139
2148
  14: { name: "ECDSAP384SHA384", deprecated: false, reason: "current — RFC 6605" },
2140
2149
  15: { name: "ED25519", deprecated: false, reason: "current — RFC 8080" },
@@ -2173,15 +2182,19 @@ var DNSKEY_ALGORITHMS = Object.freeze({
2173
2182
  * // → { digestType: 2, name: "SHA-256", deprecated: false, ... }
2174
2183
  */
2175
2184
 
2176
- // DS digest-type vocabulary (RFC 4034 §5.1 + RFC 6605 §6 + RFC 8624
2177
- // §3.2 + RFC 9558). Digest type 1 = SHA-1 is deprecated per RFC 9905
2178
- // §4. Digest types 5 (GOST R 34.11-2012) and 6 (SM3) added by RFC
2179
- // 9558. Reserved value 0 surfaced for completeness.
2185
+ // DS digest-type vocabulary (RFC 4034 §5.1 + RFC 6605 §6 + RFC 9904
2186
+ // Table 3 + RFC 9558). Digest type 1 = SHA-1 is deprecated per RFC 9905
2187
+ // §4 RFC 9904 Table 3 spells it MUST NOT for delegation and MUST for
2188
+ // validation, so `deprecated` follows the delegation side here for the
2189
+ // same reason it follows validation in the DNSKEY table: that is the
2190
+ // side an operator can still choose. Digest types 5 (GOST R 34.11-2012)
2191
+ // and 6 (SM3) added by RFC 9558. Reserved value 0 surfaced for
2192
+ // completeness.
2180
2193
  var DS_DIGEST_TYPES = Object.freeze({
2181
2194
  0: { name: "Reserved", deprecated: true, reason: "Reserved (RFC 3658) — not for production use" },
2182
2195
  1: { name: "SHA-1", deprecated: true, reason: "SHA-1 deprecated (RFC 9905 §4)" },
2183
2196
  2: { name: "SHA-256", deprecated: false, reason: "current — RFC 4509" },
2184
- 3: { name: "GOST R 34.11-94", deprecated: true, reason: "deprecated (RFC 8624 §3.2; superseded by GOST 2012 in RFC 9558)" },
2197
+ 3: { name: "GOST R 34.11-94", deprecated: true, reason: "MUST NOT be used for delegation (RFC 9904 Table 3); superseded by GOST 2012 in RFC 9558" },
2185
2198
  4: { name: "SHA-384", deprecated: false, reason: "current — RFC 6605 §6" },
2186
2199
  5: { name: "GOST R 34.11-2012", deprecated: false, reason: "current — RFC 9558 §3" },
2187
2200
  6: { name: "SM3", deprecated: false, reason: "current — RFC 9558 §3 (Chinese national standard)" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.18.45",
3
+ "version": "0.18.47",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
package/sbom.cdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:0c868505-f25e-4aac-a3e5-9d64001e0f98",
5
+ "serialNumber": "urn:uuid:2b85824f-8c87-4b4e-9ee7-abfe05e1b852",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-08-22T05:10:30.712Z",
8
+ "timestamp": "2026-08-22T12:32:06.262Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/core@0.18.45",
22
+ "bom-ref": "@blamejs/core@0.18.47",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.18.45",
25
+ "version": "0.18.47",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "The Node framework that owns its stack.",
29
- "purl": "pkg:npm/%40blamejs/core@0.18.45",
29
+ "purl": "pkg:npm/%40blamejs/core@0.18.47",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/core@0.18.45",
57
+ "ref": "@blamejs/core@0.18.47",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]