@blamejs/core 0.16.15 → 0.16.17

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,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.16.x
10
10
 
11
+ - v0.16.17 (2026-07-12) — **Reject INI float literals that overflow to Infinity, return a verdict (not an exception) when a reverse-DNS lookup faults, wrap an async redirect-hook rejection like a sync throw, and refuse a fractional --max-rows — four defects surfaced by covering previously-untested error branches.** Covering previously-untested error and adversarial branches across four primitives surfaced four genuine defects, each fixed at the root. b.parsers.ini.parse coerced a value like `x = 1e999` straight to ±Infinity — its integer and hex branches already reject out-of-range numbers, but the float branch had no finiteness guard, so an overflowing float slipped through and could poison a downstream size cap or timeout; it is now rejected with ini/value-out-of-range. b.mail.iprev.verify threw an unhandled exception when the forward-confirm DNS lookup returned an error code outside the handful it enumerated (EREFUSED / ENOTIMP / …), even though its reverse-lookup path and every sibling (SPF/DKIM/DMARC/ARC) return a verdict for such faults; it now returns a temperror verdict, and — like those siblings — accepts an operator opts.dnsLookup resolver so the confirm path is resolvable offline. b.httpClient wrapped a synchronous onRedirect hook throw into a REDIRECT_ABORTED error but let an async hook rejection escape unwrapped; both now abort the redirect identically. And the blamejs audit verify-chain --max-rows flag accepted a fractional value (2.5), which truncated the chain walk mid-row and reported a nonsensical fractional count; it now requires a whole positive integer, matching its own error message. **Fixed:** *b.parsers.ini.parse rejects an overflowing float instead of coercing to Infinity* — A float literal that exceeds the representable range (e.g. `x = 1e999`) coerced to ±Infinity. The integer and hex coercion branches already reject out-of-range numbers via Number.isSafeInteger, but the float branch returned Number(raw) with no finiteness check — so an Infinity could flow into a downstream size cap or timeout, a denial-of-service vector. The float branch now rejects a non-finite result with ini/value-out-of-range; a large-but-finite float (1e308) and underflow (1e-999 → 0) still parse. · *b.mail.iprev.verify returns a temperror verdict on an un-enumerated reverse/forward DNS fault* — The forward-confirm DNS lookup's error handler enumerated a few transient codes and threw for anything else, so a resolver returning EREFUSED / ENOTIMP / EBADRESP produced an unhandled exception from the public API rather than a verdict. The reverse-lookup path and every sibling result type (SPF / DKIM / DMARC / ARC) return a verdict for a DNS-derived fault; the forward path now does too (temperror). iprev.verify also gains an operator opts.dnsLookup resolver, matching the dnsLookup contract the other types already honor, so the forward-confirm path is resolvable offline. · *b.httpClient aborts a redirect on an async onRedirect hook rejection* — A synchronous throw from the onRedirect hook was wrapped into a REDIRECT_ABORTED error, but an async hook that rejected let the rejection escape unwrapped — inconsistent handling for the same operator control point. An async onRedirect rejection now aborts the redirect with REDIRECT_ABORTED, identical to the synchronous throw. · *blamejs audit verify-chain --max-rows requires a whole positive integer* — The --max-rows flag validated only that the value was finite and >= 1, so a fractional value (2.5) was accepted and passed to the chain walk, where it truncated the verification mid-row and reported a fractional rowsVerified count — despite the flag's own error message promising a positive integer. It now rejects a non-integer value, matching the sibling --steps flag.
12
+
13
+ - v0.16.16 (2026-07-12) — **Build the data-subject-request ticket store's SQL through the shared b.sql query builder instead of hand-assembled statements, and add a static check that keeps db-handle primitives composing b.sql.** A maintainability change with no behavior difference for operators. The b.dsr ticket store built its reads and writes by concatenating table and column names into SQL strings passed to db.prepare, re-implementing the identifier quoting and sealed-field handling that b.sql — the same builder b.db.from() uses — already provides. That hand-rolled shape is how b.tenant.quota's storage query drifted from the query builder and accrued a run of parity defects fixed in 0.16.15 (reserved-word names, schema-qualified names, sealed-column filtering). The store's DML now composes b.sql (its schema DDL, which is not a b.sql concern, stays as direct statements), so its SQL cannot diverge from the builder. A new codebase-patterns check flags any db-handle primitive that passes an inline SELECT / INSERT / UPDATE / DELETE string literal to db.prepare / runSql, directing it to compose b.sql instead, so this class of drift cannot recur. **Changed:** *b.dsr ticket store composes b.sql for its reads and writes* — The data-subject-request ticket store (insert / get / list / update / delete / purge and the legacy re-seal backfill) now builds its DML with the b.sql query builder — sql.select / insert / update / delete(table, { dialect, quoteName }).…toSql() — and prepares the resulting statement, rather than concatenating identifiers into SQL strings by hand. This removes a hand-rolled identifier-quoting surface that could drift from what b.db.from() accepts. Schema provisioning (CREATE TABLE / INDEX, ALTER, PRAGMA) is not a b.sql concern and remains as direct statements. One behavior change: on a store backed by a vault, a ticket payload is AEAD-sealed and base64-encoded (~4/3 expansion) before it is bound, and the bound cell must fit the query builder's 64 MiB per-value ceiling — so the payload is now capped at an expansion-safe plaintext size (~48 MiB) and a larger ticket is refused with dsr/ticket-too-large (route large access/portability exports through chunked storage rather than one giant sealed cell). Plaintext stores keep the full 64 MiB limit. When a vault is first enabled on a table that already holds an over-cap legacy plaintext row, the one-time re-seal backfill still migrates that row's subject columns and derived hashes — so it stays findable by subject lookup and erasable by the data-subject erasure purge — and leaves only the over-cap payload plaintext (still under the read ceiling, DB-encrypted at rest, and removed when the row is erased), rather than failing provisioning with a query-builder error. **Detectors:** *Static check: db-handle primitives must compose b.sql for DML* — A new codebase-patterns check flags any primitive holding a db handle that runs DML by passing an inline SELECT / INSERT / UPDATE / DELETE string literal to db.prepare / runSql — the shape that lets a query drift from b.sql's identifier quoting and sealed-field rewrite (the b.tenant.quota storage defect class). It directs authors to build the query with b.sql and prepare the resulting string. DDL and PRAGMA (not b.sql verbs) and queries already built through a b.sql variable are out of scope.
14
+
11
15
  - v0.16.15 (2026-07-11) — **Restore break-glass certificate key escrow, hand a failed production-security assertion its real diagnostic message, and make tenant storage-byte quotas actually enforce — three defects surfaced by broadening test coverage and fixed at the root.** Three primitives had defects that only a hostile or previously-untested path reached. b.cert key escrow — the optional break-glass path that seals a renewed private key to an operator's offline recipient — never worked: writeEscrow called a b.crypto method that does not exist, so any certificate configured with keyEscrow threw the moment renewal tried to seal the key. It now seals via b.crypto.encrypt (ML-KEM-1024, plus the P-384 hybrid leg when the recipient supplies an ecPublicKey) and the operator recovers the key offline with b.crypto.decrypt; the recipient accepts an ML-KEM-1024 public-key PEM string or a { publicKey, ecPublicKey } pair from b.crypto.generateEncryptionKeyPair(). b.security.assertProduction constructed its error with the code and message transposed, so a failed production-security assertion threw with a bare token (BAD_OPT / ASSERT_FAILED) as its .message and buried the human-readable explanation in .code — operators now get the full diagnostic where they read it. And b.tenant.quota storage-byte accounting was broken several ways: the per-tenant byte sum issued a query the builder rejects, so snapshot / assert / list always threw once a storage cap was set; it read rows through the auto-unsealing ORM, so a sealed column was measured as its small decrypted plaintext rather than the larger on-disk vault envelope (letting sealed-column tenants slip under the cap); when the tenant identifier itself was a sealed column, the plaintext lookup matched no rows at all and the cap silently counted zero; and BLOB columns (handed back as Uint8Array by node:sqlite) were stringified before measuring, roughly tripling their counted size and refusing writes far below the real cap. All are fixed — the sum now filters a sealed tenant id by its derived-hash blind index, reads the raw stored rows, and measures true on-disk byte lengths — so storage quotas enforce at the configured limit. **Fixed:** *b.cert break-glass key escrow seals the renewed key instead of throwing* — A certificate configured with keyEscrow forwarded the private key to writeEscrow, which called a b.crypto.encryptEnvelope method that does not exist — so escrow threw on every renewal and the break-glass recovery path was unusable. It now seals the key to the operator's offline recipient with b.crypto.encrypt: ML-KEM-1024 always, plus a P-384 hybrid leg when the recipient carries an ecPublicKey. The recipient accepts an ML-KEM-1024 public-key PEM string or a { publicKey, ecPublicKey } pair from b.crypto.generateEncryptionKeyPair(); the sealed key is never decrypted by the framework and is recovered offline with b.crypto.decrypt and the matching private key(s). · *b.security.assertProduction throws with the diagnostic in .message* — SecurityAssertError was constructed with its code and message arguments transposed, so a failed production-security assertion surfaced a bare token (BAD_OPT / ASSERT_FAILED) as its .message while the explanatory text — including the per-assertion failure list — landed in .code. Operators catching the error now read the full diagnostic in .message and the stable token in .code, as documented. · *b.tenant.quota enforces storage-byte caps at the configured limit* — The per-tenant storage-bytes accounting had several defects. It issued a query the query builder rejects (a literal '*' column), so snapshot / assert / list threw as soon as a storage cap was configured — the storage half of tenant quotas never ran against a real database. It read rows through the ORM, which auto-unseals sealed columns, so a sealed cell was measured as its small decrypted plaintext rather than the much larger vault envelope actually on disk — a tenant whose data lives in sealed columns could sail under the cap. When the tenant identifier column itself was sealed, the plaintext lookup compared against the on-disk envelope and matched no rows, so the cap silently counted zero for those tenants. And BLOB columns, which node:sqlite returns as a Uint8Array rather than a Node Buffer, were stringified before measuring: String(uint8array) is the decimal-joined bytes, roughly a 3x overcount that refused writes well below the real cap. The sum now filters a sealed tenant identifier by its derived-hash blind index (as the query builder does), reads the raw stored rows (no unseal), and counts text as its UTF-8 byte length and typed-array views by their true byte length, so a storage cap — including data in sealed columns — enforces at the limit operators set.
