Self Update
Framework / vendored-deps integrity check plus version pinning — refuses to install a new build when the asset's detached signature does not verify against the operator-supplied public key, or when the vendored SHA the new build would ship does not match the manifest the operator pinned.
The lifecycle is four steps, each shippable as its own audit event:
1. b.selfUpdate.poll({ releasesUrl, currentVersion }) fetches a releases feed (GitHub /releases shape or any feed exposing { tag_name, assets: [{ name, browser_download_url }] }), compares semver-shaped tags, and reports whether a newer tag is available along with the matching asset and signature URLs. 2. The operator downloads the asset bytes plus the detached signature via b.httpClient.downloadStream — the framework downloader handles SSRF guard, TLS posture, hash-while- streaming, and atomic rename of the temp file. 3. b.selfUpdate.verify({ assetPath, signaturePath, pubkeyPem }) verifies the detached signature over the asset bytes via b.crypto.verify (auto-detects ML-DSA-87 / Ed25519 / ECDSA P-384 from the supplied PEM) and reports the bytes' hash for SBOM correlation. A mismatched signature throws and the swap never runs. 4. b.selfUpdate.swap({ from, to, backupTo, expectedHash }) performs the atomic install: re-hash from and refuse unless it matches expectedHash (the hash step 3 returned — binding the installed bytes to the verified bytes), copy the current to to backupTo, rename from → to, fsync both directories. Cross-device renames fall back to copy + unlink. Any failure rolls back from the backup. b.selfUpdate.rollback({ to, backupTo }) restores the backup post-swap when a healthcheck reports the new binary is bad.
Outbound HTTP routes through b.httpClient.request so SSRF, allowedHosts, and TLS posture defaults apply uniformly. Atomic file ops route through b.atomicFile (write + fsync + rename). Every step emits an audit event under selfupdate.* with outcome: "denied" on failure, so a tampered release surfaces in the audit log immediately even when the operator's own healthcheck missed it.
b.selfUpdate.standaloneVerifier.verify(assetPath, signaturePath, pubkeyPem, opts?) #
{
maxAssetBytes: number, // asset-size ceiling (default 2 GiB); refuse a larger asset before hashing
extraDigests: array, // additional node:crypto digest names to compute in the same stream
}
Verify a signed release asset using only node:crypto + node:fs (no framework imports). For install-pipeline contexts where the framework itself is not yet installed.
Streams the asset in 64 KiB chunks through SHA-256 + SHA-3-512 + the signature verifier in parallel — single allocation peak (one buffer sized to fstat(asset).size for Ed25519 / ML-DSA-87, ECDSA P-384 needs no buffer because createVerify is incremental). The signature commits to a SHA3-512 digest and the ECDSA encoding is dispatched by structure (DER SEQUENCE vs raw IEEE-P1363), so both encodings of a SHA3-512-signed P-384 sidecar verify.
Returns { ok, sha3_512, sha256, alg, bytes, digests } on success; throws on unrecognized pubkey shape, missing files, or signature mismatch. alg is one of "ecdsa-p384", "ed25519", "ml-dsa-87" (auto-detected from the pubkey PEM). bytes is the verified asset byte count; digests maps each requested opts.extraDigests name to its hex digest (computed in the same single pass).
var verifier = require("./standalone-verifier");
var pubkey = require("./release-pubkey");
var result = verifier.verify(
"/tmp/blamejs-sea-bundle",
"/tmp/blamejs-sea-bundle.sig",
pubkey,
);
if (!result.ok) process.exit(1);
process.stdout.write("verified " + result.alg + " sha3-512=" + result.sha3_512 + "\n");
b.selfUpdate.compareTags(a, b) #
Compare two release tags / version strings per SemVer 2.0.0 §11. Returns -1 if a < b, +1 if a > b, 0 if equal. Strips a leading v / V, then:
1. Splits each tag into (numericVersion, pre-release, build). Build metadata is ignored per §10 (does NOT participate in precedence). 2. Compares the numeric version (major.minor.patch) numerically. 3. If equal, applies §11 pre-release rules: a version with NO pre-release outranks any version WITH one. Two pre-release strings split on . and compare dot-by-dot — numeric identifiers compare as numbers, alphanumeric as ASCII, numeric sorts lower than alphanumeric, and a longer pre-release with a common prefix is higher.
Missing numeric components on either side are treated as "0" so "1.0" and "1.0.0" compare equal.
Hardening (v0.9.58) — pre-v0.9.58 the pre-release segment fell back to lexicographic comparison, which silently misordered "1.0.0-alpha.10" (the strict-§11 LARGER pre-release) and "1.0.0-alpha.9": as strings "10" < "9" so alpha.10 < alpha.9, and a downstream consumer polling for the next release would silently downgrade. This implementation now follows §11 strictly.
b.selfUpdate.compareTags("v0.9.46", "v0.9.47"); // → -1
b.selfUpdate.compareTags("v0.9.47", "0.9.47"); // → 0
b.selfUpdate.compareTags("1.10.0", "1.9.0"); // → +1 (numeric)
b.selfUpdate.compareTags("1.0.0", "1.0.0-rc.1"); // → +1 (release > pre-release)
b.selfUpdate.compareTags("1.0.0-alpha.10", "1.0.0-alpha.9"); // → +1 (numeric pre-release, §11)
b.selfUpdate.compareTags("1.0.0+build1", "1.0.0+build2"); // → 0 (build metadata ignored)
b.selfUpdate.poll(opts) #
{
releasesUrl: string, // required — feed URL
currentVersion: string, // required — e.g. "0.8.43" or "v0.8.43"
assetPattern: RegExp, // match for the runtime asset (default well-known shapes)
signaturePattern: RegExp, // match for the detached signature (default .sig/.asc)
allowedProtocols: array, // default ["https:"]
allowedHosts: array, // routed into httpClient SSRF gate
allowInternal: boolean, // routed into httpClient SSRF gate
maxBytes: number, // response cap (default 8 MiB)
timeoutMs: number, // request timeout (default 15s)
headers: object, // additional request headers
etag: string, // last-seen etag for If-None-Match
// (etags are RFC 9110 §13.1.1
// per-resource; an etag captured for
// releasesUrl=A is meaningless against
// releasesUrl=B. Operators rotating
// releasesUrl MUST clear opts.etag at
// the same time; reusing a stale etag
// makes the new endpoint look like a
// 304 "no update" forever.)
}
Fetch a releases feed and report whether a newer tag is available. Tags are compared semver-style with a leading v stripped. When opts.etag is supplied an If-None-Match header makes a 304 a fast "no update" path. The match against asset and signature URLs uses opts.assetPattern and opts.signaturePattern (RegExp or substring) with conservative fallbacks. Throws SelfUpdateError on a non-2xx upstream, malformed JSON, or unexpected shape.
Each matched asset / signature is reported as { name, url, size, digest }. digest carries the release API's published assets[].digest (e.g. "sha256:) verbatim when the upstream supplies it, or null when absent — a consumer can use it for a defense-in-depth in-flight integrity check of the downloaded bytes alongside the detached-signature verify.
try {
await b.selfUpdate.poll({
releasesUrl: "https://updates.invalid.localhost/releases.json",
currentVersion: "0.8.43",
timeoutMs: 1,
});
} catch (e) {
e.code; // → "selfupdate/poll-failed"
}
b.selfUpdate.verify(opts) #
{
assetPath: string, // required — path to the downloaded asset
signaturePath: string, // required — path to the detached signature
pubkeyPem: string, // required — PEM-encoded public key
hashAlgo: string, // sha3-512 | sha-256 | sha-512 | shake256 (default sha3-512)
maxBytes: number, // asset read cap (default 1 GiB)
}
Verify a detached signature over the asset bytes. The signature algorithm is auto-detected from opts.pubkeyPem (ML-DSA-87 / Ed25519 / ECDSA P-384). Verification routes through the framework's own standaloneVerifier, which streams the asset (no whole-file buffer), commits to a SHA3-512 digest, and dispatches the ECDSA signature encoding by structure (DER SEQUENCE vs raw IEEE-P1363) — so a release sidecar signed SHA3-512-then-sign with either encoding verifies, and the accept set is identical to b.selfUpdate.standaloneVerifier.verify (no verifier divergence between the install-pipeline and installed paths). Reports the asset's hash alongside the verified flag for SBOM / audit correlation; the supported digest algorithms are sha3-512 (default), sha-256, sha-512, and shake256. Throws SelfUpdateError on a missing file, a verify-time exception, or a signature that does not verify.
try {
await b.selfUpdate.verify({
assetPath: "/tmp/blamejs-doc-asset-not-present.tar.gz",
signaturePath: "/tmp/blamejs-doc-asset-not-present.sig",
pubkeyPem: "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEA\n-----END PUBLIC KEY-----\n",
});
} catch (e) {
e.code; // → "selfupdate/read-failed"
}
b.selfUpdate.swap(opts) #
{
from: string, // required — newly-installed asset path
to: string, // required — target install path
backupTo: string, // required — backup path for the existing `to`
expectedHash: string, // required — the hash selfUpdate.verify returned
hashAlgo: string, // sha3-512 (default) | sha-256 | sha-512 | shake256
maxBytes: number, // from-bytes re-hash cap (default 1 GiB) — set to
// the same value passed to selfUpdate.verify
}
Atomic install: re-hash from and refuse unless it matches expectedHash (the hash selfUpdate.verify returned — this binds the installed bytes to the signature-verified bytes and closes the verify→swap window), MOVE the existing to aside to backupTo with a rename (which succeeds on a locked, running image where an in-place replace is refused, and IS the backup), write the verified bytes to the now-free to, then fsync both directories. backupTo must be on the same volume as to; a cross-volume backup (EXDEV) falls back to copy + replace. On an install-write failure after the move-aside, the backup is restored over to. Throws SelfUpdateError on a missing from, an expectedHash mismatch, a move-aside/backup failure, or an install-write failure.
var v = await b.selfUpdate.verify({ assetPath, signaturePath, pubkeyPem });
try {
await b.selfUpdate.swap({
from: "/tmp/blamejs-doc-missing.bin",
to: "/tmp/blamejs-doc-target.bin",
backupTo: "/tmp/blamejs-doc-backup.bin",
expectedHash: v.hash,
});
} catch (e) {
e.code; // → "selfupdate/missing-from"
}
b.selfUpdate.rollback(opts) #
{
to: string, // required — target path to restore
backupTo: string, // required — source backup path
maxBytes: number, // backup read cap (default 1 GiB)
}
Restore backupTo → to. When a bad-binary to is present it is first MOVED ASIDE with a rename — which frees the path even for a locked, running Windows image (Windows refuses an in-place replace of a mapped executable but allows the move) — so the restore is a CREATE at the freed path, not a replace of a locked file; the quarantined bad binary is then removed (best-effort). The backup read is capped at maxBytes (default 1 GiB) so a large prior binary (a Node SEA is 100+ MiB) restores rather than being refused at atomicFile's 64 MiB copy default. Operators run rollback when a post-swap healthcheck reports the new binary is bad. Throws SelfUpdateError when the backup file is missing, the move-aside fails, or the copy fails; a copy failure after the move-aside restores the quarantined image back over to so a failed rollback never leaves the target absent.
try {
await b.selfUpdate.rollback({
to: "/tmp/blamejs-doc-target.bin",
backupTo: "/tmp/blamejs-doc-missing-backup.bin",
});
} catch (e) {
e.code; // → "selfupdate/missing-backup"
}
b.selfUpdate.beginProbation(opts) #
{
to: string, // required — installed binary path (the probationary target)
backupTo: string, // required — known-good backup restored on a failed probation
expectedHash: string, // required — hash of the installed bytes (selfUpdate.verify/swap's hash)
windowMs: number, // probation window in ms; default 10 minutes
hashAlgo: string, // sha3-512 (default) | sha-256 | sha-512 | shake256
markerPath: string, // override marker path (default: `to` + ".blamejs-probation.json")
}
Arm a bounded post-install probation for a freshly-swapped binary. Writes an atomic marker (to + .blamejs-probation.json, or opts.markerPath) recording the target, the known-good backup, the installed bytes' hash, and an expiresAt = now + windowMs. The new binary calls confirmHealthy once it is up and healthy (clearing the marker); if the window elapses with no such confirmation, the next evaluateOnBoot rolls the backup back over the target.
The marker is written via b.atomicFile.writeJson (temp + fsync + rename), so a process that dies mid-write leaves either the previous complete marker or none — never a half-written record a boot could misread.
var v = await b.selfUpdate.verify({ assetPath, signaturePath, pubkeyPem });
await b.selfUpdate.swap({ from, to, backupTo, expectedHash: v.hash });
var p = await b.selfUpdate.beginProbation({ to, backupTo, expectedHash: v.hash });
p.expiresAt; // → epoch ms the probation window closes
b.selfUpdate.confirmHealthy(opts) #
{
to: string, // required — the probationary target (locates the marker)
markerPath: string, // override marker path (must match beginProbation)
}
Clear the probation marker — the explicit clean / healthy signal. The new binary calls this once its own startup health checks pass (and an operator's graceful-shutdown hook may call it too, marking a clean stop). With the marker gone, a later evaluateOnBoot finds no probation and keeps the binary. Absence of this signal at the next boot past the window is what evaluateOnBoot reads as a failed probation. Idempotent: a missing marker returns cleared: false.
// in the new binary, after startup health checks pass:
var r = await b.selfUpdate.confirmHealthy({ to: "/opt/app/app.bin" });
r.cleared; // → true (marker removed)
b.selfUpdate.evaluateOnBoot(opts) #
{
to: string, // required — the probationary target
backupTo: string, // override the marker's backup path
markerPath: string, // override marker path (must match beginProbation)
now: number, // override the wall clock (epoch ms) for deterministic evaluation
}
Decide, at process start, whether a probationary install should be kept or rolled back. Returns { action: "keep" | "rollback", reason }. No marker, or a marker still inside its window, keeps (a clean stop / restart within the window is not a crash). A marker past its window with no confirmHealthy means the binary never became healthy → the known-good backup is restored over the target and the marker cleared.
Before restoring, it RE-VERIFIES: the bytes currently at to must still hash to the marker's expectedHash (so a marker left by a swap that FAILED — where the probationary binary was never installed — never triggers a phantom rollback), and the backup must exist (otherwise it keeps and defers to the operator rather than destroying the only present binary). A corrupt / malformed marker keeps, never rolls back.
// at process start, before serving traffic:
var d = await b.selfUpdate.evaluateOnBoot({ to: "/opt/app/app.bin" });
if (d.action === "rollback") process.exit(1); // restart onto the restored binary
Last updated 2026-08-08T16:39:49.652Z by seeder.