@blamejs/core 0.15.9 → 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 +2 -0
- package/README.md +1 -1
- package/lib/bundler.js +2 -7
- package/lib/config-drift.js +17 -3
- package/lib/db-schema.js +29 -0
- package/lib/local-db-thin.js +23 -1
- package/lib/mail-scan.js +2 -5
- package/lib/numeric-bounds.js +32 -0
- package/lib/object-store/azure-blob.js +12 -1
- package/lib/object-store/gcs.js +12 -1
- package/lib/object-store/http-put.js +11 -1
- package/lib/object-store/index.js +4 -0
- package/lib/object-store/local.js +11 -1
- package/lib/object-store/sigv4.js +86 -5
- package/lib/safe-decompress.js +3 -12
- package/lib/seeders.js +33 -39
- package/lib/storage.js +71 -7
- package/lib/vault/rotate.js +4 -13
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ 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
|
+
|
|
11
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.
|
|
12
14
|
|
|
13
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.
|
package/README.md
CHANGED
|
@@ -65,7 +65,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
|
|
|
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
|
|
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
|
-
|
|
270
|
-
|
|
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/config-drift.js
CHANGED
|
@@ -350,7 +350,7 @@ function create(opts) {
|
|
|
350
350
|
* instead of a silent zero-files-checked pass.
|
|
351
351
|
*
|
|
352
352
|
* @opts
|
|
353
|
-
* libVendorDir: string, // absolute path to lib/vendor
|
|
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.
|
|
354
354
|
* manifestPath: string, // absolute path to MANIFEST.json (defaults under libVendorDir)
|
|
355
355
|
*
|
|
356
356
|
* @example
|
|
@@ -367,7 +367,14 @@ function create(opts) {
|
|
|
367
367
|
*/
|
|
368
368
|
function verifyVendorIntegrity(opts) {
|
|
369
369
|
opts = opts || {};
|
|
370
|
-
|
|
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");
|
|
371
378
|
var manifestPath = opts.manifestPath || nodePath.join(libVendorDir, "MANIFEST.json");
|
|
372
379
|
var raw;
|
|
373
380
|
try { raw = nodeFs.readFileSync(manifestPath, "utf8"); }
|
|
@@ -390,7 +397,14 @@ function verifyVendorIntegrity(opts) {
|
|
|
390
397
|
var rel = files[kind];
|
|
391
398
|
var expected = hashes[kind];
|
|
392
399
|
if (typeof rel !== "string" || typeof expected !== "string") return;
|
|
393
|
-
|
|
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);
|
|
394
408
|
var actual;
|
|
395
409
|
try {
|
|
396
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/local-db-thin.js
CHANGED
|
@@ -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,12 +56,20 @@
|
|
|
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");
|
|
62
64
|
var atomicFile = require("./atomic-file");
|
|
63
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);
|
|
72
|
+
|
|
64
73
|
var audit = lazyRequire(function () { return require("./audit"); });
|
|
65
74
|
|
|
66
75
|
// LRU prepared-statement cache cap — same magnitude as lib/db.js's full
|
|
@@ -99,6 +108,19 @@ function _validateOpts(opts) {
|
|
|
99
108
|
throw new LocalDbThinError("localdb-thin/bad-pragmas",
|
|
100
109
|
"localDb.thin: pragmas must be an object mapping pragma name -> value");
|
|
101
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 || {});
|
|
102
124
|
}
|
|
103
125
|
|
|
104
126
|
function _runPragmas(database, extra) {
|
|
@@ -175,7 +197,7 @@ function thin(opts) {
|
|
|
175
197
|
var renamedTo = null;
|
|
176
198
|
|
|
177
199
|
function _attemptOpen() {
|
|
178
|
-
var db = new DatabaseSync(file);
|
|
200
|
+
var db = new DatabaseSync(file, { limits: _resolveLimits(opts) });
|
|
179
201
|
_runPragmas(db, opts.pragmas);
|
|
180
202
|
if (!_integrityOk(db)) {
|
|
181
203
|
try { db.close(); } catch (_e) { /* best-effort */ }
|
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
|
-
|
|
165
|
-
|
|
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",
|
package/lib/numeric-bounds.js
CHANGED
|
@@ -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(
|
package/lib/object-store/gcs.js
CHANGED
|
@@ -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"
|
|
@@ -101,7 +101,17 @@ function create(config) {
|
|
|
101
101
|
});
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
function deleteKey(key) {
|
|
104
|
+
function deleteKey(key, opts) {
|
|
105
|
+
opts = opts || {};
|
|
106
|
+
// A filesystem has no object versions; a versioned-delete request can only
|
|
107
|
+
// be a caller mistake. Refuse loudly rather than unlink the single on-disk
|
|
108
|
+
// file and report a version was erased.
|
|
109
|
+
if (opts.versionId) {
|
|
110
|
+
throw _err("VERSIONID_UNSUPPORTED",
|
|
111
|
+
"deleteKey: versioned delete (opts.versionId) is not supported on the " +
|
|
112
|
+
"filesystem backend — a local file has no version history. Use a sigv4 " +
|
|
113
|
+
"(S3 Object-Lock) backend for version erasure.", true);
|
|
114
|
+
}
|
|
105
115
|
cluster.requireLeader();
|
|
106
116
|
var full = _resolveSafe(rootDir, key);
|
|
107
117
|
if (!nodeFs.existsSync(full)) return Promise.resolve(false);
|
|
@@ -499,7 +499,15 @@ function create(config) {
|
|
|
499
499
|
var headers = _makeSigned("PUT", url, payloadHash, extra);
|
|
500
500
|
return _request("PUT", url, headers, buf, reqOpts).then(function (res) {
|
|
501
501
|
_verifySseResponse(sseRequested, res.headers);
|
|
502
|
-
return {
|
|
502
|
+
return {
|
|
503
|
+
size: buf.length,
|
|
504
|
+
etag: res.headers.etag,
|
|
505
|
+
// On a versioning-enabled (Object-Lock) bucket S3/MinIO returns the
|
|
506
|
+
// version this PUT created. Surface it so callers can target the
|
|
507
|
+
// exact version for a later versioned delete / erasure — without it
|
|
508
|
+
// the only way to find the version is a separate listVersions() call.
|
|
509
|
+
versionId: res.headers && res.headers["x-amz-version-id"] || null,
|
|
510
|
+
};
|
|
503
511
|
});
|
|
504
512
|
}
|
|
505
513
|
|
|
@@ -601,7 +609,12 @@ function create(config) {
|
|
|
601
609
|
// response may or may not echo the header depending on vendor;
|
|
602
610
|
// re-verifying here would double-fault on otherwise-fine setups.
|
|
603
611
|
var result = completeDoc.CompleteMultipartUploadResult || {};
|
|
604
|
-
return {
|
|
612
|
+
return {
|
|
613
|
+
size: totalSize,
|
|
614
|
+
etag: result.ETag || completeRes.headers.etag,
|
|
615
|
+
multipart: true,
|
|
616
|
+
versionId: completeRes.headers && completeRes.headers["x-amz-version-id"] || null,
|
|
617
|
+
};
|
|
605
618
|
} catch (e) {
|
|
606
619
|
// Abort cleans up server-side storage for the partial upload.
|
|
607
620
|
// Failures here are silently swallowed — the caller's original
|
|
@@ -639,6 +652,11 @@ function create(config) {
|
|
|
639
652
|
function getResponse(key, opts) {
|
|
640
653
|
opts = opts || {};
|
|
641
654
|
var url = _keyToUrl(key);
|
|
655
|
+
// Reading a specific version (opts.versionId) is the read half of the
|
|
656
|
+
// WORM erasure workflow — verify a protected version is present before /
|
|
657
|
+
// gone after a versioned delete. Set it before signing so the query
|
|
658
|
+
// param is in the SigV4 canonical request.
|
|
659
|
+
if (opts.versionId) url.searchParams.set("versionId", opts.versionId);
|
|
642
660
|
var headers = _makeSigned("GET", url, sha256Hex(Buffer.alloc(0)));
|
|
643
661
|
if (opts.range) {
|
|
644
662
|
headers["Range"] = "bytes=" + opts.range.start + "-" + opts.range.end;
|
|
@@ -674,8 +692,10 @@ function create(config) {
|
|
|
674
692
|
});
|
|
675
693
|
}
|
|
676
694
|
|
|
677
|
-
function head(key) {
|
|
695
|
+
function head(key, opts) {
|
|
696
|
+
opts = opts || {};
|
|
678
697
|
var url = _keyToUrl(key);
|
|
698
|
+
if (opts.versionId) url.searchParams.set("versionId", opts.versionId);
|
|
679
699
|
var headers = _makeSigned("HEAD", url, sha256Hex(Buffer.alloc(0)));
|
|
680
700
|
return _request("HEAD", url, headers, null, reqOpts).then(function (res) {
|
|
681
701
|
return {
|
|
@@ -696,9 +716,25 @@ function create(config) {
|
|
|
696
716
|
});
|
|
697
717
|
}
|
|
698
718
|
|
|
699
|
-
|
|
719
|
+
// deleteKey(key, opts?) — opts.versionId targets a specific version;
|
|
720
|
+
// opts.bypassGovernanceRetention signs x-amz-bypass-governance-retention so
|
|
721
|
+
// a GOVERNANCE-mode retention can be lifted by a caller with the permission
|
|
722
|
+
// (COMPLIANCE mode is immutable to everyone and stays refused).
|
|
723
|
+
//
|
|
724
|
+
// WORM-awareness: an UNVERSIONED delete on a versioning-enabled bucket only
|
|
725
|
+
// writes a delete-marker — the data version survives and the call still
|
|
726
|
+
// resolves true. To actually erase a version (e.g. crypto-shred / GDPR Art.
|
|
727
|
+
// 17 on an Object-Lock bucket) pass the versionId from put()/listVersions().
|
|
728
|
+
// A delete refused by an active retention surfaces as a thrown error (S3 403
|
|
729
|
+
// / MinIO 400), never a silent success, so the caller learns the version is
|
|
730
|
+
// still protected.
|
|
731
|
+
function deleteKey(key, opts) {
|
|
732
|
+
opts = opts || {};
|
|
700
733
|
var url = _keyToUrl(key);
|
|
701
|
-
|
|
734
|
+
if (opts.versionId) url.searchParams.set("versionId", opts.versionId);
|
|
735
|
+
var extra = {};
|
|
736
|
+
if (opts.bypassGovernanceRetention) extra["x-amz-bypass-governance-retention"] = "true";
|
|
737
|
+
var headers = _makeSigned("DELETE", url, sha256Hex(Buffer.alloc(0)), extra);
|
|
702
738
|
return _request("DELETE", url, headers, null, reqOpts).then(
|
|
703
739
|
function () { return true; },
|
|
704
740
|
function (e) { if (e.statusCode === 404) return false; throw e; }
|
|
@@ -953,6 +989,50 @@ function create(config) {
|
|
|
953
989
|
});
|
|
954
990
|
}
|
|
955
991
|
|
|
992
|
+
// listVersions(prefix, opts?) — enumerate every object VERSION and
|
|
993
|
+
// delete-marker under prefix (S3 ListObjectVersions / the ?versions
|
|
994
|
+
// subresource). Plain list() only sees current versions; to erase prior
|
|
995
|
+
// versions on a versioning / Object-Lock bucket you first need their
|
|
996
|
+
// versionIds, which only this call surfaces. Each item carries
|
|
997
|
+
// { key, versionId, isLatest, deleteMarker, size, lastModified, etag };
|
|
998
|
+
// deleteMarker:true rows are tombstones (no data, size null). Pagination
|
|
999
|
+
// walks (keyMarker, versionIdMarker) the way list() walks continuationToken.
|
|
1000
|
+
function listVersions(prefix, opts) {
|
|
1001
|
+
opts = opts || {};
|
|
1002
|
+
var params = { versions: "" };
|
|
1003
|
+
if (prefix) params["prefix"] = prefix;
|
|
1004
|
+
if (opts.maxResults) params["max-keys"] = String(opts.maxResults);
|
|
1005
|
+
if (opts.keyMarker) params["key-marker"] = opts.keyMarker;
|
|
1006
|
+
if (opts.versionIdMarker) params["version-id-marker"] = opts.versionIdMarker;
|
|
1007
|
+
|
|
1008
|
+
var url = _bucketUrl(params);
|
|
1009
|
+
var headers = _makeSigned("GET", url, sha256Hex(Buffer.alloc(0)));
|
|
1010
|
+
return _request("GET", url, headers, null, reqOpts).then(function (res) {
|
|
1011
|
+
var doc = safeXml.parse(res.body, LIST_PARSE_OPTS);
|
|
1012
|
+
var result = doc.ListVersionsResult || {};
|
|
1013
|
+
function _mapEntry(e, isDeleteMarker) {
|
|
1014
|
+
return {
|
|
1015
|
+
key: e.Key,
|
|
1016
|
+
versionId: e.VersionId != null ? String(e.VersionId) : null,
|
|
1017
|
+
isLatest: e.IsLatest === "true",
|
|
1018
|
+
deleteMarker: isDeleteMarker,
|
|
1019
|
+
size: isDeleteMarker ? null : (e.Size != null ? parseInt(e.Size, 10) : null),
|
|
1020
|
+
lastModified: e.LastModified ? Date.parse(e.LastModified) : null,
|
|
1021
|
+
etag: isDeleteMarker ? null : (e.ETag || null),
|
|
1022
|
+
};
|
|
1023
|
+
}
|
|
1024
|
+
var versions = _arrayify(result.Version).map(function (v) { return _mapEntry(v, false); });
|
|
1025
|
+
var markers = _arrayify(result.DeleteMarker).map(function (m) { return _mapEntry(m, true); });
|
|
1026
|
+
var items = versions.concat(markers).filter(function (it) { return it.key; });
|
|
1027
|
+
return {
|
|
1028
|
+
items: items,
|
|
1029
|
+
truncated: result.IsTruncated === "true",
|
|
1030
|
+
keyMarker: result.NextKeyMarker || null,
|
|
1031
|
+
versionIdMarker: result.NextVersionIdMarker || null,
|
|
1032
|
+
};
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
|
|
956
1036
|
return {
|
|
957
1037
|
protocol: "sigv4",
|
|
958
1038
|
endpoint: endpoint,
|
|
@@ -966,6 +1046,7 @@ function create(config) {
|
|
|
966
1046
|
head: head,
|
|
967
1047
|
delete: deleteKey,
|
|
968
1048
|
list: list,
|
|
1049
|
+
listVersions: listVersions,
|
|
969
1050
|
presignedUploadUrl: presignedUploadUrl,
|
|
970
1051
|
presignedDownloadUrl: presignedDownloadUrl,
|
|
971
1052
|
presignedUploadPolicy: presignedUploadPolicy,
|
package/lib/safe-decompress.js
CHANGED
|
@@ -171,18 +171,9 @@ function safeDecompress(input, opts) {
|
|
|
171
171
|
JSON.stringify(opts.algorithm));
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
-
// maxOutputBytes — required, positive finite integer.
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
// when undefined). The numericBounds.requirePositiveFiniteInt helper
|
|
178
|
-
// would fit, but the existing call surface across the framework
|
|
179
|
-
// uses the inline shape for required-opt validation.
|
|
180
|
-
if (!numericBounds.isPositiveFiniteInt(opts.maxOutputBytes)) { // allow:inline-numeric-bounds-cascade — required (non-optional) opt; requirePositiveFiniteIntIfPresent skips when undefined
|
|
181
|
-
throw new SafeDecompressError(
|
|
182
|
-
"safe-decompress/bad-arg",
|
|
183
|
-
"safeDecompress: maxOutputBytes must be a positive finite integer; got " +
|
|
184
|
-
numericBounds.shape(opts.maxOutputBytes));
|
|
185
|
-
}
|
|
174
|
+
// maxOutputBytes — required, positive finite integer.
|
|
175
|
+
numericBounds.requirePositiveFiniteInt(opts.maxOutputBytes,
|
|
176
|
+
"safeDecompress: maxOutputBytes", SafeDecompressError, "safe-decompress/bad-arg");
|
|
186
177
|
|
|
187
178
|
// Input shape
|
|
188
179
|
var buf;
|
package/lib/seeders.js
CHANGED
|
@@ -546,46 +546,40 @@ function create(opts) {
|
|
|
546
546
|
|
|
547
547
|
try {
|
|
548
548
|
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
appliedAt: nowIso, rerunnable: mod.rerunnable ? 1 : 0 })
|
|
571
|
-
.toSql();
|
|
572
|
-
}
|
|
573
|
-
var writeStmt = db.prepare(writeBuilt.sql);
|
|
574
|
-
writeStmt.run.apply(writeStmt, writeBuilt.params);
|
|
575
|
-
_runSql(db, "COMMIT");
|
|
576
|
-
} catch (e) {
|
|
577
|
-
try { _runSql(db, "ROLLBACK"); }
|
|
578
|
-
catch (rollbackErr) {
|
|
579
|
-
log.debug("rollback-failed", {
|
|
580
|
-
op: "seed-apply",
|
|
581
|
-
env: env,
|
|
582
|
-
name: name,
|
|
583
|
-
error: rollbackErr && rollbackErr.message,
|
|
584
|
-
});
|
|
585
|
-
}
|
|
586
|
-
throw e;
|
|
549
|
+
// Per-seed transaction: SQLite txns are sync, but the seed's
|
|
550
|
+
// run() may be async — runInTransactionAsync wraps BEGIN/COMMIT
|
|
551
|
+
// around the awaited body and rolls back this seed only on failure.
|
|
552
|
+
await dbSchema.runInTransactionAsync(db, async function () {
|
|
553
|
+
await mod.run(db, ctx);
|
|
554
|
+
var nowIso = new Date(clock()).toISOString();
|
|
555
|
+
var writeBuilt;
|
|
556
|
+
if (alreadyApplied && mod.rerunnable) {
|
|
557
|
+
writeBuilt = sql.update(_seedersTable(), _sqlOpts(db))
|
|
558
|
+
.set({ appliedAt: nowIso, description: mod.description || "",
|
|
559
|
+
rerunnable: mod.rerunnable ? 1 : 0 })
|
|
560
|
+
.where("env", env).where("name", name).toSql();
|
|
561
|
+
} else if (alreadyApplied && force) {
|
|
562
|
+
writeBuilt = sql.update(_seedersTable(), _sqlOpts(db))
|
|
563
|
+
.set({ appliedAt: nowIso, description: mod.description || "" })
|
|
564
|
+
.where("env", env).where("name", name).toSql();
|
|
565
|
+
} else {
|
|
566
|
+
writeBuilt = sql.insert(_seedersTable(), _sqlOpts(db))
|
|
567
|
+
.values({ env: env, name: name, description: mod.description || "",
|
|
568
|
+
appliedAt: nowIso, rerunnable: mod.rerunnable ? 1 : 0 })
|
|
569
|
+
.toSql();
|
|
587
570
|
}
|
|
588
|
-
|
|
571
|
+
var writeStmt = db.prepare(writeBuilt.sql);
|
|
572
|
+
writeStmt.run.apply(writeStmt, writeBuilt.params);
|
|
573
|
+
}, {
|
|
574
|
+
onRollbackFail: function (rollbackErr) {
|
|
575
|
+
log.debug("rollback-failed", {
|
|
576
|
+
op: "seed-apply",
|
|
577
|
+
env: env,
|
|
578
|
+
name: name,
|
|
579
|
+
error: rollbackErr && rollbackErr.message,
|
|
580
|
+
});
|
|
581
|
+
},
|
|
582
|
+
});
|
|
589
583
|
applied.push(name);
|
|
590
584
|
appliedSet.add(name);
|
|
591
585
|
|
package/lib/storage.js
CHANGED
|
@@ -425,7 +425,7 @@ async function getFileStream(key, sealedKey, opts) {
|
|
|
425
425
|
* @example
|
|
426
426
|
* b.storage.init({ backend: "local", uploadDir: "./data/uploads" });
|
|
427
427
|
* var saved = await b.storage.saveRaw(Buffer.from("public-bytes"), "logo.png");
|
|
428
|
-
* // → { storedPath: "logo.png", backend: "default" }
|
|
428
|
+
* // → { storedPath: "logo.png", backend: "default", versionId: null }
|
|
429
429
|
*/
|
|
430
430
|
async function saveRaw(buffer, key, opts) {
|
|
431
431
|
_requireInit();
|
|
@@ -443,7 +443,9 @@ async function saveRaw(buffer, key, opts) {
|
|
|
443
443
|
raw: true,
|
|
444
444
|
},
|
|
445
445
|
});
|
|
446
|
-
|
|
446
|
+
// versionId is non-null only on a versioning-enabled (S3 Object-Lock)
|
|
447
|
+
// backend; capture it to target the exact version for later erasure.
|
|
448
|
+
return { storedPath: key, backend: picked.backend.name, versionId: result.versionId || null };
|
|
447
449
|
}
|
|
448
450
|
|
|
449
451
|
/**
|
|
@@ -486,14 +488,24 @@ async function getRawBuffer(key, opts) {
|
|
|
486
488
|
* Remove `key` from the routed backend. Returns `true` when the
|
|
487
489
|
* object existed and was removed, `false` when it was already
|
|
488
490
|
* absent. Emits `system.storage.delete` with `{ backend, key,
|
|
489
|
-
* existed }` so the audit chain records GDPR right-to-erasure
|
|
491
|
+
* existed, versionId }` so the audit chain records GDPR right-to-erasure
|
|
490
492
|
* flows. The sealed encryption key the caller persisted alongside
|
|
491
493
|
* the row should be discarded by the caller after a successful
|
|
492
494
|
* delete — without the bytes, the key has no recovery value.
|
|
493
495
|
*
|
|
496
|
+
* On a versioning-enabled (S3 Object-Lock) backend an unversioned
|
|
497
|
+
* delete only writes a delete-marker — the data version survives. To
|
|
498
|
+
* erase a specific version pass `versionId` (from `saveRaw`'s return or
|
|
499
|
+
* `listVersions`); a version under an active retention is refused (the
|
|
500
|
+
* call throws), and `bypassGovernanceRetention` lifts a GOVERNANCE-mode
|
|
501
|
+
* retention for callers with the permission (COMPLIANCE stays immutable).
|
|
502
|
+
* `versionId` is S3/sigv4-only and is refused on other backends.
|
|
503
|
+
*
|
|
494
504
|
* @opts
|
|
495
505
|
* classification: string, // route to a backend serving this classification
|
|
496
506
|
* backend: string, // explicit backend by name
|
|
507
|
+
* versionId: string, // erase a specific object version (S3 Object-Lock)
|
|
508
|
+
* bypassGovernanceRetention: boolean, // lift GOVERNANCE retention (not COMPLIANCE)
|
|
497
509
|
*
|
|
498
510
|
* @example
|
|
499
511
|
* b.storage.init({ backend: "local", uploadDir: "./data/uploads" });
|
|
@@ -507,12 +519,16 @@ async function deleteFile(key, opts) {
|
|
|
507
519
|
_requireInit();
|
|
508
520
|
opts = opts || {};
|
|
509
521
|
var picked = _pickBackend(opts);
|
|
510
|
-
var result = await picked.backend.delete(key
|
|
522
|
+
var result = await picked.backend.delete(key, {
|
|
523
|
+
versionId: opts.versionId,
|
|
524
|
+
bypassGovernanceRetention: opts.bypassGovernanceRetention,
|
|
525
|
+
});
|
|
511
526
|
_emit("system.storage.delete", {
|
|
512
527
|
metadata: {
|
|
513
|
-
backend:
|
|
514
|
-
key:
|
|
515
|
-
existed:
|
|
528
|
+
backend: picked.backend.name,
|
|
529
|
+
key: key,
|
|
530
|
+
existed: result,
|
|
531
|
+
versionId: opts.versionId || null,
|
|
516
532
|
},
|
|
517
533
|
});
|
|
518
534
|
return result;
|
|
@@ -556,6 +572,53 @@ async function exists(key, opts) {
|
|
|
556
572
|
}
|
|
557
573
|
}
|
|
558
574
|
|
|
575
|
+
/**
|
|
576
|
+
* @primitive b.storage.listVersions
|
|
577
|
+
* @signature b.storage.listVersions(prefix, opts?)
|
|
578
|
+
* @since 0.15.10
|
|
579
|
+
* @status stable
|
|
580
|
+
* @compliance gdpr, sox-404, soc2
|
|
581
|
+
* @related b.storage.deleteFile, b.storage.saveRaw, b.worm.create
|
|
582
|
+
*
|
|
583
|
+
* Enumerate every object VERSION and delete-marker under `prefix` on a
|
|
584
|
+
* versioning-enabled (S3 Object-Lock) backend. Plain reads only see the
|
|
585
|
+
* current version; right-to-erasure / crypto-shred on an Object-Lock
|
|
586
|
+
* bucket must target prior versions by `versionId`, which only this call
|
|
587
|
+
* surfaces. Each item is `{ key, versionId, isLatest, deleteMarker, size,
|
|
588
|
+
* lastModified, etag }`; `deleteMarker: true` rows are tombstones with no
|
|
589
|
+
* data. Pair with `deleteFile(key, { versionId })` to erase a version.
|
|
590
|
+
*
|
|
591
|
+
* Versioning is an S3/sigv4 feature — a backend without a version surface
|
|
592
|
+
* (filesystem, and the current Azure/GCS adapters) throws
|
|
593
|
+
* `VERSIONS_UNSUPPORTED` rather than silently returning the current view,
|
|
594
|
+
* so an erasure workflow can never mistake a single-version backend for a
|
|
595
|
+
* fully-enumerated one.
|
|
596
|
+
*
|
|
597
|
+
* @opts
|
|
598
|
+
* classification: string, // route to a backend serving this classification
|
|
599
|
+
* backend: string, // explicit backend by name
|
|
600
|
+
* maxResults: number, // page size
|
|
601
|
+
* keyMarker: string, // pagination cursor (from a prior page)
|
|
602
|
+
* versionIdMarker: string, // pagination cursor (from a prior page)
|
|
603
|
+
*
|
|
604
|
+
* @example
|
|
605
|
+
* var page = await b.storage.listVersions("filings/2026/");
|
|
606
|
+
* for (var v of page.items) {
|
|
607
|
+
* if (!v.isLatest) await b.storage.deleteFile(v.key, { versionId: v.versionId });
|
|
608
|
+
* }
|
|
609
|
+
*/
|
|
610
|
+
async function listVersions(prefix, opts) {
|
|
611
|
+
_requireInit();
|
|
612
|
+
opts = opts || {};
|
|
613
|
+
var picked = _pickBackend(opts);
|
|
614
|
+
if (typeof picked.backend.listVersions !== "function") {
|
|
615
|
+
throw _err("VERSIONS_UNSUPPORTED",
|
|
616
|
+
"listVersions: backend '" + picked.backend.name + "' has no version surface " +
|
|
617
|
+
"(S3/sigv4 only). A filesystem / single-version backend cannot enumerate versions.", true);
|
|
618
|
+
}
|
|
619
|
+
return picked.backend.listVersions(prefix, opts);
|
|
620
|
+
}
|
|
621
|
+
|
|
559
622
|
/**
|
|
560
623
|
* @primitive b.storage.listBackends
|
|
561
624
|
* @signature b.storage.listBackends()
|
|
@@ -1266,6 +1329,7 @@ module.exports = {
|
|
|
1266
1329
|
getRawBuffer: getRawBuffer,
|
|
1267
1330
|
deleteFile: deleteFile,
|
|
1268
1331
|
exists: exists,
|
|
1332
|
+
listVersions: listVersions,
|
|
1269
1333
|
presignedUploadUrl: presignedUploadUrl,
|
|
1270
1334
|
presignedDownloadUrl: presignedDownloadUrl,
|
|
1271
1335
|
presignedUploadPolicy: presignedUploadPolicy,
|
package/lib/vault/rotate.js
CHANGED
|
@@ -544,12 +544,6 @@ function _walkAndReSeal(node, oldKeys, newKeys) {
|
|
|
544
544
|
return { value: node, changed: false };
|
|
545
545
|
}
|
|
546
546
|
|
|
547
|
-
// Transaction-control statements only (BEGIN / COMMIT / ROLLBACK) - fixed
|
|
548
|
-
// keywords, no identifier / value, so they stay verbatim rather than route
|
|
549
|
-
// through b.sql (the builder has no transaction-control verb). The param is
|
|
550
|
-
// named `stmtText` so it does not shadow the module-level `sql` builder.
|
|
551
|
-
function _runStmt(db, stmtText) { db.prepare(stmtText).run(); }
|
|
552
|
-
|
|
553
547
|
function _rotateColumn(db, table, column, schema, roots, batchSize, progress) {
|
|
554
548
|
// Every statement composes through b.sql (sqlite dialect, quoteName so
|
|
555
549
|
// the concrete handle's table is quoted, not left bare for a cluster
|
|
@@ -655,8 +649,9 @@ function _rotateOverflow(db, table, oldKeys, newKeys, batchSize, progress, warni
|
|
|
655
649
|
var rows = sel.all(lastId);
|
|
656
650
|
if (rows.length === 0) break;
|
|
657
651
|
|
|
658
|
-
|
|
659
|
-
|
|
652
|
+
// One transaction per page via the shared wrapper — the rotate worker
|
|
653
|
+
// no longer hand-rolls the BEGIN/COMMIT/ROLLBACK skeleton.
|
|
654
|
+
dbSchema.runInTransaction(db, function () {
|
|
660
655
|
for (var i = 0; i < rows.length; i++) {
|
|
661
656
|
var row = rows[i];
|
|
662
657
|
var doc;
|
|
@@ -671,11 +666,7 @@ function _rotateOverflow(db, table, oldKeys, newKeys, batchSize, progress, warni
|
|
|
671
666
|
var rv = _walkAndReSeal(doc, oldKeys, newKeys);
|
|
672
667
|
if (rv.changed) upd.run(JSON.stringify(rv.value), row._id);
|
|
673
668
|
}
|
|
674
|
-
|
|
675
|
-
} catch (e) {
|
|
676
|
-
_runStmt(db, "ROLLBACK");
|
|
677
|
-
throw e;
|
|
678
|
-
}
|
|
669
|
+
});
|
|
679
670
|
processed += rows.length;
|
|
680
671
|
lastId = rows[rows.length - 1]._id;
|
|
681
672
|
_emit(progress, { phase: "rotate_overflow", table: table, rowsProcessed: processed, rowsTotal: total });
|
package/package.json
CHANGED
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:
|
|
5
|
+
"serialNumber": "urn:uuid:67b3105d-f49c-44bd-bf4e-afbb994d35e2",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-06-
|
|
8
|
+
"timestamp": "2026-06-14T03:47:28.687Z",
|
|
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.15.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.15.10",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.15.
|
|
25
|
+
"version": "0.15.10",
|
|
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.15.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.15.10",
|
|
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.15.
|
|
57
|
+
"ref": "@blamejs/core@0.15.10",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|