12
16
 
13
17
  - v0.16.14 (2026-07-11) — **Make the object-store single-backend shorthand work for remote backends, and return a string time zone (not an array) when importing an iCalendar event — two defects found by covering previously-untested configuration and import branches.** Covering more configuration and import branches surfaced two genuine defects, now fixed at the root. The documented object-store single-backend shorthand — b.storage.init({ backend: 'sigv4' | 'gcs' | 'azure-blob' | 'http-put', ... }) — never worked for a remote backend: it forwarded the caller's options with the backend key intact, but the object-store backend builder resolves protocol, so the backend was constructed with no protocol and initialization threw a missing-protocol error. Only the local shorthand (which happens to name the key correctly) worked. The shorthand now translates backend to protocol, so all four remote backends construct as documented. And b.calendar.fromIcal mapped a DTSTART;TZID=<zone> parameter to a JSCalendar timeZone that was an array (['America/New_York']) instead of the string RFC 8984 §4.7.1 requires — it only round-tripped by accident because a single-element array coerces to a string; the parameter is now unwrapped to a scalar string. **Fixed:** *b.storage remote single-backend shorthand constructs the backend* — b.storage.init({ backend: 'sigv4' | 'gcs' | 'azure-blob' | 'http-put', ... }) forwarded the options with the backend key, but the object-store backend builder reads protocol — so the default backend had no protocol and initialization threw a missing-protocol ObjectStoreError. The remote shorthand never worked (only the { backend: 'local' } form, which names protocol correctly under the hood, did). The shorthand now maps backend to protocol and drops the backend key, so all four remote backends build as documented. · *b.calendar.fromIcal returns a string time zone for DTSTART/DUE;TZID* — An imported event's DTSTART;TZID=<zone> (or a task's DUE;TZID) mapped to a JSCalendar timeZone that was a single-element array rather than the string RFC 8984 §4.7.1 requires. It happened to round-trip back through toIcal because a one-element array coerces to a string, but consumers reading timeZone as a string saw an array. The property parameter is now unwrapped to its scalar first value.
package/lib/cli.js CHANGED
@@ -713,7 +713,14 @@ async function _runAudit(args, ctx) {
713
713
  var tableV = args.flags.table ? String(args.flags.table) : "audit_log";
714
714
  var maxRows = args.flags["max-rows"];
715
715
  var maxRowsN = maxRows === undefined ? undefined : Number(maxRows);
716
- if (maxRowsN !== undefined && (!Number.isFinite(maxRowsN) || maxRowsN < 1)) {
716
+ // Positive-INTEGER validation, mirroring `migrate down --steps`: reject
717
+ // NaN/Infinity, < 1, AND any fractional value. The message promises "a
718
+ // positive integer", so a value like 2.5 must be refused here at the
719
+ // entry point rather than passed to verifyChain, where a fractional
720
+ // maxRows truncates the walk mid-chain and reports a nonsensical
721
+ // fractional rowsVerified (Math.min(rows.length, 2.5)).
722
+ if (maxRowsN !== undefined &&
723
+ (!Number.isFinite(maxRowsN) || maxRowsN < 1 || Math.floor(maxRowsN) !== maxRowsN)) {
717
724
  _writeLine(ctx.stderr, "blamejs audit verify-chain: --max-rows must be a positive integer");
718
725
  return 2;
719
726
  }
package/lib/dsr.js CHANGED
@@ -116,6 +116,7 @@ var bCrypto = require("./crypto");
116
116
  var lazyRequire = require("./lazy-require");
117
117
  var validateOpts = require("./validate-opts");
118
118
  var safeSql = require("./safe-sql");
119
+ var sql = require("./sql");
119
120
  var safeJson = require("./safe-json");
120
121
  var boundedMap = require("./bounded-map");
121
122
  var { defineClass } = require("./framework-error");
@@ -124,6 +125,13 @@ var DsrError = defineClass("DsrError", { alwaysPermanent: true });
124
125
 
125
126
  var audit = lazyRequire(function () { return require("./audit"); });
126
127
  var observability = lazyRequire(function () { return require("./observability"); });
128
+
129
+ // A vaulted store AEAD-seals + base64-encodes the payload before binding it
130
+ // (~4/3 expansion), and the bound cell must fit b.sql's 64 MiB per-value
131
+ // ceiling. Cap the plaintext at an expansion-safe size — leave a KiB for the
132
+ // vault nonce / tag / prefix — so the sealed cell binds through b.sql; a
133
+ // plaintext store keeps the full read ceiling (safeJson.ABSOLUTE_MAX_BYTES).
134
+ var VAULTED_SEAL_SAFE_MAX_BYTES = Math.floor(safeJson.ABSOLUTE_MAX_BYTES * 3 / 4) - C.BYTES.kib(1);
127
135
  // cryptoField + vault lazy-required: dbTicketStore seals subject PII + the
128
136
  // full ticket payload at rest so a GDPR Art 17 erasure leaves no
129
137
  // decryptable copy. Lazy so the module loads in vault-less / test-tooling
@@ -1027,6 +1035,12 @@ function dbTicketStore(opts) {
1027
1035
  validateOpts.requireMethods(db, ["runSql", "prepare"],
1028
1036
  "dbTicketStore: opts.db (b.db-shaped handle)", DsrError, "dsr/bad-db");
1029
1037
  var tableRaw = opts.table || "dsr_tickets";
1038
+ // b.sql builder opts for the DML below — quoteName quotes the operator-
1039
+ // supplied table name exactly as db.from() does (reserved words, schema-
1040
+ // qualified "schema.table"), so the store composes b.sql instead of
1041
+ // hand-rolling identifier quoting. DDL (CREATE/ALTER) stays hand-rolled —
1042
+ // b.sql is a DML builder, not a schema tool.
1043
+ var SQL_OPTS = { dialect: "sqlite", quoteName: true };
1030
1044
  var qTable, qEmailIdx, qStatusIdx;
1031
1045
  try {
1032
1046
  qTable = safeSql.quoteIdentifier(tableRaw, "sqlite");
@@ -1109,32 +1123,57 @@ function dbTicketStore(opts) {
1109
1123
  // selected) and cheap (an empty scan) once migrated.
1110
1124
  if (vault().isInitialized()) {
1111
1125
  _ensureDsrSealTable();
1112
- var legacyRows = db.prepare(
1113
- "SELECT id, subject_id, subject_email, subject_phone, payload FROM " + qTable +
1114
- " WHERE (subject_email IS NOT NULL AND subject_email_hash IS NULL)" +
1115
- " OR (subject_id IS NOT NULL AND subject_id_hash IS NULL)").all({});
1126
+ // The grouped-OR predicate has no operator input; it is a fixed
1127
+ // structural condition, so it rides through b.sql's whereRaw escape
1128
+ // (allow:hand-rolled-sql a static, param-free legacy-detection filter
1129
+ // b.sql's structured where() cannot express as one OR-of-ANDs group).
1130
+ var legacySel = sql.select(tableRaw, SQL_OPTS)
1131
+ .columns(["id", "subject_id", "subject_email", "subject_phone", "payload"])
1132
+ .whereRaw("(subject_email IS NOT NULL AND subject_email_hash IS NULL) OR (subject_id IS NOT NULL AND subject_id_hash IS NULL)")
1133
+ .toSql();
1134
+ var legacyStmt = db.prepare(legacySel.sql);
1135
+ var legacyRows = legacyStmt.all.apply(legacyStmt, legacySel.params);
1116
1136
  for (var bi = 0; bi < (legacyRows || []).length; bi++) {
1117
1137
  var lrow = legacyRows[bi];
1118
1138
  var lEmailDerived = cryptoField().computeDerived(DSR_SEAL_TABLE, "subject_email", lrow.subject_email);
1119
1139
  var lIdDerived = cryptoField().computeDerived(DSR_SEAL_TABLE, "subject_id", lrow.subject_id);
1120
- var lSealed = cryptoField().sealRow(DSR_SEAL_TABLE, {
1140
+ // A legacy plaintext payload above the expansion-safe cap cannot be
1141
+ // sealed (its sealed form would exceed b.sql's per-value ceiling). The
1142
+ // row must still become findable + erasable, so ALWAYS seal the (small)
1143
+ // subject columns and populate the derived hashes — otherwise, in
1144
+ // vaulted mode, _subjectConds filters only on the hash columns and
1145
+ // list({ subject }) / the erasure purge would never see this prior
1146
+ // ticket, leaving its PII un-erasable. When the payload is over-cap,
1147
+ // keep it plaintext (still ≤ the read ceiling, so it binds; it is
1148
+ // DB-encrypted at rest and removed when the row is erased) and surface
1149
+ // it so the operator can chunk-migrate it.
1150
+ var payloadTooBig = Buffer.byteLength(String(lrow.payload == null ? "" : lrow.payload), "utf8") > VAULTED_SEAL_SAFE_MAX_BYTES;
1151
+ if (payloadTooBig) {
1152
+ try {
1153
+ observability().safeEvent("dsr.backfill.payload_left_plaintext", 1, { table: tableRaw });
1154
+ } catch (_e) { /* drop-silent */ }
1155
+ }
1156
+ // Omit the payload from the seal input when it is over-cap — sealRow
1157
+ // skips absent fields, so only the subject columns are sealed and the
1158
+ // plaintext payload is written back below.
1159
+ var lSealInput = {
1121
1160
  id: lrow.id,
1122
1161
  subject_id: lrow.subject_id,
1123
1162
  subject_email: lrow.subject_email,
1124
1163
  subject_phone: lrow.subject_phone,
1125
- payload: lrow.payload,
1126
- });
1127
- db.prepare("UPDATE " + qTable + " SET subject_id = $sid, subject_email = $email," +
1128
- " subject_phone = $phone, payload = $payload, subject_email_hash = $emailHash," +
1129
- " subject_id_hash = $idHash WHERE id = $id").run({
1130
- $id: lrow.id,
1131
- $sid: lSealed.subject_id,
1132
- $email: lSealed.subject_email,
1133
- $phone: lSealed.subject_phone,
1134
- $payload: lSealed.payload,
1135
- $emailHash: lEmailDerived ? lEmailDerived.value : null,
1136
- $idHash: lIdDerived ? lIdDerived.value : null,
1137
- });
1164
+ };
1165
+ if (!payloadTooBig) lSealInput.payload = lrow.payload;
1166
+ var lSealed = cryptoField().sealRow(DSR_SEAL_TABLE, lSealInput);
1167
+ var lUpd = sql.update(tableRaw, SQL_OPTS).set({
1168
+ subject_id: lSealed.subject_id,
1169
+ subject_email: lSealed.subject_email,
1170
+ subject_phone: lSealed.subject_phone,
1171
+ payload: payloadTooBig ? lrow.payload : lSealed.payload,
1172
+ subject_email_hash: lEmailDerived ? lEmailDerived.value : null,
1173
+ subject_id_hash: lIdDerived ? lIdDerived.value : null,
1174
+ }).where("id", "=", lrow.id).toSql();
1175
+ var lUpdStmt = db.prepare(lUpd.sql);
1176
+ lUpdStmt.run.apply(lUpdStmt, lUpd.params);
1138
1177
  }
1139
1178
  }
1140
1179
  }
@@ -1146,15 +1185,23 @@ function dbTicketStore(opts) {
1146
1185
  // it stores plaintext (matching the agent-* fallback).
1147
1186
  function _sealColumns(id, ticket) {
1148
1187
  // The payload column is read back through safeJson.parse, whose hard
1149
- // ceiling (safeJson.ABSOLUTE_MAX_BYTES) caps what any read can accept.
1150
- // Refuse a ticket whose serialized form exceeds that same ceiling on
1151
- // write so the store never holds a payload it cannot read back later
1152
- // (write cap == read cap, measured the same way: UTF-8 byte length).
1188
+ // ceiling (safeJson.ABSOLUTE_MAX_BYTES) caps what any read can accept, so
1189
+ // the plaintext must not exceed it on write either. When a vault is
1190
+ // configured the payload is AEAD-sealed and base64-encoded before it is
1191
+ // bound, which expands it ~4/3; the bound (sealed) cell must still fit the
1192
+ // query builder's per-value binding ceiling (b.sql's MAX_PARAM_BYTES, the
1193
+ // same 64 MiB). Cap the plaintext at an expansion-safe size when vaulted so
1194
+ // an oversized ticket is refused here with the store's own error rather
1195
+ // than a SqlBuilderError deep in the insert — and route large exports to
1196
+ // chunked storage rather than binding one giant sealed cell.
1153
1197
  var serializedPayload = JSON.stringify(ticket);
1154
- if (Buffer.byteLength(serializedPayload, "utf8") > safeJson.ABSOLUTE_MAX_BYTES) {
1198
+ var vaulted = vault().isInitialized();
1199
+ var maxPlaintextBytes = vaulted ? VAULTED_SEAL_SAFE_MAX_BYTES : safeJson.ABSOLUTE_MAX_BYTES;
1200
+ if (Buffer.byteLength(serializedPayload, "utf8") > maxPlaintextBytes) {
1155
1201
  throw new DsrError("dsr/ticket-too-large",
1156
- "_sealColumns: ticket " + id + " payload exceeds the " +
1157
- safeJson.ABSOLUTE_MAX_BYTES + "-byte store limit");
1202
+ "_sealColumns: ticket " + id + " payload exceeds the " + maxPlaintextBytes +
1203
+ "-byte store limit" +
1204
+ (vaulted ? " (sealing expands it; store large exports via chunked storage)" : ""));
1158
1205
  }
1159
1206
  var row = {
1160
1207
  id: id,
@@ -1205,7 +1252,8 @@ function dbTicketStore(opts) {
1205
1252
  { key: "email", plainCol: "subject_email", sealField: "subject_email", hashCol: "subject_email_hash", param: "$email" },
1206
1253
  { key: "subjectId", plainCol: "subject_id", sealField: "subject_id", hashCol: "subject_id_hash", param: "$sid" },
1207
1254
  ];
1208
- function _subjectConds(filter, conds, params) {
1255
+ // AND the subject-match predicate(s) onto a b.sql select builder.
1256
+ function _subjectConds(filter, qb) {
1209
1257
  if (!filter.subject) return;
1210
1258
  var vaulted = vault().isInitialized();
1211
1259
  if (vaulted) _ensureDsrSealTable();
@@ -1213,8 +1261,7 @@ function dbTicketStore(opts) {
1213
1261
  var supplied = filter.subject[spec.key];
1214
1262
  if (!supplied) return;
1215
1263
  if (!vaulted) {
1216
- conds.push(spec.plainCol + " = " + spec.param);
1217
- params[spec.param] = supplied;
1264
+ qb.where(spec.plainCol, "=", supplied);
1218
1265
  return;
1219
1266
  }
1220
1267
  // Vaulted: match BOTH the active keyed-MAC digest AND the legacy
@@ -1226,111 +1273,99 @@ function dbTicketStore(opts) {
1226
1273
  var cand = cryptoField().lookupHashCandidates(DSR_SEAL_TABLE, spec.sealField, supplied);
1227
1274
  var values = cand && cand.values ? cand.values : [];
1228
1275
  if (values.length === 0) return;
1229
- var placeholders = values.map(function (v, i) {
1230
- var p = spec.param + "_" + i;
1231
- params[p] = v;
1232
- return p;
1233
- });
1234
- conds.push(spec.hashCol + " IN (" + placeholders.join(", ") + ")");
1276
+ qb.whereIn(spec.hashCol, values);
1235
1277
  });
1236
1278
  }
1237
1279
 
1238
1280
  return {
1239
1281
  insert: async function (ticket) {
1240
1282
  var cols = _sealColumns(ticket.id, ticket);
1241
- var stmt = db.prepare("INSERT INTO " + qTable +
1242
- " (id, type, status, subject_id, subject_email, subject_phone, " +
1243
- " subject_email_hash, subject_id_hash, " +
1244
- " submitted_at, deadline_at, processed_at, verification_level, posture, payload) " +
1245
- " VALUES ($id, $type, $status, $sid, $email, $phone, " +
1246
- " $emailHash, $idHash, $submittedAt, " +
1247
- " $deadlineAt, $processedAt, $verLevel, $posture, $payload)");
1248
- stmt.run({
1249
- $id: ticket.id,
1250
- $type: ticket.type,
1251
- $status: ticket.status,
1252
- $sid: cols.$sid,
1253
- $email: cols.$email,
1254
- $phone: cols.$phone,
1255
- $emailHash: cols.$emailHash,
1256
- $idHash: cols.$idHash,
1257
- $submittedAt: ticket.submittedAt,
1258
- $deadlineAt: ticket.deadlineAt,
1259
- $processedAt: ticket.processedAt || null,
1260
- $verLevel: ticket.verificationLevel || null,
1261
- $posture: ticket.posture || null,
1262
- $payload: cols.$payload,
1263
- });
1283
+ var built = sql.insert(tableRaw, SQL_OPTS).values({
1284
+ id: ticket.id,
1285
+ type: ticket.type,
1286
+ status: ticket.status,
1287
+ subject_id: cols.$sid,
1288
+ subject_email: cols.$email,
1289
+ subject_phone: cols.$phone,
1290
+ subject_email_hash: cols.$emailHash,
1291
+ subject_id_hash: cols.$idHash,
1292
+ submitted_at: ticket.submittedAt,
1293
+ deadline_at: ticket.deadlineAt,
1294
+ processed_at: ticket.processedAt || null,
1295
+ verification_level: ticket.verificationLevel || null,
1296
+ posture: ticket.posture || null,
1297
+ payload: cols.$payload,
1298
+ }).toSql();
1299
+ var stmt = db.prepare(built.sql);
1300
+ stmt.run.apply(stmt, built.params);
1264
1301
  },
1265
1302
  get: async function (id) {
1266
- var rows = db.prepare("SELECT id, payload FROM " + qTable + " WHERE id = $id")
1267
- .all({ $id: id });
1303
+ var built = sql.select(tableRaw, SQL_OPTS).columns(["id", "payload"]).where("id", "=", id).toSql();
1304
+ var stmt = db.prepare(built.sql);
1305
+ var rows = stmt.all.apply(stmt, built.params);
1268
1306
  if (!rows || rows.length === 0) return null;
1269
1307
  return safeJson.parse(_unsealPayload(rows[0].payload, rows[0].id), { maxBytes: safeJson.ABSOLUTE_MAX_BYTES });
1270
1308
  },
1271
1309
  list: async function (filter) {
1272
1310
  filter = filter || {};
1273
- var sql = "SELECT id, payload FROM " + qTable;
1274
- var conds = [];
1275
- var params = {};
1276
- if (filter.status) {
1277
- conds.push("status = $status");
1278
- params.$status = filter.status;
1279
- }
1280
- _subjectConds(filter, conds, params);
1281
- if (conds.length > 0) sql += " WHERE " + conds.join(" AND ");
1282
- sql += " ORDER BY submitted_at DESC";
1283
- var rows = db.prepare(sql).all(params);
1311
+ var qb = sql.select(tableRaw, SQL_OPTS).columns(["id", "payload"]);
1312
+ if (filter.status) qb.where("status", "=", filter.status);
1313
+ _subjectConds(filter, qb);
1314
+ qb.orderBy("submitted_at", "DESC");
1315
+ var built = qb.toSql();
1316
+ var stmt = db.prepare(built.sql);
1317
+ var rows = stmt.all.apply(stmt, built.params);
1284
1318
  return rows.map(function (r) { return safeJson.parse(_unsealPayload(r.payload, r.id), { maxBytes: safeJson.ABSOLUTE_MAX_BYTES }); });
1285
1319
  },
1286
1320
  update: async function (id, ticket) {
1287
1321
  var cols = _sealColumns(id, ticket);
1288
- var stmt = db.prepare("UPDATE " + qTable + " SET " +
1289
- " type = $type, status = $status, subject_id = $sid, " +
1290
- " subject_email = $email, subject_phone = $phone, " +
1291
- " subject_email_hash = $emailHash, subject_id_hash = $idHash, " +
1292
- " submitted_at = $submittedAt, deadline_at = $deadlineAt, " +
1293
- " processed_at = $processedAt, verification_level = $verLevel, " +
1294
- " posture = $posture, payload = $payload " +
1295
- " WHERE id = $id");
1296
- var info = stmt.run({
1297
- $id: id,
1298
- $type: ticket.type,
1299
- $status: ticket.status,
1300
- $sid: cols.$sid,
1301
- $email: cols.$email,
1302
- $phone: cols.$phone,
1303
- $emailHash: cols.$emailHash,
1304
- $idHash: cols.$idHash,
1305
- $submittedAt: ticket.submittedAt,
1306
- $deadlineAt: ticket.deadlineAt,
1307
- $processedAt: ticket.processedAt || null,
1308
- $verLevel: ticket.verificationLevel || null,
1309
- $posture: ticket.posture || null,
1310
- $payload: cols.$payload,
1311
- });
1322
+ var built = sql.update(tableRaw, SQL_OPTS).set({
1323
+ type: ticket.type,
1324
+ status: ticket.status,
1325
+ subject_id: cols.$sid,
1326
+ subject_email: cols.$email,
1327
+ subject_phone: cols.$phone,
1328
+ subject_email_hash: cols.$emailHash,
1329
+ subject_id_hash: cols.$idHash,
1330
+ submitted_at: ticket.submittedAt,
1331
+ deadline_at: ticket.deadlineAt,
1332
+ processed_at: ticket.processedAt || null,
1333
+ verification_level: ticket.verificationLevel || null,
1334
+ posture: ticket.posture || null,
1335
+ payload: cols.$payload,
1336
+ }).where("id", "=", id).toSql();
1337
+ var stmt = db.prepare(built.sql);
1338
+ var info = stmt.run.apply(stmt, built.params);
1312
1339
  if (info && info.changes === 0) {
1313
1340
  throw new DsrError("dsr/ticket-not-found",
1314
1341
  "dbTicketStore: ticket " + id + " not found for update");
1315
1342
  }
1316
1343
  },
1317
1344
  delete: async function (id) {
1318
- var info = db.prepare("DELETE FROM " + qTable + " WHERE id = $id").run({ $id: id });
1345
+ var built = sql.delete(tableRaw, SQL_OPTS).where("id", "=", id).toSql();
1346
+ var stmt = db.prepare(built.sql);
1347
+ var info = stmt.run.apply(stmt, built.params);
1319
1348
  return !!(info && info.changes > 0);
1320
1349
  },
1321
1350
  purgeExpired: async function (asOfMs) {
1322
1351
  // Bulk-delete tickets in terminal states whose retentionUntil
1323
1352
  // is in the past. Returns the number of rows removed.
1324
1353
  var asOf = (typeof asOfMs === "number" && isFinite(asOfMs)) ? asOfMs : Date.now();
1325
- var rows = db.prepare("SELECT id, payload FROM " + qTable +
1326
- " WHERE status IN ('completed','partially_completed','cancelled','rejected','expired')").all({});
1354
+ var selBuilt = sql.select(tableRaw, SQL_OPTS).columns(["id", "payload"])
1355
+ .whereIn("status", ["completed", "partially_completed", "cancelled", "rejected", "expired"])
1356
+ .toSql();
1357
+ var selStmt = db.prepare(selBuilt.sql);
1358
+ var rows = selStmt.all.apply(selStmt, selBuilt.params);
1327
1359
  var purged = 0;
1328
- var del = db.prepare("DELETE FROM " + qTable + " WHERE id = $id");
1329
1360
  for (var i = 0; i < rows.length; i++) {
1330
1361
  try {
1331
1362
  var t = safeJson.parse(_unsealPayload(rows[i].payload, rows[i].id), { maxBytes: safeJson.ABSOLUTE_MAX_BYTES });
1332
1363
  if (t.retentionUntil && t.retentionUntil < asOf) {
1333
- del.run({ $id: rows[i].id });
1364
+ // The delete SQL string is identical every iteration, so db.prepare
1365
+ // returns the same cached statement — composing per-row is free.
1366
+ var dBuilt = sql.delete(tableRaw, SQL_OPTS).where("id", "=", rows[i].id).toSql();
1367
+ var dStmt = db.prepare(dBuilt.sql);
1368
+ dStmt.run.apply(dStmt, dBuilt.params);
1334
1369
  purged += 1;
1335
1370
  }
1336
1371
  } catch (_e) { /* malformed payload — leave it */ }
@@ -1358,14 +1358,21 @@ function _requestWithRedirects(opts, hopsLeft) {
1358
1358
  headersStripped: headersStripped,
1359
1359
  method: nextMethod,
1360
1360
  });
1361
+ function _redirectAborted(e) {
1362
+ return Promise.reject(_makeError(opts.errorClass, "REDIRECT_ABORTED",
1363
+ "onRedirect hook refused redirect: " + ((e && e.message) || String(e)), true));
1364
+ }
1361
1365
  try {
1362
1366
  var hookResult = onRedirect(hookEvent);
1363
1367
  if (hookResult && typeof hookResult.then === "function") {
1364
- return hookResult.then(function () { return _continueFollow(); });
1368
+ // An async hook's rejection must abort the follow with the SAME
1369
+ // REDIRECT_ABORTED shape a sync throw produces — otherwise an
1370
+ // operator who awaits inside the hook (or returns a rejected
1371
+ // Promise) silently gets an un-coded raw rejection instead.
1372
+ return hookResult.then(function () { return _continueFollow(); }, _redirectAborted);
1365
1373
  }
1366
1374
  } catch (e) {
1367
- return Promise.reject(_makeError(opts.errorClass, "REDIRECT_ABORTED",
1368
- "onRedirect hook refused redirect: " + ((e && e.message) || String(e)), true));
1375
+ return _redirectAborted(e);
1369
1376
  }
1370
1377
  }
1371
1378
  return _continueFollow();
package/lib/mail-auth.js CHANGED
@@ -197,7 +197,7 @@ async function _safeResolveMx(qname, operatorLookup) {
197
197
  return entries.map(function (e) { return e.exchange; });
198
198
  }
199
199
 
200
- async function _safeReverse(ip) {
200
+ async function _safeReverse(ip, operatorLookup) {
201
201
  // PTR query against the reverse-arpa name. IPv4: a.b.c.d.in-addr.arpa
202
202
  // (reversed octets); IPv6: nibble-reversed under ip6.arpa.
203
203
  var qname = _ipToReverseArpa(ip);
@@ -206,6 +206,30 @@ async function _safeReverse(ip) {
206
206
  err.code = "ENOTFOUND";
207
207
  throw err;
208
208
  }
209
+ // Operator-supplied resolver: the callback receives the reverse-arpa
210
+ // qname (the same name a real resolver queries) and returns the
211
+ // documented flat PTR-name array. Threading it here makes the iprev
212
+ // forward-confirm path operator-mockable, matching the dnsLookup
213
+ // contract every other type in this file already honors.
214
+ if (operatorLookup) {
215
+ var resp = await operatorLookup(qname, "PTR");
216
+ if (!Array.isArray(resp) || resp.length === 0) {
217
+ var perr = new Error("no PTR records for " + ip);
218
+ perr.code = "ENODATA";
219
+ throw perr;
220
+ }
221
+ var names = [];
222
+ for (var pi = 0; pi < resp.length; pi += 1) {
223
+ var nm = String(resp[pi]).replace(/\.$/, "");
224
+ if (nm.length > 0) names.push(nm);
225
+ }
226
+ if (names.length === 0) {
227
+ var perr2 = new Error("no PTR records for " + ip);
228
+ perr2.code = "ENODATA";
229
+ throw perr2;
230
+ }
231
+ return names;
232
+ }
209
233
  var r = await _getDefaultResolver().query(qname, "PTR");
210
234
  var out = [];
211
235
  for (var i = 0; i < r.rrs.length; i += 1) {
@@ -2864,13 +2888,22 @@ function dmarcBuildAggregateReport(report, opts) {
2864
2888
  // Authentication-Results header so downstream policies can react.
2865
2889
  //
2866
2890
  // Surface:
2867
- // await b.mail.iprev.verify(ip)
2891
+ // await b.mail.iprev.verify(ip, opts?)
2868
2892
  // → { result: "pass"|"fail"|"permerror"|"temperror",
2869
2893
  // ptr, forward, fcrdns, ip }
2870
2894
  //
2871
- // Returns "permerror" on bad-shape input (not an IP literal); returns
2872
- // "temperror" on ENODATA / ENOTFOUND / lookup failure (the receiver
2873
- // retries on transient DNS faults). Pure-DNS no operator state.
2895
+ // Returns "permerror" on bad-shape input (not an IP literal, or a PTR
2896
+ // whose rdata isn't a valid DNS name). A definitive negative answer
2897
+ // (no PTR record, or the PTR forward-resolves to a set that omits the
2898
+ // connecting IP) is a "fail"; a transient resolver fault (SERVFAIL,
2899
+ // timeout, or any non-negative error code) is a "temperror" the
2900
+ // receiver retries on. Every path RETURNS a verdict object — the
2901
+ // primitive never throws on a message- or DNS-derived fault.
2902
+ //
2903
+ // DNS defaults to b.network.dns.resolver; an operator `opts.dnsLookup`
2904
+ // callback overrides it (same shape as the rest of this file — the PTR
2905
+ // query receives the reverse-arpa qname; the forward query the PTR
2906
+ // name), so a receiver can unit-test the forward-confirm decision.
2874
2907
 
2875
2908
  // RFC 8601 §3 — PTR result shape. The PTR rdata is an FQDN (1*labels).
2876
2909
  // Reject answers that aren't shaped as a DNS name: non-strings,
@@ -2897,7 +2930,10 @@ function _isValidPtrName(name) {
2897
2930
  return true;
2898
2931
  }
2899
2932
 
2900
- async function iprevVerify(ip) {
2933
+ async function iprevVerify(ip, opts) {
2934
+ opts = opts || {};
2935
+ validateOpts(opts, ["dnsLookup"], "mail.iprev.verify");
2936
+ var dnsLookup = opts.dnsLookup;
2901
2937
  if (typeof ip !== "string" || ip.length === 0) {
2902
2938
  return { result: "permerror", ip: ip || null,
2903
2939
  ptr: null, forward: [], fcrdns: false,
@@ -2910,7 +2946,7 @@ async function iprevVerify(ip) {
2910
2946
  }
2911
2947
 
2912
2948
  var ptrs;
2913
- try { ptrs = await _safeReverse(ip); }
2949
+ try { ptrs = await _safeReverse(ip, dnsLookup); }
2914
2950
  catch (e) {
2915
2951
  var rcode = e && e.code;
2916
2952
  if (rcode === "ENOTFOUND" || rcode === "ENODATA") {
@@ -2942,7 +2978,7 @@ async function iprevVerify(ip) {
2942
2978
  var isV6 = net.isIPv6(ip);
2943
2979
  var forwardAddrs;
2944
2980
  try {
2945
- forwardAddrs = await _safeResolveA(ptr, isV6 ? 6 : 4);
2981
+ forwardAddrs = await _safeResolveA(ptr, isV6 ? 6 : 4, dnsLookup);
2946
2982
  } catch (e) {
2947
2983
  var fcode = e && e.code;
2948
2984
  if (fcode === "ENOTFOUND" || fcode === "ENODATA") {
@@ -2950,23 +2986,32 @@ async function iprevVerify(ip) {
2950
2986
  ptr: ptr, forward: [], fcrdns: false,
2951
2987
  explanation: "no forward record for PTR " + ptr };
2952
2988
  }
2953
- if (fcode === "ETIMEOUT" || fcode === "ESERVFAIL") {
2954
- return { result: "temperror", ip: ip,
2955
- ptr: ptr, forward: [], fcrdns: false,
2956
- explanation: "forward lookup transient failure: " + fcode };
2957
- }
2958
- // Anything else propagate as temperror; Node DNS surfaces some
2959
- // non-RFC error codes via the platform resolver. Permerror only
2960
- // for definitive negative answers above.
2961
- throw new MailAuthError("mail-auth/iprev-temperror",
2962
- "iprev.verify: forward lookup of " + ptr + " threw: " +
2963
- ((e && e.message) || String(e)));
2989
+ // Every non-negative fault (SERVFAIL, timeout, or any other
2990
+ // platform-resolver code) is transient — RETURN a temperror verdict,
2991
+ // mirroring the reverse-lookup catch above. Throwing here broke the
2992
+ // primitive's contract that every message/DNS-derived fault surfaces
2993
+ // as a verdict object: a caller of the documented API got an
2994
+ // exception on e.g. an EREFUSED forward fault. Only the definitive
2995
+ // negative answers above (no forward record) are a "fail".
2996
+ return { result: "temperror", ip: ip,
2997
+ ptr: ptr, forward: [], fcrdns: false,
2998
+ explanation: "forward lookup of " + ptr + " transient failure: " +
2999
+ (fcode || (e && e.message) || String(e)) };
2964
3000
  }
2965
3001
  var forward = Array.isArray(forwardAddrs) ? forwardAddrs.slice() : [];
2966
- var ipLc = ip.toLowerCase();
3002
+ // RFC 8601 §3 forward-confirm match. An IPv6 address has many equivalent
3003
+ // textual forms (compressed `2001:db8::1` vs expanded
3004
+ // `2001:0db8:0:0:0:0:0:1`); the connecting literal and the AAAA rdata
3005
+ // routinely differ. Compare the CANONICAL form (fixed-width hex nibbles)
3006
+ // so an equivalent-but-differently-written IPv6 address still confirms.
3007
+ // IPv4 has a single canonical dotted form, so a lowercased string compare
3008
+ // is exact there.
3009
+ var ipCanon = isV6 ? ipUtils.expandIpv6Hex(ip) : ip.toLowerCase();
2967
3010
  var fcrdns = false;
2968
3011
  for (var i = 0; i < forward.length; i += 1) {
2969
- if (String(forward[i]).toLowerCase() === ipLc) { fcrdns = true; break; }
3012
+ var fwdStr = String(forward[i]);
3013
+ var fwdCanon = isV6 ? ipUtils.expandIpv6Hex(fwdStr) : fwdStr.toLowerCase();
3014
+ if (ipCanon && fwdCanon && fwdCanon === ipCanon) { fcrdns = true; break; }
2970
3015
  }
2971
3016
  return {
2972
3017
  result: fcrdns ? "pass" : "fail",
@@ -132,7 +132,15 @@ function _coerceValue(raw) {
132
132
  return n;
133
133
  }
134
134
  if (/^-?\d+\.\d+([eE][+-]?\d+)?$/.test(raw) || /^-?\d+[eE][+-]?\d+$/.test(raw)) {
135
- return Number(raw);
135
+ var f = Number(raw);
136
+ // Float overflow (e.g. 1e999) coerces to ±Infinity — the same
137
+ // never-silently-coerce refusal the integer/hex branches enforce with
138
+ // Number.isSafeInteger. An Infinity slipping into a downstream size cap
139
+ // or timeout is a live DoS, so an out-of-range float is rejected here.
140
+ if (!Number.isFinite(f)) {
141
+ throw _err("ini/value-out-of-range", "float exceeds representable range: " + raw);
142
+ }
143
+ return f;
136
144
  }
137
145
  return _unquote(raw);
138
146
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.16.15",
3
+ "version": "0.16.17",
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:1618a7b7-f477-422c-99bb-f941a8827acf",
5
+ "serialNumber": "urn:uuid:3e3c1217-5f8b-4f16-b9f5-bea246e83437",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-12T01:36:29.373Z",
8
+ "timestamp": "2026-07-12T06:26:29.484Z",
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.16.15",
22
+ "bom-ref": "@blamejs/core@0.16.17",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.16.15",
25
+ "version": "0.16.17",
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.16.15",
29
+ "purl": "pkg:npm/%40blamejs/core@0.16.17",
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.16.15",
57
+ "ref": "@blamejs/core@0.16.17",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]