@blamejs/core 0.15.8 → 0.15.10

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.15.x
10
10
 
11
+ - v0.15.10 (2026-06-13) — **Makes S3 Object-Lock version erasure reachable through the object store, and pins the build toolchain's native binary to a reviewed hash.** The object store gains the versioned-delete surface its S3 Object Lock support always needed for real erasure. An unversioned delete on a versioning-enabled (Object-Lock) bucket only writes a delete-marker — the data version survives — so the framework's own delete could report success while a record protected for compliance, or one a data subject asked to erase, stayed on disk. b.objectStore / b.storage now carry a versionId: put and saveRaw return the version they created, deleteFile(key, { versionId, bypassGovernanceRetention }) targets a specific version (refused — not silently delete-markered — when it is under an active retention), and listVersions enumerates versions and delete-markers so an erasure workflow can find them. Backends with no version surface (the filesystem backend, and the current Azure and GCS adapters) refuse a versioned delete loudly rather than silently dropping the current object. Separately, the build toolchain's native bundler binary is now verified against a reviewed SHA-256 pin so a tampered or drifted binary is caught before it bundles the framework. **Added:** *Versioned object delete + listVersions for S3 Object-Lock erasure* — b.storage.deleteFile and the b.objectStore sigv4 backend now accept opts.versionId to erase a specific object version, and opts.bypassGovernanceRetention to lift a GOVERNANCE-mode retention for callers with the permission (COMPLIANCE stays immutable to everyone). b.storage.saveRaw and the backend put now return the versionId they created on a versioning-enabled bucket, and a new b.storage.listVersions(prefix) / backend listVersions enumerates every version and delete-marker (key, versionId, isLatest, deleteMarker, size, lastModified, etag) so a right-to-erasure or crypto-shred workflow can target prior versions. On a backend with no version surface, listVersions throws VERSIONS_UNSUPPORTED and a versioned delete throws VERSIONID_UNSUPPORTED rather than silently acting on the current object. · *b.localDb.thin reaches SQLite resource-limit parity (limits option)* — b.localDb.thin now opens its node:sqlite handle with the same parse-time statement-size cap as b.db and the CLI — a SQL statement over 1 MiB is rejected at parse time, the SQLITE_LIMIT_LENGTH floor that guards prepare()/exec() of raw SQL against an attacker-influenced megaquery (SQLite's default is 1 GB). The cap is on by default; a new limits option (e.g. { sqlLength: 2 * 1024 * 1024 } or other SQLITE_LIMIT_* keys) lets an operator raise or extend it. Previously the thin opener had no limits plumbing, so a consumer on that path could not reach parity with the rest of the framework's SQLite surface. **Fixed:** *S3 Object-Lock version erasure is reachable through the framework delete path* — On a versioning-enabled (Object-Lock) bucket, an unversioned DELETE only writes a delete-marker — the protected data version survives untouched — yet the framework's delete had no versionId surface, so it issued the unversioned form and reported success while the bytes the lock protects remained. A retention or legal hold could therefore look enforced to the framework caller while the operation WORM actually blocks was unreachable. The delete path now targets the exact version: deleting a version under a COMPLIANCE retention is refused (it throws, even with bypassGovernanceRetention), a no-retention version erases cleanly, and the enforcement is proven end-to-end against MinIO through the framework's own API. · *b.configDrift.verifyVendorIntegrity is now working-directory-independent* — The vendored-dependency integrity check resolved each manifest file path against process.cwd(), so it only worked when run from the application root. Run from anywhere else it read-failed every entry (reporting ok:false), and under a crafted working directory that happened to contain a clean vendor tree it could hash a different tree than the one actually loaded. It now resolves each file under the framework's own vendor directory by default — the tree loaded at runtime — and honors an explicit libVendorDir for verifying a deployed tree elsewhere, so the result no longer depends on where the process was started. **Security:** *Build toolchain native binary pinned to a reviewed hash* — The native bundler binary the build toolchain runs (esbuild's per-platform compiler, a development dependency that never ships in the runtime) is now verified against a SHA-256 pin captured by diffing the published package tarballs and hashing the binary. The build gate fails if the on-disk binary does not match the reviewed hash for its (version, platform); for a version that has not been reviewed it notes the gap and skips rather than trusting an unverified binary. A cross-artifact check keeps the version in agreement across package.json, the CI install step, and the hash map, so the gate can never quietly test a version that was never diffed — closing a real drift where CI had been installing an older patch than package.json declared. The reviewed diff is benign: version strings plus an installer size-bound and error-message hardening, no new install hooks, files, or network paths, and no runtime-dependency impact. **Detectors:** *Object-store erasure guard, esbuild-pin agreement, + structural re-anchoring of the lint detectors* — A new guard locks the object-store delete path to the versioned-erasure contract: b.storage.deleteFile must thread versionId to the backend, so it can never silently revert to the WORM-blind unversioned delete. A second guard enforces that the esbuild build-tool version agrees across package.json, the CI install step, and the binary-hash map, so a future bump can't update one and leave the gate testing an unreviewed version. Separately, the framework's internal codebase-pattern lint detectors were re-anchored from fixed character spans to structural code boundaries, so they keep matching the code they guard as those functions grow rather than aging out of range; reviving them surfaced a few internal validation and transaction sites that now route through shared helpers (a required positive-integer-with-range validator and an async transaction wrapper) instead of hand-rolling the check. No public API change.
12
+
13
+ - v0.15.9 (2026-06-13) — **Raises the Node floor to 24.16, adds SQLite parse-time resource caps, retries Windows rename locks on every atomic write and download, and ships a one-call secure logout that wipes client-side state.** This release moves the engines floor to the current Node 24 LTS patch level and adds three hardening primitives. node:sqlite handles now construct with SQLITE_LIMIT_* caps: a statement over 1 MiB is rejected at parse time (a DoS floor on the raw-SQL surface, complementary to the existing row-count gate) and ATTACH DATABASE is denied. Every final temp-to-destination rename — the file written by an atomic write, a downloaded file, a sealed vault key, a rotated log, an extracted archive entry — now routes through a single retry that rides out a transient Windows lock (antivirus, the search indexer, or a file-sync client briefly holding the destination), instead of surfacing the lock as a hard failure; the retry, previously hand-rolled and unreachable, is now the reusable b.atomicFile.renameWithRetry. And b.session.logout destroys a session and tells the browser to wipe its client-side state in one call: it emits an RFC 9527 Clear-Site-Data header and expires the session cookie before destroying the row, the secure-default logout that previously had to be assembled by hand. **Added:** *b.session.logout — one-call secure logout* — `b.session.logout(res, token, opts?)` destroys the server-side session AND tells the browser to wipe its client-side state in one call: it emits an RFC 9527 Clear-Site-Data response header (cookies + storage + cache + execution contexts by default) and expires the session cookie, then destroys the session row. `b.session.destroy` alone is a store operation with no response object, so it could not wipe the browser's cached pages, storage, or a stale tab still holding the now-revoked cookie — that wiring previously had to be mounted by hand. Pass `cookieName` to match a non-default cookie and `types` to choose the Clear-Site-Data directives. **Changed:** *Node engines floor raised to >=24.16.0* — The minimum supported Node is now 24.16.0 (the current Node 24 LTS patch level), up from 24.14.1. This is an LTS-currency bump — there are no Node CVE fixes between 24.14.1 and 24.16.0 (24.14.1 already carried the CVE-2026-21713 HMAC fix); it keeps the framework on the latest patched LTS and makes the node:sqlite resource-cap hardening below available everywhere. Pre-1.0, operators upgrade across the floor; Node 26 continues to satisfy it. **Fixed:** *b.watcher canonicalizes its root on Windows* — `b.watcher.create` now resolves its `root` to the real long path before watching. On Windows a root with an 8.3 short-name component (the system temp directory commonly resolves to one) made the native recursive backend deliver long-name event paths that no longer prefix-matched the watched root, which could abort the process under a strict libuv fs-event assertion. The watcher now canonicalizes the root (expanding short names and resolving symlinks), so events match the watched directory on Windows. **Security:** *SQLite parse-time statement-size cap* — Every node:sqlite database the framework opens — the main db handle and the CLI's handle — now constructs with a SQLITE_LIMIT_LENGTH cap: a SQL statement over 1 MiB is rejected at parse time. Because the query builder parameterizes every value, the size cap guards the raw-SQL surface (`b.db.runSql`) against an attacker-influenced megaquery the parser would otherwise process (SQLite's default is 1 GB); it is a parse-time DoS floor complementary to the existing row-count gate. Legitimate framework and operator statements are far under the cap. · *Windows rename-lock retry on every atomic rename and download* — On Windows a freshly-written file's destination is briefly held by antivirus, the search indexer, or a file-sync client (Dropbox, OneDrive), surfacing as a transient EPERM / EACCES / EBUSY on rename even though the temp file is fine. `b.atomicFile.writeSync` already retried this, but `b.httpClient.downloadStream` did not — a download into a cloud-synced or AV-scanned directory could fail hard on the lock. The retry is now the reusable `b.atomicFile.renameWithRetry`, and every final temp-to-destination rename in the framework routes through it: downloads, sealed vault keys, CA key/cert writes, log rotation, archive extraction, config-drift sidecars, the self-update binary swap, and restore/rollback moves. A non-transient error still throws immediately; POSIX renames are unaffected. **Detectors:** *Rename-retry, SQLite-limits, and Clear-Site-Data guards* — Three recurrence detectors ship with the fixes: a bare `nodeFs.renameSync` final rename that doesn't route through `atomicFile.renameWithRetry`; a main `DatabaseSync` handle constructed without the SQLITE_LIMIT_LENGTH `limits`; and a hand-rolled Clear-Site-Data header value that skips the shared RFC 9527 builder.
14
+
11
15
  - v0.15.8 (2026-06-13) — **Redacts OTLP log-sink attributes to close a secret/PII egress the span fix missed, adds EU DSA and China PIPL cross-border compliance record-builders, ships an SSDF producer self-attestation with every release, and makes the published tarball reproducible.** This release closes a telemetry egress hole, adds two compliance record-builder namespaces, and hardens the release supply chain. The OTLP log sinks (HTTP-JSON and gRPC) shipped a log record's meta attributes and the resource attributes to the collector unredacted — a log line carrying a bearer token, password, or API key reached the wire verbatim (CWE-532). The 0.15.4 fix wired the telemetry redactor into the span and metric exporters but the log sinks were missed; both now run record and resource attributes through the same redactor before serialization. New b.dsa builds the EU Digital Services Act (Reg 2022/2065) records an intermediary or platform must keep — Art. 16 notice-and-action, Art. 17 statement of reasons, and the Art. 15 / 24(3) transparency report. New b.pipl builds the China PIPL cross-border transfer records — an Art. 38/40 assessment that determines whether a CAC security assessment is mandatory (CIIO, important data, or the volume / sensitive-PI thresholds), and an Art. 40 security-assessment certificate. On the supply-chain side, every release now ships ssdf-attestation.json, a machine-readable NIST SP 800-218 / OMB M-22-18 producer self-attestation mapping each secure-development practice to its implementing control, and the published tarball is now packed with SOURCE_DATE_EPOCH so an operator can rebuild it byte-for-byte from the release commit. **Added:** *b.dsa — EU Digital Services Act compliance record-builders* — `b.dsa` builds the dated, frozen records the EU Digital Services Act (Regulation (EU) 2022/2065) requires an online intermediary or platform to keep: `b.dsa.noticeAndAction` (Art. 16) records a notice against a piece of content and computes the action-due window; `b.dsa.statementOfReasons` (Art. 17) records a moderation decision with its legal or contractual ground (exactly one is required), the facts, whether it was automated, and the redress routes offered; `b.dsa.transparencyReport` (Art. 15 / 24(3)) aggregates the period counts into a report with the next-due date. The builders perform no network I/O and emit a best-effort audit event; they map to the `dsa` compliance posture. · *b.pipl — China PIPL cross-border transfer record-builders* — `b.pipl.sccFilingAssessment` builds a PIPL Art. 38/40/55 cross-border transfer assessment and determines the lawful mechanism: it forces a CAC security assessment (over a self-selected standard contract or certification) when the exporter is a critical-information-infrastructure operator, exports important data, handles personal information of more than 1,000,000 individuals, or crosses the cumulative volume / sensitive-PI thresholds. `b.pipl.securityAssessmentCertificate` records an Art. 40 security-assessment self-declaration with a 3-year validity clock. Both return frozen dated records and map to the `pipl-cn` posture. · *SSDF producer self-attestation shipped with every release* — Every release now attaches `ssdf-attestation.json` — a machine-readable NIST SP 800-218 (SSDF v1.1) / OMB M-22-18 producer self-attestation. It maps each secure-software-development practice to its implementing control in the tree (SLSA L3 provenance, SSH-signed tags, vendored zero-runtime-dep supply chain, OSV-Scanner gating, coordinated disclosure) and is deterministic from the release commit. Its sha256 is a subject of the SLSA L3 provenance, so verifying the provenance verifies the attestation has not been tampered with. Downstream consumers who require SSDF supplier-compliance evidence can download it from the release page. **Security:** *OTLP log sinks redact record and resource attributes before export* — `b.logStream`'s OTLP log sinks (HTTP-JSON and gRPC) shipped a log record's `meta` attributes and the sink's resource attributes to the collector UNREDACTED, so a log line whose meta carried a bearer token, password, or API key — or a credential placed in a resource attribute — reached the OTLP wire verbatim (CWE-532). The 0.15.4 change baked the telemetry redactor into the span and metric exporters but its detector was anchored on the span/metric encoder function names, leaving the log sinks uncovered. Both log sinks now run record and resource attributes through `b.observability.redactAttrs` before serialization, the same egress contract the span and metric exporters already hold. · *Reproducible published tarball (SOURCE_DATE_EPOCH)* — The release workflow now exports `SOURCE_DATE_EPOCH` (derived from the tagged commit's author date) before `npm pack`, so the mtime stamped into every tar header is deterministic. An operator can re-pack the package from the same commit and match the published tarball's sha256 byte-for-byte, strengthening the source-to-artifact verification path alongside the existing SLSA L3 provenance and PQC signatures. **Detectors:** *otlp-log-sink-encodes-attrs-without-redactor* — Fires when an OTLP log-sink encoder hands a raw `record.meta` or resource-attribute map to serialization without routing it through `observability.redactAttrs` — the class the span/metric detector could not see because the log sinks carry the OTLP-logs schema function names.
12
16
 
13
17
  - v0.15.7 (2026-06-13) — **Hardens the new URL canonicalizer against an IPv4-mapped allowlist bypass, enforces the OIDC azp authorized-party check, and closes a set of audited correctness gaps in retention, credential rehashing, the scheduler, and SD-JWT key binding.** This release deepens the URL/host canonicalizer shipped in 0.15.6 and clears a batch of audited correctness gaps. The canonicalizer now folds an IPv4-mapped IPv6 address (::ffff:1.2.3.4) to its embedded IPv4 and strips every trailing dot from a host, so a dual-stack peer can no longer slip past an operator's dotted-IPv4 allowlist and host./host.. no longer evade a host comparison. (NAT64 and 6to4 hosts are deliberately kept as IPv6 so canonicalizing then classifying agrees with the SSRF classifier, which treats them as reserved.) On the OIDC side, verifyIdToken now enforces OIDC Core 3.1.3.7: a multi-audience ID token must carry an azp (authorized party) and a present azp must equal the client_id, closing a confused-deputy hole where a token minted for a different client but listing this RP in its audience array verified clean. The rest are audited fixes: retention.complianceFloor now honors the active posture set via applyPosture (the documented inheritance was unimplemented); credentialHash.needsRehash now drives the advertised SHAKE256 length-rotation (raising the output length now triggers a rehash, upgrade-only); the task scheduler no longer lets a run abandoned by its watchdog clobber the next run's state or emit a stale success when its slow promise settles late; and the SD-JWT key-binding JWT compares its audience and nonce in constant time. **Fixed:** *retention.complianceFloor honors the active compliance posture* — `b.retention.complianceFloor` required an explicit posture and never read the active posture set by `applyPosture` (the `b.compliance.set` cascade), so the documented inheritance was unimplemented dead state. It now inherits the active posture when none is passed — `complianceFloor(candidateTtlMs)` uses the active posture, an explicit posture still overrides it — and `applyPosture(null)` now clears the active posture (it was a silent no-op, so `b.compliance.clear` could not reset it). · *credentialHash.needsRehash drives the SHAKE256 length-rotation* — `b.credentialHash.needsRehash` never compared the stored SHAKE256 digest length against the configured length, so raising the output length never triggered a rehash and the advertised length-rotation was a silent no-op. It now flags a digest shorter than the configured/default length for rehash (upgrade-only — a longer-than-target digest is never shortened, matching the Argon2 convention). · *The scheduler no longer lets a watchdog-abandoned run corrupt the next run* — `b.scheduler`'s watchdog force-clears a task's running flag after `maxJobMs` so a hung handler can't lock out future fires, and the next tick re-fires. The original slow promise then settled late and unconditionally overwrote the task's running / lastFinish / lastError state and emitted a `system.scheduler.task.success`|`failure` for a run the watchdog had already given up on — clobbering the new run and double-counting. Each run is now tagged with a generation that the watchdog and every fire bump; a settle whose tag is stale is ignored. **Security:** *The URL canonicalizer folds IPv4-mapped IPv6 addresses to IPv4* — `b.safeUrl.canonicalize` / `b.ssrfGuard.canonicalizeHost` now fold an IPv4-mapped IPv6 address (`::ffff:1.2.3.4`, the `::ffff:0:0/96` block) to its embedded IPv4 dotted form. Previously it canonicalized to an IPv6 string, so a dual-stack peer never unified with an operator's `1.2.3.4` allow/deny entry — an allowlist bypass. Only the IPv4-mapped block folds, because the SSRF classifier maps it to the embedded v4 verdict directly; NAT64 (`64:ff9b::/96`) and 6to4 (`2002::/16`) are deliberately kept as IPv6, since the classifier treats a NAT64 literal as reserved and folding it would turn a blocked verdict into an allowed public IPv4. The host canonicalizer also now strips every trailing dot, so `host`, `host.`, and `host..` collapse to one form. · *verifyIdToken enforces the OIDC azp (authorized party) check* — `b.auth.oauth`'s `verifyIdToken` validated only that its `client_id` was present in the token's `aud`, ignoring `azp`. Per OIDC Core 3.1.3.7, a multi-audience ID token must carry an `azp` and a present `azp` must equal the RP's `client_id`. Without it, a token whose authorized party is a different client — but whose `aud` array also lists this RP — verified clean (a confused-deputy / token-substitution hole). The verifier now rejects a multi-audience token with no `azp` (`auth-oauth/azp-required`) and any token whose `azp` is not the `client_id` (`auth-oauth/azp-mismatch`). A single-audience token with no `azp` (the common case) is unaffected. · *SD-JWT key-binding audience and nonce compare in constant time* — `b.auth.sdJwtVc.verify` compared the key-binding JWT's `aud` and `nonce` with a short-circuiting `!==`, while the adjacent `sd_hash` check already used a constant-time compare. The `nonce` is a verifier-issued replay-defense value, so a non-constant-time compare leaks a matching-prefix timing oracle; both checks now use the constant-time helper.
package/README.md CHANGED
@@ -52,7 +52,7 @@ var b = require("@blamejs/core");
52
52
  })();
53
53
  ```
54
54
 
55
- **Requirements:** Node.js 24.14+ (current active LTS, fixes CVE-2026-21713 non-constant-time HMAC compare). Node 26 satisfies the floor and the framework test suite runs cleanly on it today; the floor itself will bump to `>=26.x` when Node 26 promotes to Active LTS. Two Node 26 platform changes operators integrating with blamejs should know about: the new `localStorage` global (the framework's storage backend is `b.backup.diskStorage`; the legacy `b.backup.localStorage` alias was removed in v0.11.20 — update call sites accordingly), and the seed-only ML-KEM / ML-DSA PKCS8 export shape (sealed material from Node 24 re-imports cleanly on Node 26; new material from Node 26 in the seed-only shape). See [SECURITY.md](SECURITY.md#node-26-compatibility) for the details.
55
+ **Requirements:** Node.js 24.16+ (current active LTS line; 24.14.1 fixed CVE-2026-21713 non-constant-time HMAC compare, 24.16 is the current patch level). Node 26 satisfies the floor and the framework test suite runs cleanly on it today; the floor itself will bump to `>=26.x` when Node 26 promotes to Active LTS. Two Node 26 platform changes operators integrating with blamejs should know about: the new `localStorage` global (the framework's storage backend is `b.backup.diskStorage`; the legacy `b.backup.localStorage` alias was removed in v0.11.20 — update call sites accordingly), and the seed-only ML-KEM / ML-DSA PKCS8 export shape (sealed material from Node 24 re-imports cleanly on Node 26; new material from Node 26 in the seed-only shape). See [SECURITY.md](SECURITY.md#node-26-compatibility) for the details.
56
56
 
57
57
  ## What ships in the box
58
58
 
@@ -60,12 +60,12 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
60
60
 
61
61
  ### Data layer
62
62
 
63
- - **SQLite with sealed-by-default columns** — `b.db`, migrations, seeders, atomic-file writes
63
+ - **SQLite with sealed-by-default columns** — `b.db`, migrations, seeders, atomic-file writes; the db handle constructs with a SQLITE_LIMIT_LENGTH parse-time cap (a >1 MiB statement is rejected) as a DoS floor on the raw-SQL surface
64
64
  - **Chainable query builder** — atomic `.increment(col, delta)`, closure-form `.whereGroup` / top-level `.orWhere` OR composition, `.search(fields, term)` LIKE-OR with safe `%`/`_` ESCAPE handling, `.paginate(opts)` returning `{ items, total, page, totalPages }`; a column-membership gate (`db.init({ columnGate })`, default reject) fails a query closed when it names a column the table never declared, and `whereRaw` refuses an embedded string literal so values bind through placeholders
65
65
  - **Mongo-style document-store facade** — `b.db.collection(name, opts?)` with `$set` / `$inc` / `$unset` / `$eq` / `$ne` / `$gt` / `$gte` / `$lt` / `$lte` / `$in` / `$like`; schemaless-document opts via `overflow: "<col>"` (folds unknown fields into a JSON-text column; rewrites `WHERE` on virtual fields to `JSON_EXTRACT`), `jsonColumns: [...]` (auto-stringify on write + parse via `b.safeJson` on read), `sealedFields: { email: "emailHash" }` (co-locates a `b.cryptoField` sealed-column / derived-hash declaration so plaintext lookups auto-rewrite to hash-column lookups)
66
66
  - **DB lifecycle** — in-memory encrypted snapshot via `b.db.snapshot()`; standalone encrypted-DB-file lifecycle (`b.db.fileLifecycle({ dataDir, vault })` — decrypt-to-tmpfs, periodic re-encrypt flush, graceful shutdown — same envelope as `b.db`, no schema/audit-chain coupling); `db.init` opt-outs `frameworkTables: false` / `auditSigning: false` and path overrides `encryptedDbPath` / `encryptedDbName` / `dbKeyPath`
67
67
  - **External RDBMS** — bring-your-own Postgres / MySQL with pool tuning + role-aware connect + read-replica routing (`b.externalDb`); declarative role-narrowed views and Postgres row-level-security migrations (`b.db.declareView`, `b.db.declareRowPolicy`); an opt-in `requireTls` transport posture refuses a non-TLS backend at boot, and query / transaction / read traces carry OpenTelemetry `db.*` attributes. The framework's own data layer — the signed audit chain, cluster leadership and lease fencing, sessions, break-glass, and the local queue / cache / scheduler — is composed through the dialect-aware `b.sql` builder (every identifier quoted by construction, every value bound as a placeholder, dialect-correct SQLite / Postgres / MySQL output), so the framework's tables run on a Postgres or MySQL backend, not only local SQLite; `b.guardSql` validates result rows against NUL bytes, quote-jump sequences, and per-column / total-size boundaries
68
- - **Object store** — S3 / R2 / B2 / GCS / Azure with multipart upload + SSE + bucket-ops (create / delete / list / lifecycle / CORS); S3 Object Lock + per-object retention + legal hold for write-once-read-many compliance workloads (`b.storage`, `b.objectStore`)
68
+ - **Object store** — S3 / R2 / B2 / GCS / Azure with multipart upload + SSE + bucket-ops (create / delete / list / lifecycle / CORS); S3 Object Lock + per-object retention + legal hold for write-once-read-many compliance workloads, with versioned delete + `listVersions` for right-to-erasure / crypto-shred against an Object-Lock bucket (`b.storage`, `b.objectStore`)
69
69
  - **Queues + cache** — durable queue with priority + cron + flows on local SQLite, shared Redis, OR AWS SQS via SigV4 + AWSJsonProtocol_1.0 (`b.queue`, `b.jobs`) — the local backend can target an operator-supplied database / table / schema; cluster-shared cache (`b.cache`)
70
70
  ### Identity & access
71
71
 
@@ -86,6 +86,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
86
86
  - Opaque-userId anonymous sessions via `create({ anonymous: true })`
87
87
  - Idle / absolute timeouts, fingerprint drift detection + anomaly scoring, brute-force lockout
88
88
  - Session-fixation rotation (`b.session.rotate`) re-keys the sid-bound device fingerprint to the new id — pass the same `{ req, fingerprintFields }` used at `create` (a fingerprint-bound session rotated without `req` is refused, so the binding can never silently break or false-drift)
89
+ - One-call secure logout (`b.session.logout(res, token)`) destroys the session AND wipes client-side state — emits an RFC 9527 Clear-Site-Data header (cookies + storage + cache) and expires the session cookie before deleting the row
89
90
  - **Authorization** — RBAC + per-role DB binding + role-spec `requireMfa` + per-route MFA freshness window + ABAC predicate registry (`b.permissions`); API keys with rotation (`b.apiKey`)
90
91
  - **Workflow gates** — break-glass column gates with second-factor + audit (`b.breakGlass`); two-person-rule m-of-n approval with cooling-off lock + cancellation (`b.dualControl`)
91
92
  - **Financial / Open Banking** — FAPI 2.0 Final composite posture (PAR + PKCE-S256 + DPoP-or-mTLS + RFC 9207); runtime enforcement helpers `b.fapi2.assertCallback` (refuses missing iss + bare-param under message-signing) and `b.fapi2.assertAuthzRequest` (refuses non-JAR); CFPB §1033 / FDX 6.0 consumer-financial-data-sharing wrapper (`b.fdx`)
@@ -42,6 +42,7 @@ var nodeFs = require("node:fs");
42
42
  var C = require("./constants");
43
43
  var lazyRequire = require("./lazy-require");
44
44
  var { defineClass } = require("./framework-error");
45
+ var atomicFile = require("./atomic-file");
45
46
 
46
47
  var ArchiveReadError = defineClass("ArchiveReadError", { alwaysPermanent: true });
47
48
 
@@ -925,7 +926,7 @@ function zip(adapter, opts) {
925
926
  // the rename targets a non-existent path.
926
927
  var tmpPath = resolvedPath + ".__blamejs-archive-read-tmp__";
927
928
  nodeFs.writeFileSync(tmpPath, body);
928
- nodeFs.renameSync(tmpPath, resolvedPath);
929
+ atomicFile.renameWithRetry(tmpPath, resolvedPath);
929
930
  written.push({ name: entry.name, bytesWritten: body.length, path: resolvedPath });
930
931
  bytesExtracted += body.length;
931
932
  }
@@ -14,6 +14,7 @@ var C = require("./constants");
14
14
  var lazyRequire = require("./lazy-require");
15
15
  var safeBuffer = require("./safe-buffer");
16
16
  var archiveTar = require("./archive-tar");
17
+ var atomicFile = require("./atomic-file");
17
18
 
18
19
  var TarError = archiveTar.TarError;
19
20
  var _parseHeader = archiveTar._parseHeader;
@@ -428,7 +429,7 @@ function tar(adapter, opts) {
428
429
  }
429
430
  var tmpPath = resolvedPath + ".__blamejs-archive-tar-tmp__";
430
431
  nodeFs.writeFileSync(tmpPath, body);
431
- nodeFs.renameSync(tmpPath, resolvedPath);
432
+ atomicFile.renameWithRetry(tmpPath, resolvedPath);
432
433
  written.push({ name: entry.name, bytesWritten: body.length, path: resolvedPath });
433
434
  bytesExtracted += body.length;
434
435
  }
@@ -1077,6 +1077,11 @@ module.exports = {
1077
1077
  fsync: fsync,
1078
1078
  fsyncDir: fsyncDir,
1079
1079
  ensureDir: ensureDir,
1080
+ // Atomic rename with a bounded retry on Windows-transient lock errors
1081
+ // (EPERM/EACCES/EBUSY from AV / search indexer / Dropbox / OneDrive briefly
1082
+ // holding the destination). Exposed so any final temp->dest rename routes
1083
+ // through the same retry instead of hand-rolling it (or, worse, omitting it).
1084
+ renameWithRetry: _renameWithRetry,
1080
1085
  copyDirRecursive: copyDirRecursive,
1081
1086
  pathTimestamp: pathTimestamp,
1082
1087
  conflictPath: conflictPath,
package/lib/bundler.js CHANGED
@@ -266,13 +266,8 @@ function create(opts) {
266
266
  var hashOn = opts.hash !== false;
267
267
  var hashLen = DEFAULT_HASH_LEN;
268
268
  if (opts.hashLen !== undefined) {
269
- if (!numericBounds.isPositiveFiniteInt(opts.hashLen) ||
270
- opts.hashLen < MIN_HASH_LEN || opts.hashLen > MAX_HASH_LEN) {
271
- throw new BundlerError("bundler/bad-hash-len",
272
- "bundler.create: opts.hashLen must be a positive finite integer " +
273
- "between " + MIN_HASH_LEN + " and " + MAX_HASH_LEN +
274
- "; got " + numericBounds.shape(opts.hashLen));
275
- }
269
+ numericBounds.requirePositiveFiniteInt(opts.hashLen, "bundler.create: opts.hashLen",
270
+ BundlerError, "bundler/bad-hash-len", { min: MIN_HASH_LEN, max: MAX_HASH_LEN });
276
271
  hashLen = opts.hashLen;
277
272
  }
278
273
  var log = opts.log || null;
package/lib/cli.js CHANGED
@@ -94,7 +94,14 @@ function _openSqlite(dbPath) {
94
94
  // Lazy-required so the CLI doesn't crash on `blamejs version` or
95
95
  // `blamejs help` if node:sqlite isn't usable for some reason.
96
96
  var { DatabaseSync } = require("node:sqlite");
97
- return new DatabaseSync(dbPath);
97
+ // Same SQLITE_LIMIT_ sqlLength cap as db.init's main handle — the CLI opens
98
+ // the operator's real database for migrate / inspect, so the parse-time DoS
99
+ // floor applies here too.
100
+ return new DatabaseSync(dbPath, {
101
+ limits: {
102
+ sqlLength: C.BYTES.mib(1),
103
+ },
104
+ });
98
105
  }
99
106
 
100
107
  // ---- Subcommand: migrate ----
@@ -44,6 +44,7 @@ var lazyRequire = require("./lazy-require");
44
44
  var safeJson = require("./safe-json");
45
45
  var validateOpts = require("./validate-opts");
46
46
  var { defineClass } = require("./framework-error");
47
+ var atomicFile = require("./atomic-file");
47
48
 
48
49
  var audit = lazyRequire(function () { return require("./audit"); });
49
50
 
@@ -201,7 +202,7 @@ function create(opts) {
201
202
  };
202
203
  var tmp = sidecarPath + ".tmp";
203
204
  nodeFs.writeFileSync(tmp, JSON.stringify(payload, null, 2));
204
- nodeFs.renameSync(tmp, sidecarPath);
205
+ atomicFile.renameWithRetry(tmp, sidecarPath);
205
206
  }
206
207
 
207
208
  function _verifySidecar(parsed) {
@@ -349,7 +350,7 @@ function create(opts) {
349
350
  * instead of a silent zero-files-checked pass.
350
351
  *
351
352
  * @opts
352
- * libVendorDir: string, // absolute path to lib/vendor (defaults to cwd-relative)
353
+ * libVendorDir: string, // absolute path to the lib/vendor tree to verify; default: the framework's own (cwd-independent). Per-file manifest paths resolve under this directory.
353
354
  * manifestPath: string, // absolute path to MANIFEST.json (defaults under libVendorDir)
354
355
  *
355
356
  * @example
@@ -366,7 +367,14 @@ function create(opts) {
366
367
  */
367
368
  function verifyVendorIntegrity(opts) {
368
369
  opts = opts || {};
369
- var libVendorDir = opts.libVendorDir || nodePath.join(process.cwd(), "lib", "vendor");
370
+ // Default to the framework's OWN vendor directory (this module lives in
371
+ // lib/, so __dirname/vendor is lib/vendor) — the tree actually loaded at
372
+ // runtime. The previous cwd-relative default made the check cwd-dependent:
373
+ // run from another directory it read-failed every entry, and under a crafted
374
+ // cwd that happened to hold a clean vendor tree it could hash a DIFFERENT
375
+ // tree than the one loaded. Operators verifying a deployed tree elsewhere
376
+ // pass libVendorDir explicitly; per-file resolution honors it below.
377
+ var libVendorDir = opts.libVendorDir || nodePath.join(__dirname, "vendor");
370
378
  var manifestPath = opts.manifestPath || nodePath.join(libVendorDir, "MANIFEST.json");
371
379
  var raw;
372
380
  try { raw = nodeFs.readFileSync(manifestPath, "utf8"); }
@@ -389,7 +397,14 @@ function verifyVendorIntegrity(opts) {
389
397
  var rel = files[kind];
390
398
  var expected = hashes[kind];
391
399
  if (typeof rel !== "string" || typeof expected !== "string") return;
392
- var abs = nodePath.isAbsolute(rel) ? rel : nodePath.join(process.cwd(), rel);
400
+ // Manifest paths are stored repo-root-relative (e.g. "lib/vendor/x.cjs").
401
+ // Resolve each one UNDER libVendorDir (the tree being verified), not
402
+ // process.cwd(), so the check hashes the actual loaded files regardless
403
+ // of the working directory. Strip the leading lib/vendor/ so the join
404
+ // doesn't double it; a manifest that already stored a vendor-relative
405
+ // path resolves the same way.
406
+ var relInVendor = rel.replace(/^lib[\\/]+vendor[\\/]+/, "");
407
+ var abs = nodePath.isAbsolute(rel) ? rel : nodePath.join(libVendorDir, relInVendor);
393
408
  var actual;
394
409
  try {
395
410
  var bytes = nodeFs.readFileSync(abs);
package/lib/db-schema.js CHANGED
@@ -98,6 +98,34 @@ function runInTransaction(db, fn, opts) {
98
98
  }
99
99
  }
100
100
 
101
+ // runInTransactionAsync — the async sibling of runInTransaction. SQLite
102
+ // transactions are synchronous at the wire, but the body between BEGIN and
103
+ // COMMIT may await (a seeder's run(), a per-row re-seal that reads off the
104
+ // handle): await fn() before COMMIT so the transaction wraps the whole
105
+ // awaited body, and ROLLBACK on rejection. Same opts.lockMode /
106
+ // opts.onRollbackFail contract as the sync form.
107
+ async function runInTransactionAsync(db, fn, opts) {
108
+ if (typeof fn !== "function") {
109
+ throw new TypeError("dbSchema.runInTransactionAsync: fn must be a function");
110
+ }
111
+ opts = opts || {};
112
+ var beginSql = opts.lockMode ? "BEGIN " + opts.lockMode : "BEGIN";
113
+ runSqlOnHandle(db, beginSql);
114
+ try {
115
+ var result = await fn();
116
+ runSqlOnHandle(db, "COMMIT");
117
+ return result;
118
+ } catch (e) {
119
+ try { runSqlOnHandle(db, "ROLLBACK"); }
120
+ catch (rollbackErr) {
121
+ if (typeof opts.onRollbackFail === "function") {
122
+ try { opts.onRollbackFail(rollbackErr); } catch (_e) { /* nested handler must not bubble */ }
123
+ }
124
+ }
125
+ throw e;
126
+ }
127
+ }
128
+
101
129
  // ---- Internal migrations table ----
102
130
 
103
131
  // Logical name; the physical name + configured prefix resolve through
@@ -611,6 +639,7 @@ module.exports = {
611
639
  runSql: runSql,
612
640
  runSqlOnHandle: runSqlOnHandle,
613
641
  runInTransaction: runInTransaction,
642
+ runInTransactionAsync: runInTransactionAsync,
614
643
  // Shared data-layer dialect resolution — composed by migrations.js +
615
644
  // seeders.js so the handle-dialect / b.sql-opts / key-text-type logic
616
645
  // lives in exactly one place.
package/lib/db.js CHANGED
@@ -1156,8 +1156,21 @@ async function init(opts) {
1156
1156
  encKey = null;
1157
1157
  }
1158
1158
 
1159
- // Open the database
1160
- database = new DatabaseSync(dbPath);
1159
+ // Open the database. The node:sqlite `limits` option sets SQLITE_LIMIT_*
1160
+ // caps at construction — a parse-time DoS floor complementary to the
1161
+ // streamLimit row-count gate (one bounds statement size, the other bounds
1162
+ // result cardinality). sqlLength rejects a megaquery (>1 MiB) before the
1163
+ // parser chews CPU/memory on it; the framework never legitimately emits a
1164
+ // statement anywhere near 1 MiB, and a 1 GB attacker-influenced statement
1165
+ // would otherwise be parsed. The limits option is part of node:sqlite from
1166
+ // Node 24.10+, comfortably under the engines floor. (SQLITE_LIMIT_ATTACHED is
1167
+ // left at the SQLite default — the snapshot / backup path relies on the
1168
+ // attach mechanism.)
1169
+ database = new DatabaseSync(dbPath, {
1170
+ limits: {
1171
+ sqlLength: C.BYTES.mib(1),
1172
+ },
1173
+ });
1161
1174
 
1162
1175
  // Performance pragmas
1163
1176
  runSql(database, "PRAGMA journal_mode=WAL");
@@ -2000,9 +2000,12 @@ async function downloadStream(opts) {
2000
2000
  }
2001
2001
  }
2002
2002
 
2003
- // Atomic rename + dir fsync.
2003
+ // Atomic rename + dir fsync. Route the final rename through
2004
+ // atomicFile.renameWithRetry so a Windows-transient lock on the destination
2005
+ // (AV / search indexer / Dropbox / OneDrive) is retried rather than surfaced
2006
+ // as a hard download failure — the same retry b.atomicFile.writeSync applies.
2004
2007
  try {
2005
- nodeFs.renameSync(tmpPath, dest);
2008
+ atomicFile.renameWithRetry(tmpPath, dest);
2006
2009
  atomicFile.fsyncDir(dir);
2007
2010
  } catch (e) {
2008
2011
  try { nodeFs.unlinkSync(tmpPath); } catch (_u) { /* best-effort cleanup */ }
@@ -44,6 +44,7 @@
44
44
  * schemaSql: string, // required CREATE TABLE / INDEX script
45
45
  * recovery: "refuse" | "rename-and-recreate", // default: "refuse"
46
46
  * pragmas: object, // optional extra PRAGMA overrides
47
+ * limits: object, // node:sqlite SQLITE_LIMIT_* caps; default { sqlLength: 1 MiB } (parity with b.db / CLI)
47
48
  * audit: boolean, // default: true
48
49
  * }) -> { db, prepare, run, query, close, file }
49
50
  *
@@ -55,10 +56,19 @@
55
56
 
56
57
  var nodeFs = require("node:fs");
57
58
  var nodePath = require("node:path");
59
+ var C = require("./constants");
58
60
  var lazyRequire = require("./lazy-require");
59
61
  var validateOpts = require("./validate-opts");
60
62
  var safeSql = require("./safe-sql");
61
63
  var { LocalDbThinError } = require("./framework-error");
64
+ var atomicFile = require("./atomic-file");
65
+
66
+ // Default parse-time statement-size cap, matching b.db and the CLI opener
67
+ // (the v0.15.9 node:sqlite SQLITE_LIMIT_LENGTH floor). prepare()/exec() on the
68
+ // thin path parse operator/application SQL, so the same cap guards it against
69
+ // an attacker-influenced megaquery the parser would otherwise chew (SQLite's
70
+ // default is 1 GB). Operators raise/relax it via opts.limits.
71
+ var _DEFAULT_SQL_LENGTH = C.BYTES.mib(1);
62
72
 
63
73
  var audit = lazyRequire(function () { return require("./audit"); });
64
74
 
@@ -98,6 +108,19 @@ function _validateOpts(opts) {
98
108
  throw new LocalDbThinError("localdb-thin/bad-pragmas",
99
109
  "localDb.thin: pragmas must be an object mapping pragma name -> value");
100
110
  }
111
+ if (opts.limits !== undefined &&
112
+ (typeof opts.limits !== "object" || opts.limits === null || Array.isArray(opts.limits))) {
113
+ throw new LocalDbThinError("localdb-thin/bad-limits",
114
+ "localDb.thin: limits must be an object of node:sqlite SQLITE_LIMIT_* caps " +
115
+ "(e.g. { sqlLength: 1048576 })");
116
+ }
117
+ }
118
+
119
+ // Merge operator-supplied limits over the framework default (sqlLength cap),
120
+ // so the thin path reaches parity with b.db / the CLI opener while letting an
121
+ // operator raise the cap or add SQLITE_LIMIT_* keys (e.g. attach: 0).
122
+ function _resolveLimits(opts) {
123
+ return Object.assign({ sqlLength: _DEFAULT_SQL_LENGTH }, opts.limits || {});
101
124
  }
102
125
 
103
126
  function _runPragmas(database, extra) {
@@ -174,7 +197,7 @@ function thin(opts) {
174
197
  var renamedTo = null;
175
198
 
176
199
  function _attemptOpen() {
177
- var db = new DatabaseSync(file);
200
+ var db = new DatabaseSync(file, { limits: _resolveLimits(opts) });
178
201
  _runPragmas(db, opts.pragmas);
179
202
  if (!_integrityOk(db)) {
180
203
  try { db.close(); } catch (_e) { /* best-effort */ }
@@ -203,7 +226,7 @@ function thin(opts) {
203
226
  var lastRenameErr = null;
204
227
  for (var attempt = 0; attempt < 20 && !renamed; attempt += 1) {
205
228
  try {
206
- if (nodeFs.existsSync(file)) nodeFs.renameSync(file, renamedTo);
229
+ if (nodeFs.existsSync(file)) atomicFile.renameWithRetry(file, renamedTo);
207
230
  renamed = true;
208
231
  } catch (re) {
209
232
  lastRenameErr = re;
@@ -228,7 +251,7 @@ function thin(opts) {
228
251
  ["-wal", "-shm"].forEach(function (suffix) {
229
252
  var sibling = file + suffix;
230
253
  if (nodeFs.existsSync(sibling)) {
231
- try { nodeFs.renameSync(sibling, sibling + ".corrupt-" + stamp); }
254
+ try { atomicFile.renameWithRetry(sibling, sibling + ".corrupt-" + stamp); }
232
255
  catch (_se) { /* best-effort */ }
233
256
  }
234
257
  });
@@ -85,7 +85,7 @@ function create(config) {
85
85
  var stamp = time.toIso8601NoMs(new Date()).replace(/[-:]/g, "");
86
86
  var rotated = nodePath.join(dir, cfg.fileNamePrefix + "-" + stamp + ".log");
87
87
  if (nodeFs.existsSync(activePath)) {
88
- nodeFs.renameSync(activePath, rotated);
88
+ atomicFile.renameWithRetry(activePath, rotated);
89
89
  if (cfg.compressRotations) {
90
90
  var data = nodeFs.readFileSync(rotated);
91
91
  var gz = zlib.gzipSync(data);
package/lib/mail-scan.js CHANGED
@@ -161,11 +161,8 @@ function create(opts) {
161
161
 
162
162
  validateOpts.requireNonEmptyString(opts.host, "mail.scan.create.host",
163
163
  MailScanError, "mail-scan/bad-host");
164
- if (!numericBounds.isPositiveFiniteInt(opts.port) || opts.port > 65535) { // TCP port-number range cap
165
- throw new MailScanError("mail-scan/bad-port",
166
- "mail.scan.create.port must be a positive integer in [1,65535]; got " +
167
- numericBounds.shape(opts.port));
168
- }
164
+ numericBounds.requirePositiveFiniteInt(opts.port, "mail.scan.create.port",
165
+ MailScanError, "mail-scan/bad-port", { max: 65535 }); // TCP port-number range cap
169
166
  var protocol = opts.protocol || DEFAULT_PROTOCOL;
170
167
  if (!ALLOWED_PROTOCOLS[protocol]) {
171
168
  throw new MailScanError("mail-scan/bad-protocol",
@@ -87,29 +87,51 @@ var DEFAULT_TYPES = ["cookies", "storage", "cache", "executionContexts"];
87
87
  * },
88
88
  * ]);
89
89
  */
90
- function create(opts) {
91
- opts = opts || {};
92
- validateOpts(opts, ["types"], "middleware.clearSiteData");
93
- var types = opts.types === undefined ? DEFAULT_TYPES : opts.types;
90
+ /**
91
+ * @primitive b.middleware.clearSiteData.headerValue
92
+ * @signature b.middleware.clearSiteData.headerValue(types, label?)
93
+ * @since 0.15.9
94
+ * @status stable
95
+ * @related b.middleware.clearSiteData, b.session.logout
96
+ *
97
+ * Build the RFC 9527 §3 Clear-Site-Data header value from a list of directive
98
+ * types — a comma-separated list of double-quoted tokens — validating each
99
+ * against the known set (`cookies`, `storage`, `cache`, `executionContexts`).
100
+ * The middleware factory and `b.session.logout` both compose it so every
101
+ * emitter produces the same validated header instead of hand-rolling the
102
+ * quoting. Throws a `TypeError` on an unknown directive or a non-array input
103
+ * (config-time / entry-point tier).
104
+ *
105
+ * @example
106
+ * b.middleware.clearSiteData.headerValue(["cookies", "storage"]);
107
+ * // → '"cookies", "storage"'
108
+ */
109
+ function headerValue(types, label) {
110
+ label = label || "middleware.clearSiteData";
94
111
  if (!Array.isArray(types) || types.length === 0) {
95
- throw new TypeError("middleware.clearSiteData: opts.types must be a non-empty array");
112
+ throw new TypeError(label + ": types must be a non-empty array");
96
113
  }
97
114
  for (var i = 0; i < types.length; i += 1) {
98
115
  var t = types[i];
99
116
  if (typeof t !== "string" || !KNOWN_TYPES[t]) {
100
117
  throw new TypeError(
101
- "middleware.clearSiteData: unknown type '" + t +
118
+ label + ": unknown type '" + t +
102
119
  "' (expected one of: " + Object.keys(KNOWN_TYPES).join(", ") + ")");
103
120
  }
104
121
  }
105
- // Header value is a comma-separated list of double-quoted tokens
106
- // per RFC 9527 §3 (Structured Field Value List of Strings). Build
107
- // once at construction time — runtime cost is one setHeader call.
108
- var headerValue = types.map(function (t) { return '"' + t + '"'; }).join(", ");
122
+ return types.map(function (t) { return '"' + t + '"'; }).join(", ");
123
+ }
124
+
125
+ function create(opts) {
126
+ opts = opts || {};
127
+ validateOpts(opts, ["types"], "middleware.clearSiteData");
128
+ var types = opts.types === undefined ? DEFAULT_TYPES : opts.types;
129
+ // Header value built once at construction; runtime cost is one setHeader.
130
+ var headerVal = headerValue(types, "middleware.clearSiteData");
109
131
 
110
132
  return function clearSiteData(req, res, next) {
111
133
  if (typeof res.setHeader === "function") {
112
- res.setHeader("Clear-Site-Data", headerValue);
134
+ res.setHeader("Clear-Site-Data", headerVal);
113
135
  }
114
136
  next();
115
137
  };
@@ -117,6 +139,9 @@ function create(opts) {
117
139
 
118
140
  module.exports = {
119
141
  create: create,
142
+ // The shared RFC 9527 header-value builder — b.session.logout composes it so
143
+ // the logout path emits the same validated Clear-Site-Data header.
144
+ headerValue: headerValue,
120
145
  KNOWN_TYPES: Object.keys(KNOWN_TYPES),
121
146
  DEFAULT_TYPES: DEFAULT_TYPES,
122
147
  };
package/lib/mtls-ca.js CHANGED
@@ -336,8 +336,8 @@ function create(opts) {
336
336
  _writeExclusive(keyTmp, opts2.caKeyPem, 0o600);
337
337
  }
338
338
  _writeExclusive(certTmp, opts2.caCertPem, 0o644);
339
- nodeFs.renameSync(keyTmp, keyDest);
340
- nodeFs.renameSync(certTmp, paths.caCert);
339
+ atomicFile.renameWithRetry(keyTmp, keyDest);
340
+ atomicFile.renameWithRetry(certTmp, paths.caCert);
341
341
  } catch (e) {
342
342
  // Best-effort cleanup of half-written tmp files; the original
343
343
  // commit error is what we re-raise. Log cleanup failures at debug
@@ -82,6 +82,37 @@ function requireNonNegativeFiniteIntIfPresent(value, label, errorClass, code) {
82
82
  return value;
83
83
  }
84
84
 
85
+ // requirePositiveFiniteInt — REQUIRED-shape gate (the non-optional sibling
86
+ // of requirePositiveFiniteIntIfPresent): throws when the value is absent OR
87
+ // not a positive finite integer, and — when range bounds are supplied —
88
+ // when it falls outside them. Replaces the per-file
89
+ // `if (!nb.isPositiveFiniteInt(opts.X) || opts.X < MIN || opts.X > MAX) throw`
90
+ // cascade that bundler / mail-scan / safe-decompress rolled by hand for
91
+ // REQUIRED numeric opts (the IfPresent helper can't be used there — it
92
+ // skips when undefined, so a missing required opt would pass).
93
+ //
94
+ // nb.requirePositiveFiniteInt(opts.maxOutputBytes,
95
+ // "safeDecompress: maxOutputBytes", SafeDecompressError, "safe-decompress/bad-arg");
96
+ // nb.requirePositiveFiniteInt(opts.hashLen, "bundler.create: opts.hashLen",
97
+ // BundlerError, "bundler/bad-hash-len", { min: MIN_HASH_LEN, max: MAX_HASH_LEN });
98
+ function _rangeSuffix(range) {
99
+ if (!range) return "";
100
+ if (range.min != null && range.max != null) return " in [" + range.min + ", " + range.max + "]";
101
+ if (range.max != null) return " <= " + range.max;
102
+ if (range.min != null) return " >= " + range.min;
103
+ return "";
104
+ }
105
+ function requirePositiveFiniteInt(value, label, errorClass, code, range) {
106
+ var inRange = !range ||
107
+ ((range.min == null || value >= range.min) && (range.max == null || value <= range.max));
108
+ if (!isPositiveFiniteInt(value) || !inRange) {
109
+ throw new errorClass(code,
110
+ (label || "value") + " must be a positive finite integer" +
111
+ _rangeSuffix(range) + "; got " + shape(value));
112
+ }
113
+ return value;
114
+ }
115
+
85
116
  // requireAllPositiveFiniteIntIfPresent — batch validator. Walk each
86
117
  // opt-name in the list; for any that is present in opts, require it to
87
118
  // be a positive finite integer (otherwise throw via errorClass with the
@@ -105,6 +136,7 @@ module.exports = {
105
136
  shape: shape,
106
137
  isPositiveFiniteInt: isPositiveFiniteInt,
107
138
  isNonNegativeFiniteInt: isNonNegativeFiniteInt,
139
+ requirePositiveFiniteInt: requirePositiveFiniteInt,
108
140
  requirePositiveFiniteIntIfPresent: requirePositiveFiniteIntIfPresent,
109
141
  requireNonNegativeFiniteIntIfPresent: requireNonNegativeFiniteIntIfPresent,
110
142
  requireAllPositiveFiniteIntIfPresent: requireAllPositiveFiniteIntIfPresent,
@@ -356,7 +356,18 @@ function create(config) {
356
356
  });
357
357
  }
358
358
 
359
- function deleteKey(key) {
359
+ function deleteKey(key, opts) {
360
+ opts = opts || {};
361
+ // Versioned erasure (opts.versionId) is the S3 Object-Lock workflow and is
362
+ // sigv4-only today. Refuse loudly rather than silently delete the current
363
+ // blob — a silent drop on an erasure path would let a caller believe a
364
+ // specific version was shredded when it was not.
365
+ if (opts.versionId) {
366
+ throw _err("VERSIONID_UNSUPPORTED",
367
+ "deleteKey: versioned delete (opts.versionId) is S3/sigv4-only; the Azure " +
368
+ "Blob backend has no version surface here. Use a sigv4 backend for " +
369
+ "Object-Lock version erasure.", true);
370
+ }
360
371
  var url = _blobUrl(key);
361
372
  var headers = _signed("DELETE", url, {});
362
373
  return _httpRequest("DELETE", url, headers, null, reqOpts).then(
@@ -281,7 +281,18 @@ function create(config) {
281
281
  };
282
282
  }
283
283
 
284
- async function deleteKey(key) {
284
+ async function deleteKey(key, opts) {
285
+ opts = opts || {};
286
+ // Versioned erasure (opts.versionId) is the S3 Object-Lock workflow and is
287
+ // sigv4-only today. Refuse loudly rather than silently delete the live
288
+ // object — a silent drop on an erasure path would let a caller believe a
289
+ // specific version was shredded when it was not.
290
+ if (opts.versionId) {
291
+ throw _err("VERSIONID_UNSUPPORTED",
292
+ "deleteKey: versioned delete (opts.versionId) is S3/sigv4-only; the GCS " +
293
+ "backend has no version surface here. Use a sigv4 backend for Object-Lock " +
294
+ "version erasure.", true);
295
+ }
285
296
  var token = await _ensureToken();
286
297
  var url = _objectUrl(key);
287
298
  try {
@@ -110,7 +110,17 @@ function create(config) {
110
110
  });
111
111
  }
112
112
 
113
- function deleteKey(key) {
113
+ function deleteKey(key, opts) {
114
+ opts = opts || {};
115
+ // Versioned erasure (opts.versionId) is the S3 Object-Lock workflow and is
116
+ // sigv4-only. A bare PUT target has no version surface, so refuse loudly
117
+ // rather than issue a plain DELETE and report a specific version erased —
118
+ // a silent drop on an erasure path is the footgun.
119
+ if (opts.versionId) {
120
+ throw _err("VERSIONID_UNSUPPORTED",
121
+ "deleteKey: versioned delete (opts.versionId) is S3/sigv4-only; the http-put " +
122
+ "backend has no version surface. Use a sigv4 backend for Object-Lock version erasure.", true);
123
+ }
114
124
  var url = _keyToUrl(baseUrl, key);
115
125
  return _request("DELETE", url, null, headers, reqOpts).then(
116
126
  function () { return true; },
@@ -122,6 +122,10 @@ function buildBackend(config) {
122
122
  head: wrap("head"),
123
123
  delete: wrap("delete"),
124
124
  list: wrap("list"),
125
+ // listVersions is S3/sigv4-only (the ?versions subresource backs the
126
+ // WORM erasure workflow). Backends without it expose null so callers can
127
+ // feature-detect rather than hit a "wrap of undefined" at boot.
128
+ listVersions: typeof raw.listVersions === "function" ? wrap("listVersions") : null,
125
129
  // presigned*Url are sync URL-builders (no network call), so they
126
130
  // bypass retry + circuit-breaker — propagate any throw directly.
127
131
  presignedUploadUrl: typeof raw.presignedUploadUrl === "function"