@blamejs/blamejs-shop 0.2.26 → 0.2.27

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,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.2.x
10
10
 
11
+ - v0.2.27 (2026-05-29) — **Upload product images directly from the admin console.** The admin product media manager can now take an image file uploaded straight from your device, in addition to attaching one by URL. On the product detail screen, pick a PNG, JPEG, WebP, GIF, AVIF, or SVG and it streams to object storage and attaches to the product (or a specific variant) in one step. Every upload is validated by both its declared content type and its actual magic bytes — a mismatched or disguised file is refused — and capped at 10 MiB. The upload surface is only present when object storage is configured, so a store without it sees no change. **Added:** *Direct image-file upload in the admin product media manager* — A file picker on the admin product detail screen accepts a local image (`multipart/form-data`) alongside the existing attach-by-URL field. The file is content-type- and magic-byte-validated (png/jpeg/webp/gif/avif/svg), size-capped at 10 MiB, streamed to the object-storage bridge, and attached to the product or variant. JSON API: `POST /admin/products/:id/media/upload-file` and `POST /admin/media/upload-file`. The routes mount only when the object-storage bridge is configured.
12
+
11
13
  - v0.2.26 (2026-05-29) — **Update the vendored blamejs runtime to v0.13.44.** The bundled blamejs runtime moves from v0.13.0 to v0.13.44, carrying a run of upstream security and reliability fixes through the crypto, storage, and middleware layers the shop is built on, with no API or configuration changes. Highlights: DNSSEC validation now bounds the work spent on colliding keys and signatures (KeyTrap / NSEC3 resource-exhaustion defense); a sealed database column that fails to unseal now returns null instead of risking forged or cross-row ciphertext; the encrypted database gains a temporary-storage free-space guard and a shutdown watchdog that preserves the final flush on exit; S/MIME chain verification binds the leaf certificate to the verifying signer key; archive extraction refuses Windows reserved and alternate-data-stream path names; and the replay-nonce store is memory-capped and fails closed under flood. **Changed:** *Database reliability under low temporary storage* — The encrypted database now refuses growth-writes with a clear error when its temporary storage drops below a safety headroom (rather than risking corruption), and a shutdown watchdog preserves the final database flush if a shutdown phase hangs. Both harden the container restart path. **Security:** *Bundled blamejs runtime updated to v0.13.44* — Pulls in the upstream security fixes accumulated since v0.13.0: DNSSEC KeyTrap / NSEC3 work caps, sealed-column null-on-unseal-failure (no forged or cross-row ciphertext), S/MIME leaf-to-signer binding, archive-extraction rejection of Windows reserved / alternate-data-stream names, static-file sibling-prefix path containment, and a memory-capped, fail-closed replay-nonce store. No API or configuration changes are required.
12
14
 
13
15
  - v0.2.25 (2026-05-29) — **Harden session and admin cookies with __Host- / __Secure- name prefixes.** The session, login, and admin cookies now carry the browser-enforced `__Host-` / `__Secure-` name prefixes when served over HTTPS. These prefixes bind a cookie to a secure, same-origin context — a browser only accepts a `__Host-` cookie that was set with `Secure`, `Path=/`, and no `Domain` attribute — which closes off cookie-injection and session-fixation paths from a related origin or a non-secure context. The secure-versus-plain decision follows the forwarded request protocol, so a deployment behind a TLS-terminating proxy (the production setup) gets the hardened prefixes while local development and tests over HTTP keep the bare names and keep working. Returning visitors are not signed out by the upgrade: the cookie reader resolves both the prefixed and the legacy name during the transition. **Security:** *`__Host-` on the session and login cookies* — Over HTTPS the storefront session and login cookies are issued as `__Host-shop_sid` and `__Host-shop_auth` with `Secure`, `Path=/`, and no `Domain` — the attribute set a browser requires before it will store a `__Host-` cookie, so the session cookie can no longer be planted or overwritten by a related origin or a non-secure context. · *`__Secure-` on the admin cookie* — The admin session cookie is scoped to `Path=/admin`, so it takes the `__Secure-` prefix (which requires `Secure`) rather than `__Host-` (which mandates `Path=/`). The edge cache-skip check and the audience bucketing were updated to recognize the prefixed names so a signed-in response is never served from the shared edge cache.
package/lib/admin.js CHANGED
@@ -90,6 +90,28 @@ function _extFromContentType(ct) {
90
90
  return _CT_TO_EXT[ct.toLowerCase()] || "";
91
91
  }
92
92
 
93
+ // Image content-types the direct-file upload path accepts — a strict
94
+ // subset of _CT_TO_EXT. The file picker is for product imagery, so
95
+ // video / pdf are not offered here (the attach-by-key + upload-from-URL
96
+ // routes still reach the wider _CT_TO_EXT set for those). svg stays on
97
+ // the list to match the upload-from-URL flow, but it can't be
98
+ // magic-byte sniffed (it's text), so the mismatch cross-check skips it.
99
+ var _UPLOAD_IMAGE_CT = {
100
+ "image/png": "png",
101
+ "image/jpeg": "jpg",
102
+ "image/jpg": "jpg",
103
+ "image/webp": "webp",
104
+ "image/gif": "gif",
105
+ "image/avif": "avif",
106
+ "image/svg+xml": "svg",
107
+ };
108
+ // Per-file cap on a direct upload, sized for product photography. The
109
+ // body-parser multipart sub-parser enforces its own global fileSize cap
110
+ // upstream; this is the route-level cap so the limit is explicit at the
111
+ // media surface and the rejection names the media budget rather than a
112
+ // generic 413.
113
+ var _UPLOAD_MAX_BYTES = b.constants.BYTES.mib(10);
114
+
93
115
  // ---- shared helpers -----------------------------------------------------
94
116
 
95
117
  function _parseEpochMs(str, label) {
@@ -1007,14 +1029,24 @@ function mount(router, deps) {
1007
1029
  if (buf.length === 0) {
1008
1030
  return { status: 422, code: "source-empty", detail: "source_url returned an empty body" };
1009
1031
  }
1010
- // Generate the R2 key. The extension is inferred from the
1011
- // declared content-type so the operator can preview the asset
1012
- // without a content-disposition round-trip.
1032
+ return await _storeAndAttach(buf, body.content_type, body);
1033
+ }
1034
+
1035
+ // Store bytes to R2 and attach the media row — the tail shared by the
1036
+ // upload-from-URL flow and the direct-file upload flow. Generates the
1037
+ // R2 key (extension inferred from the declared content-type so the
1038
+ // operator can preview without a content-disposition round-trip),
1039
+ // pushes through the same r2_bridge put path, then records the catalog
1040
+ // row. Returns `{ status, code, detail }` on an operational failure or
1041
+ // `{ rec }` on success. Never throws on an R2 / attach failure — the
1042
+ // caller renders the problem.
1043
+ async function _storeAndAttach(buf, contentType, body) {
1044
+ var declared = String(contentType).split(";")[0].trim().toLowerCase();
1013
1045
  var ext = _extFromContentType(declared);
1014
1046
  var id = b.uuid.v7();
1015
1047
  var key = "media/" + id + (ext ? "." + ext : "");
1016
1048
  try {
1017
- await r2.put(key, buf, body.content_type);
1049
+ await r2.put(key, buf, contentType);
1018
1050
  } catch (e) {
1019
1051
  return { status: 502, code: "r2-upload-failed", detail: (e && e.message) || String(e) };
1020
1052
  }
@@ -1024,7 +1056,7 @@ function mount(router, deps) {
1024
1056
  product_id: body.product_id || undefined,
1025
1057
  variant_id: body.variant_id || undefined,
1026
1058
  r2_key: key,
1027
- content_type: body.content_type,
1059
+ content_type: contentType,
1028
1060
  width: body.width || 0,
1029
1061
  height: body.height || 0,
1030
1062
  position: body.position || 0,
@@ -1042,6 +1074,91 @@ function mount(router, deps) {
1042
1074
  return { rec: Object.assign({}, m, { asset_url: assetPrefix + key }) };
1043
1075
  }
1044
1076
 
1077
+ // Direct-file upload: validate + store an image picked from the
1078
+ // operator's device. The framework's multipart body-parser has already
1079
+ // streamed the part to a tmp file (req.files[N] = { field, filename,
1080
+ // mimeType, path, size, hash }); this reads it back, checks the
1081
+ // declared MIME against the image allowlist, enforces the media-budget
1082
+ // size cap, cross-checks the magic bytes against the declared type
1083
+ // (defense against an image/png label on a non-image body), then hands
1084
+ // off to _storeAndAttach. Request-shape reader: returns `{ status,
1085
+ // code, detail }` for any bad/oversized/disallowed file rather than
1086
+ // throwing — the upload must never crash the request that carries it.
1087
+ async function _performFileUpload(file, body) {
1088
+ body = body || {};
1089
+ if (!body.product_id && !body.variant_id) {
1090
+ return { status: 400, code: "missing-target", detail: "one of product_id / variant_id required" };
1091
+ }
1092
+ if (!file || typeof file.path !== "string" || !file.path.length) {
1093
+ return { status: 400, code: "no-file", detail: "no file part received (expected a multipart `file` field)" };
1094
+ }
1095
+ var declaredCT = String(file.mimeType || "").split(";")[0].trim().toLowerCase();
1096
+ if (!_UPLOAD_IMAGE_CT[declaredCT]) {
1097
+ return { status: 415, code: "unsupported-type",
1098
+ detail: "`" + (declaredCT || "(none)") + "` is not an accepted image type " +
1099
+ "(png, jpeg, webp, gif, avif, svg)" };
1100
+ }
1101
+ // The multipart parser caps file size globally, but the media route
1102
+ // pins its own budget so the limit is explicit here and the message
1103
+ // names the media cap. file.size is the streamed byte count.
1104
+ if (typeof file.size === "number" && file.size > _UPLOAD_MAX_BYTES) {
1105
+ return { status: 413, code: "file-too-large",
1106
+ detail: "file is " + file.size + " bytes, exceeds the " + _UPLOAD_MAX_BYTES + "-byte media upload cap" };
1107
+ }
1108
+ // Read the streamed tmp file back through the framework's atomic
1109
+ // reader with the media cap as maxBytes — this re-checks the on-disk
1110
+ // byte count against the budget (the parser's own cap is global) and
1111
+ // catches a size header that under-reported the streamed bytes,
1112
+ // throwing `atomic-file/too-large` rather than buffering past the cap.
1113
+ var buf;
1114
+ try {
1115
+ buf = b.atomicFile.readSync(file.path, { maxBytes: _UPLOAD_MAX_BYTES });
1116
+ } catch (e) {
1117
+ if (e && e.code === "atomic-file/too-large") {
1118
+ return { status: 413, code: "file-too-large",
1119
+ detail: "file exceeds the " + _UPLOAD_MAX_BYTES + "-byte media upload cap" };
1120
+ }
1121
+ return { status: 500, code: "tmp-read-failed", detail: (e && e.message) || String(e) };
1122
+ }
1123
+ if (buf.length === 0) {
1124
+ return { status: 422, code: "file-empty", detail: "uploaded file is empty (0 bytes)" };
1125
+ }
1126
+ // Magic-byte cross-check: refuse a body whose sniffed type doesn't
1127
+ // match the declared image type. svg is text (no magic bytes) so
1128
+ // b.fileType.detect returns null — skip the cross-check for it and
1129
+ // trust the declared type, same as the upload-from-URL flow.
1130
+ if (declaredCT !== "image/svg+xml") {
1131
+ var sniffed = b.fileType.detect(buf);
1132
+ var sniffedMime = sniffed && sniffed.mime;
1133
+ // jpg/jpeg are synonyms; normalize both sides to one token.
1134
+ var declNorm = declaredCT === "image/jpg" ? "image/jpeg" : declaredCT;
1135
+ var sniffNorm = sniffedMime === "image/jpg" ? "image/jpeg" : sniffedMime;
1136
+ if (!sniffNorm) {
1137
+ return { status: 422, code: "unrecognized-bytes",
1138
+ detail: "could not classify the file's bytes as a known image format" };
1139
+ }
1140
+ if (sniffNorm !== declNorm) {
1141
+ return { status: 422, code: "content-type-mismatch",
1142
+ detail: "file bytes sniff as `" + sniffNorm + "` but the part declared `" + declaredCT + "`" };
1143
+ }
1144
+ }
1145
+ return await _storeAndAttach(buf, declaredCT, body);
1146
+ }
1147
+
1148
+ // Pull the first uploaded file out of req.files. The multipart parser
1149
+ // exposes every accepted file part as req.files[] = { field, filename,
1150
+ // mimeType, path, size, hash }; the media form names its part `file`,
1151
+ // but accept any single file part so an API caller using a different
1152
+ // field name still works.
1153
+ function _firstUploadFile(req) {
1154
+ var files = (req && Array.isArray(req.files)) ? req.files : [];
1155
+ if (!files.length) return null;
1156
+ for (var i = 0; i < files.length; i++) {
1157
+ if (files[i] && files[i].field === "file") return files[i];
1158
+ }
1159
+ return files[0];
1160
+ }
1161
+
1045
1162
  router.post("/admin/media/upload", W("media.upload", async function (req, res) {
1046
1163
  var out = await _performMediaUpload(req.body || {});
1047
1164
  if (out.rec) { _json(res, 201, out.rec); return out.rec; }
@@ -1070,6 +1187,33 @@ function mount(router, deps) {
1070
1187
  _redirect(res, "/admin/products/" + enc + "?saved=1");
1071
1188
  },
1072
1189
  ));
1190
+
1191
+ // Direct-file upload (multipart/form-data). The JSON API route takes
1192
+ // product_id / variant_id from the form fields; the browser alias
1193
+ // scopes it to the product in the path and PRGs back to the detail.
1194
+ router.post("/admin/media/upload-file", W("media.upload", async function (req, res) {
1195
+ var out = await _performFileUpload(_firstUploadFile(req), req.body || {});
1196
+ if (out.rec) { _json(res, 201, out.rec); return out.rec; }
1197
+ return _problem(res, out.status, out.code, out.detail);
1198
+ }));
1199
+
1200
+ router.post("/admin/products/:id/media/upload-file", _pageOrApi(false,
1201
+ W("media.upload", async function (req, res) {
1202
+ var body = Object.assign({}, req.body || {}, { product_id: req.params.id });
1203
+ var out = await _performFileUpload(_firstUploadFile(req), body);
1204
+ if (out.rec) { _json(res, 201, out.rec); return out.rec; }
1205
+ return _problem(res, out.status, out.code, out.detail);
1206
+ }),
1207
+ async function (req, res) {
1208
+ var id = req.params.id;
1209
+ var enc = encodeURIComponent(id);
1210
+ var body = Object.assign({}, req.body || {}, { product_id: id });
1211
+ var out = await _performFileUpload(_firstUploadFile(req), body);
1212
+ if (!out.rec) return _redirect(res, "/admin/products/" + enc + "?err=1");
1213
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".media.upload", outcome: "success", metadata: { id: id } });
1214
+ _redirect(res, "/admin/products/" + enc + "?saved=1");
1215
+ },
1216
+ ));
1073
1217
  }
1074
1218
 
1075
1219
  router.delete("/admin/media/:id", W("media.delete", async function (req, res) {
@@ -6573,6 +6717,21 @@ function renderAdminProduct(opts) {
6573
6717
  "</form>" +
6574
6718
  "</div>";
6575
6719
 
6720
+ var fileUploadForm = uploadWired
6721
+ ? "<div class=\"panel mt-1 mw-40\">" +
6722
+ "<h3 class=\"subhead\">Upload an image from your device</h3>" +
6723
+ "<p class=\"meta\">Pick a file (PNG, JPEG, WebP, GIF, AVIF, or SVG). It's stored in your bucket and attached to this product in one step.</p>" +
6724
+ "<form method=\"post\" enctype=\"multipart/form-data\" action=\"/admin/products/" + pid + "/media/upload-file\">" +
6725
+ "<label class=\"form-field\"><span>Image file</span>" +
6726
+ "<input type=\"file\" name=\"file\" accept=\"image/png,image/jpeg,image/webp,image/gif,image/avif,image/svg+xml\" required>" +
6727
+ "<small>Up to 10 MB. The file's bytes are checked against its type.</small>" +
6728
+ "</label>" +
6729
+ _setupField("Alt text", "alt_text", "", "text", "Describes the image for screen readers + SEO.", " maxlength=\"500\"") +
6730
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Upload + attach</button></div>" +
6731
+ "</form>" +
6732
+ "</div>"
6733
+ : "";
6734
+
6576
6735
  var uploadForm = uploadWired
6577
6736
  ? "<div class=\"panel mt-1 mw-40\">" +
6578
6737
  "<h3 class=\"subhead\">Upload media from a URL</h3>" +
@@ -6586,7 +6745,7 @@ function renderAdminProduct(opts) {
6586
6745
  "</div>"
6587
6746
  : "";
6588
6747
 
6589
- var mediaSection = "<section class=\"mt\"><h3 class=\"fs-105\">Media</h3>" + mediaGrid + attachForm + uploadForm + "</section>";
6748
+ var mediaSection = "<section class=\"mt\"><h3 class=\"fs-105\">Media</h3>" + mediaGrid + fileUploadForm + attachForm + uploadForm + "</section>";
6590
6749
 
6591
6750
  // ---- head + assembly -------------------------------------------------
6592
6751
  var statusCls = p.status === "active" ? "paid" : (p.status === "archived" ? "refunded" : "pending");
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.2.26",
2
+ "version": "0.2.27",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-1SIn6oAf1DjECbRfKENZasdKHJiywGdXR58wn0hsFGcdVHzUmvfgMkEz5ANIAZJ3",
@@ -3,8 +3,8 @@
3
3
  "_about": "blamejs.shop vendors a single framework — blamejs — which itself bundles every server-side crypto/identity dependency. The transitive packages blamejs ships are surfaced in its own MANIFEST.json at lib/vendor/blamejs/lib/vendor/MANIFEST.json — Trivy / Grype rely on that nested data for CVE attribution.",
4
4
  "packages": {
5
5
  "blamejs": {
6
- "version": "0.13.44",
7
- "tag": "v0.13.44",
6
+ "version": "0.13.45",
7
+ "tag": "v0.13.45",
8
8
  "license": "Apache-2.0",
9
9
  "author": "blamejs contributors",
10
10
  "source": "https://github.com/blamejs/blamejs",
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.13.x
10
10
 
11
+ - v0.13.45 (2026-05-29) — **`b.cert` now fetches and staples a validated OCSP response per certificate, and validates declared compliance postures at create().** Two capabilities that b.cert documented but did not act on are now wired through. OCSP stapling: the cert manager fetches the leaf's OCSP response from the responder named in its Authority Information Access extension, validates it against the issuer (status, nonce, serial) via b.network.tls.ocsp, caches the DER, and exposes it on getContext().ocspResponse so a TLS server's OCSPRequest handler can staple it. The fetch runs in the background on a refresh timer and never blocks cert.start() — a slow or unreachable responder produces an audited per-certificate failure, not a stalled boot. Compliance postures: opts.compliance names are now validated against b.compliance.KNOWN_POSTURES at create() (an unknown name throws cert/unknown-compliance-posture instead of being silently recorded) and are surfaced on getContext().compliance for an auditor. Storage-confidentiality postures hold by construction because cert keys and certificates are always sealed at rest. The supporting composition primitive b.network.tls.ocsp.fetch (build request, POST to the responder through b.httpClient, validate the response) is now part of the public OCSP surface. **Added:** *b.network.tls.ocsp.fetch — fetch and validate an OCSP response* — The OCSP helper set previously built requests and evaluated responses but had no way to actually retrieve one. b.network.tls.ocsp.fetch({ leafPem, issuerPem, nonce?, timeoutMs? }) reads the responder URL from the leaf certificate's Authority Information Access extension, builds the request, POSTs it through b.httpClient (so the SSRF guard and pinned DNS apply), and validates the response against the issuer — returning the validated DER plus the parsed evaluation. It rejects when the leaf carries no OCSP responder URL or the response fails validation. · *b.cert staples a validated OCSP response per certificate* — With ocsp.stapling enabled (the default), the cert manager refreshes each certificate's OCSP response on a timer (ocsp.refreshMs, default 12h) and caches the validated DER. getContext(serverName).ocspResponse returns that DER for a TLS server to hand back from its OCSPRequest handler. The refresh runs in the background and is never on the path of cert.start(): an unreachable or slow responder is recorded as an audited cert.ocsp.refresh failure for that certificate and leaves the rest of the manager running. **Changed:** *opts.compliance posture names are validated at create()* — b.cert.create now checks each name in opts.compliance against b.compliance.KNOWN_POSTURES and throws cert/unknown-compliance-posture on an unrecognized name, so a typo is caught at construction rather than being silently recorded. The declared postures are surfaced on getContext().compliance. Cert keys and certificates are always sealed at rest, so storage-confidentiality postures are satisfied by construction.
12
+
11
13
  - v0.13.44 (2026-05-29) — **Error codes on the consent, compliance, and protocol namespaces now follow the namespace/kebab-case contract.** The framework's error contract is `err.code = "namespace/kebab-case"`, and the vast majority of namespaces already followed it. This release normalizes the holdouts: fifteen namespaces that threw bare UPPER_SNAKE codes with no namespace, and nine that used a camelCase namespace prefix. After this release every error these namespaces throw carries a `namespace/kebab-case` code, so an operator switching on `err.code` no longer has to special-case them. This is a breaking change for code that matches the old strings — pre-1.0, there is no compatibility shim, so update any `err.code` comparisons against the listed namespaces. A codebase check now enforces the convention so it cannot regress. A small set of older codes (the cluster, scheduler, circuit-breaker, object-store, and upload subsystems) is intentionally left for the 1.0 release, where it will carry a deprecation cycle. **Changed:** *Bare UPPER_SNAKE error codes are now namespaced (breaking)* — Fifteen namespaces threw bare UPPER_SNAKE error codes with no namespace prefix (for example `mcp` threw `BAD_JSON`, `BAD_ENVELOPE`, `BAD_METHOD`). Their `err.code` values are now `namespace/kebab-case` — `mcp/bad-json`, `mcp/bad-envelope`, and so on. The affected namespaces are `b.a2a`, `b.aiInput`, `b.aiPref`, `b.budr`, `b.contentCredentials`, `b.darkPatterns`, `b.fapi2`, `b.fdx`, `b.graphqlFederation`, `b.iabTcf`, `b.iabMspa`, `b.mcp`, `b.secCyber`, `b.sse`, and `b.tcpa10dlc`. Operators matching the old bare codes on `err.code` must update those comparisons; the error message text is unchanged. · *camelCase error-code namespaces are now kebab-case (breaking)* — Nine namespaces emitted error codes whose namespace segment was camelCase (for example `aiDp/bad-bound`, `argParser/flag-duplicate`). The namespace segment is now kebab-case to match every other code: `ai-dp/`, `ai-capability/`, `ai-quota/`, `arg-parser/`, `audit-sign/`, `auth-step-up/`, `ddl-change-control/`, `dr-runbook/`, `tenant-quota/`, and `boot-gates/`. The `b.*` API namespace keys themselves are unchanged (those remain camelCase, e.g. `b.argParser`); only the `err.code` string changed. Operators matching these `err.code` strings must update them. **Detectors:** *Error-code shape is enforced* — A codebase check now flags any error code constructed via `new XError(...)` or the per-class `factory(...)` whose value is a bare UPPER_SNAKE string or carries a camelCase namespace segment, so the `namespace/kebab-case` contract cannot silently regress. It correctly ignores native error constructors (whose first argument is the message, not a code).
12
14
 
13
15
  - v0.13.43 (2026-05-29) — **LTS window stated consistently as 24 months, experimental primitives declared semver-exempt, and stale version references cleaned up.** Documentation and operator-facing string hygiene ahead of the 1.0 stability contract. The LTS support window is now stated as 24 months everywhere (GOVERNANCE.md and the LTS calendar previously disagreed — 24 vs 18). The LTS calendar gains an explicit clause that primitives marked experimental are exempt from the stability/LTS contract, so operators can tell at a glance which surfaces may change between minors. Several error messages and doc blocks that pinned to long-past version numbers ("lands in v0.10.9", "not supported in v0.12.7", "ships in v0.6.45+") are restated version-agnostically with their escape hatch, and the S/MIME module now points operators at the live PGP encrypt/decrypt path for confidentiality today. No API or behavior changes. **Changed:** *LTS support window is consistently 24 months* — `GOVERNANCE.md` promised a 24-month LTS window while `LTS-CALENDAR.md` and `SECURITY.md` stated 18 — a six-month contradiction in the single most load-bearing number of the support contract. All three now state 24 months of security-only patches per major. The calendar table and the supported-versions prose are aligned. · *Experimental primitives are declared exempt from the stability contract* — `LTS-CALENDAR.md` now states explicitly that primitives documented as experimental (shown as "experimental" on their wiki page, and via the `experimental` segment in namespaces like `b.jose.jwe.experimental`) are not covered by the stability contract or the LTS window — they may change signature, behavior, or wire format, or be removed, in any minor without a deprecation cycle. This lets the framework ship primitives that track in-flight standards without freezing an unsettled format, and tells operators precisely which surfaces are not yet frozen. **Fixed:** *Stale version references removed from operator-facing errors and docs* — Error messages and documentation that pinned to long-past versions are restated version-agnostically with the relevant escape hatch: ZIP64 and unsupported-compression errors in archive reading, the CMS AuthEnvelopedData / fielded-decoder notes, the mTLS CRL-engine error, the safe-archive format-detection summary (which also now correctly lists the supported zip / tar / tar.gz set rather than claiming only zip), and the AI-content IPTC-reader note. None changed behavior; they no longer read as broken promises against the published version history. · *S/MIME confidentiality deferral points to the working PGP path* — `b.mail.crypto.smime` ships sign + verify; encrypt/decrypt is deferred. The deferral note previously cited an open-ended internal condition; it now names the escape hatch directly — use `b.mail.crypto.pgp.encrypt` / `decrypt` for mail confidentiality today — and states the concrete trigger that would re-open S/MIME-specific (X.509-recipient) encryption. · *Governance doc no longer references an internal file operators cannot see* — `GOVERNANCE.md` cited a rule number in a contributor-only file that does not ship in the repository. The deprecation-policy statement is now self-contained.
@@ -114,7 +114,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
114
114
  - **CMS codec** — RFC 5652 Cryptographic Message Syntax encoder + decoder with PQC signers (ML-DSA-65 / ML-DSA-87 / SLH-DSA-SHAKE-256f; RFC 9909 + 9881) and KEMRecipientInfo recipients (ML-KEM-1024; RFC 9629 + 9936); ChaCha20-Poly1305 content encryption (RFC 8103) so Efail-class malleability cannot apply (`b.cms`)
115
115
  - **Stream throttle** — shared token-bucket bandwidth limiter (RFC 2697 srTCM shape); N concurrent `node:stream` pipelines draw from one operator-configured `bytesPerSec` budget (`b.streamThrottle`)
116
116
  - **TLS-RPT receiver** — RFC 8460 inbound aggregate-report ingest; HTTPS POST handler + §4.4 schema parser with gzip-bomb / ratio-bomb / depth-bomb defenses (`b.mail.deploy.parseTlsRptReport` / `b.mail.deploy.tlsRptIngestHttp`)
117
- - **TLS / channel binding** — RFC 9266 TLS-Exporter token-to-session pinning (`b.tlsExporter`); RFC 9162 CT v2 inclusion-proof verification (`b.network.tls.ct.verifyInclusion`); RFC 8555 ACME + RFC 9773 ARI for 47-day certs with `{ jitter: true }` fleet-scheduling (`b.acme.renewIfDue`); draft-aaron-acme-profiles (`acme.listProfiles()` + `newOrder({ profile })`); draft-ietf-acme-dns-account-label (`acme.dnsAccount01ChallengeRecord(token, { identifier })`); RFC 8470 0-RTT inbound posture refuse / replay-cache (`b.router.create({tls0Rtt})`); RFC 9794 SecP256r1MLKEM768 in preferred-group order (`b.network.tls.preferredGroups`)
117
+ - **TLS / channel binding** — RFC 9266 TLS-Exporter token-to-session pinning (`b.tlsExporter`); RFC 9162 CT v2 inclusion-proof verification (`b.network.tls.ct.verifyInclusion`); RFC 8555 ACME + RFC 9773 ARI for 47-day certs with `{ jitter: true }` fleet-scheduling (`b.acme.renewIfDue`); draft-aaron-acme-profiles (`acme.listProfiles()` + `newOrder({ profile })`); draft-ietf-acme-dns-account-label (`acme.dnsAccount01ChallengeRecord(token, { identifier })`); RFC 8470 0-RTT inbound posture refuse / replay-cache (`b.router.create({tls0Rtt})`); RFC 9794 SecP256r1MLKEM768 in preferred-group order (`b.network.tls.preferredGroups`); RFC 6960 OCSP stapling — the cert manager (`b.cert`) fetches + validates each managed certificate's OCSP response (`b.network.tls.ocsp.fetch`) on a refresh cadence and exposes it on the served context for a TLS server's `OCSPRequest` handler to staple
118
118
  - **mTLS CA** — pure-JS, issues clientAuth / serverAuth / dual-EKU certs with SAN; auto-detects highest-PQC signature alg (today ECDSA-P384-SHA384; self-upgrades to SLH-DSA / ML-DSA when X.509 ecosystem catches up); PQC TLS gates inbound + outbound (`b.mtlsCa`, `b.pqcGate`, `b.pqcAgent`)
119
119
  ### HTTP
120
120
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 1,
3
- "frameworkVersion": "0.13.44",
4
- "createdAt": "2026-05-30T00:31:59.364Z",
3
+ "frameworkVersion": "0.13.45",
4
+ "createdAt": "2026-05-30T01:07:50.513Z",
5
5
  "exports": {
6
6
  "a2a": {
7
7
  "type": "object",
@@ -42813,6 +42813,10 @@
42813
42813
  "type": "function",
42814
42814
  "arity": 2
42815
42815
  },
42816
+ "fetch": {
42817
+ "type": "function",
42818
+ "arity": 1
42819
+ },
42816
42820
  "inspectMustStaple": {
42817
42821
  "type": "function",
42818
42822
  "arity": 1
@@ -22,9 +22,9 @@
22
22
  * - `b.acme.create` → ACME orders, JWS, ARI fetch
23
23
  * - `b.vault.seal` → sealed-disk persistence of certs + keys + account material
24
24
  * - `b.safeAsync.repeating` → renewal scheduler with drop-silent error path
25
- * - `b.network.tls.ocsp` → server-side stapling helpers
25
+ * - `b.network.tls.ocsp` → fetches + caches a validated OCSP response per cert for server-side stapling
26
26
  * - `b.audit` → cert.* lifecycle audit chain
27
- * - `b.compliance` → posture refusals (e.g. plaintext storage refused under HIPAA / PCI)
27
+ * - `b.compliance` → validates the declared posture names; storage-confidentiality postures hold because keys/certs are always sealed at rest
28
28
  *
29
29
  * Does NOT ship the challenge-solver implementations (HTTP-01 server,
30
30
  * DNS provider integrations, TLS-ALPN-01 socket). Those are operator-
@@ -60,6 +60,7 @@ var acme = lazyRequire(function () { return require("./acme"); });
60
60
  var vault = lazyRequire(function () { return require("./vault"); });
61
61
  var audit = lazyRequire(function () { return require("./audit"); });
62
62
  var networkTls = lazyRequire(function () { return require("./network-tls"); });
63
+ var compliance = lazyRequire(function () { return require("./compliance"); });
63
64
  var bCrypto = lazyRequire(function () { return require("./crypto"); });
64
65
 
65
66
  var CertError = defineClass("CertError");
@@ -222,7 +223,7 @@ function _createSealedDiskStorage(opts) {
222
223
  * refreshMs: number, // default 12h — OCSP-response cache lifetime
223
224
  * },
224
225
  * audit: boolean | object, // default true — emit cert.* lifecycle events via b.audit.safeEmit
225
- * compliance: Array<string>, // optional — posture refusals (e.g. ["hipaa"]); refuses plaintext storage etc.
226
+ * compliance: Array<string>, // optional — posture names (e.g. ["hipaa"]); validated against b.compliance.KNOWN_POSTURES (throws on an unknown name) + surfaced on getContext().compliance. Cert keys/certs are always sealed at rest, so storage-confidentiality postures hold by construction.
226
227
  *
227
228
  * @example
228
229
  * var mgr = b.cert.create({
@@ -383,13 +384,31 @@ function create(opts) {
383
384
 
384
385
  // ---- Audit + compliance ----
385
386
  var auditEnabled = opts.audit !== false;
386
- var compliance = Array.isArray(opts.compliance) ? opts.compliance.slice() : [];
387
+ var compliancePostures = Array.isArray(opts.compliance) ? opts.compliance.slice() : [];
388
+ // Validate posture names against the framework catalog so a typo is
389
+ // caught at create() rather than silently ignored. The cert manager
390
+ // satisfies the storage-confidentiality postures (HIPAA / PCI-DSS /
391
+ // GDPR …) by construction — keys + certs are always sealed at rest
392
+ // (storage.type is enforced to "sealed-disk"), so there is no plaintext-
393
+ // storage state for a posture to fail to. The postures are recorded +
394
+ // surfaced on the served context for an auditor.
395
+ if (compliancePostures.length > 0) {
396
+ var knownPostures = compliance().KNOWN_POSTURES;
397
+ compliancePostures.forEach(function (p) {
398
+ if (knownPostures.indexOf(p) === -1) {
399
+ throw new CertError("cert/unknown-compliance-posture",
400
+ "cert.create: opts.compliance posture '" + p + "' is not a known posture; " +
401
+ "see b.compliance.KNOWN_POSTURES");
402
+ }
403
+ });
404
+ }
387
405
 
388
406
  // ---- Internal state ----
389
407
  var emitter = new EventEmitter();
390
- var loadedContexts = Object.create(null); // name → { cert, key, ca, expiresAt, fingerprintSha256, sniNames }
408
+ var loadedContexts = Object.create(null); // name → { cert, key, ca, expiresAt, fingerprintSha256, sniNames, ocspResponse }
391
409
  var acmeClient = null;
392
410
  var scheduler = null;
411
+ var ocspTimer = null;
393
412
  var stopped = false;
394
413
 
395
414
  function _emitAudit(action, outcome, metadata) {
@@ -689,6 +708,39 @@ function create(opts) {
689
708
  }
690
709
  }
691
710
 
711
+ // Split a PEM chain into individual certificate blocks (leaf first).
712
+ function _splitPemChain(pem) {
713
+ return pem.match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) || [];
714
+ }
715
+
716
+ // Fetch + cache a validated OCSP response for one managed cert, for
717
+ // server-side stapling. Fail-soft: a responder error, or no issuer in the
718
+ // served chain, leaves any prior staple in place and never throws — an
719
+ // absent staple degrades gracefully (clients fall back to their own
720
+ // revocation checking). The validated DER is exposed on
721
+ // getContext().ocspResponse for the operator's TLS server to staple via
722
+ // its 'OCSPRequest' handler.
723
+ async function _refreshOcspFor(name) {
724
+ var ctx = loadedContexts[name];
725
+ if (!ctx || !ocspStapling) return;
726
+ var chain = _splitPemChain(ctx.cert);
727
+ if (chain.length < 2) return; // no issuer in the served chain
728
+ try {
729
+ // allow:raw-outbound-http — b.network.tls.ocsp.fetch composes b.httpClient internally (SSRF guard + pinned DNS); not a raw outbound call
730
+ var rv = await networkTls().ocsp.fetch({ leafPem: chain[0], issuerPem: chain[1] });
731
+ ctx.ocspResponse = rv.ocspDer;
732
+ _emitAudit("cert.ocsp.refreshed", "success", { name: name });
733
+ } catch (e) {
734
+ _emitAudit("cert.ocsp.refresh-failed", "failure",
735
+ { name: name, error: (e && e.message) || String(e) });
736
+ }
737
+ }
738
+
739
+ async function _refreshAllOcsp() {
740
+ var keys = Object.keys(loadedContexts);
741
+ for (var i = 0; i < keys.length; i += 1) { await _refreshOcspFor(keys[i]); }
742
+ }
743
+
692
744
  async function start() {
693
745
  if (stopped) {
694
746
  throw new CertError("cert/already-stopped",
@@ -706,12 +758,21 @@ function create(opts) {
706
758
  await _renewCheckOne(certsByName[keys[ki]]);
707
759
  }
708
760
  }, renewIntervalMs, { name: "cert-renew" });
761
+ // 3. OCSP stapling. The initial fetch runs in the background so a slow
762
+ // responder never delays start(); the staple becomes available
763
+ // shortly after, and the timer refreshes on the configured cadence.
764
+ if (ocspStapling) {
765
+ _refreshAllOcsp().catch(function () { /* per-cert errors already audited */ });
766
+ ocspTimer = safeAsync.repeating(_refreshAllOcsp, ocspRefreshMs, { name: "cert-ocsp" });
767
+ }
709
768
  }
710
769
 
711
770
  async function stop() {
712
771
  stopped = true;
713
772
  if (scheduler && typeof scheduler.stop === "function") scheduler.stop();
714
773
  scheduler = null;
774
+ if (ocspTimer && typeof ocspTimer.stop === "function") ocspTimer.stop();
775
+ ocspTimer = null;
715
776
  }
716
777
 
717
778
  function getContext(name) {
@@ -729,6 +790,11 @@ function create(opts) {
729
790
  key: ctx.key,
730
791
  expiresAt: ctx.expiresAt,
731
792
  fingerprintSha256: ctx.fingerprintSha256,
793
+ // The cached, validated OCSP response (DER Buffer) when ocsp.stapling
794
+ // is on and a response has been fetched; null otherwise. Staple it
795
+ // from the TLS server's 'OCSPRequest' handler: cb(null, ocspResponse).
796
+ ocspResponse: ctx.ocspResponse || null,
797
+ compliance: compliancePostures.slice(),
732
798
  };
733
799
  }
734
800
 
@@ -798,10 +864,6 @@ function create(opts) {
798
864
  function off(event, handler) { emitter.off(event, handler); return this; }
799
865
  function once(event, handler) { emitter.once(event, handler); return this; }
800
866
 
801
- // Suppress unused-warnings for ocsp + compliance until those branches
802
- // wire up in v0.11.23+ follow-up.
803
- void ocspStapling; void ocspRefreshMs; void compliance; void networkTls;
804
-
805
867
  return {
806
868
  start: start,
807
869
  stop: stop,
@@ -20,6 +20,7 @@ var NetworkTlsError = defineClass("NetworkTlsError", { alwaysPermanent: true });
20
20
  var observability = lazyRequire(function () { return require("./observability"); });
21
21
  var audit = lazyRequire(function () { return require("./audit"); });
22
22
  var networkDns = lazyRequire(function () { return require("./network-dns"); });
23
+ var httpClient = lazyRequire(function () { return require("./http-client"); });
23
24
  var asn1 = require("./asn1-der");
24
25
 
25
26
  // STATE.tlsKeyShares is initialized to the default PQC group list at
@@ -1194,9 +1195,10 @@ function evaluateOcspResponse(ocspDer, opts) {
1194
1195
  //
1195
1196
  // Constructs a DER-encoded OCSPRequest for a single (leafCertDer,
1196
1197
  // issuerCertDer) pair, optionally with an RFC 8954 nonce extension.
1197
- // Operators send the returned `requestDer` to the OCSP responder URL
1198
- // (e.g. via b.httpClient with `Content-Type: application/ocsp-request`)
1199
- // and pass `nonce` to `ocsp.evaluate(responseDer, { expectedNonce })`
1198
+ // `ocsp.fetch` composes this with `b.httpClient` to POST the request to
1199
+ // the cert's responder and return a validated response; operators who
1200
+ // need the raw request (custom transport, batched requests) call this
1201
+ // directly and pass `nonce` to `ocsp.evaluate(responseDer, { expectedNonce })`
1200
1202
  // to defend against replay attacks.
1201
1203
  //
1202
1204
  // Nonce DEFAULT ON — defense in depth. RFC 6960 §4.4.1 marks nonce
@@ -1291,8 +1293,10 @@ function buildOcspRequest(opts) {
1291
1293
  // framework that touches SHA-1" need a signal. Emit an audit row
1292
1294
  // on every OCSP request build so the algorithm choice is visible
1293
1295
  // in the chain.
1294
- var nameHash = nodeCrypto.createHash("sha1").update(iss.issuerNameDer).digest();
1295
- var keyHash = nodeCrypto.createHash("sha1").update(iss.issuerKey).digest();
1296
+ // lgtm[js/weak-cryptographic-algorithm] RFC 6960 §4.1.1 CertID lookup hash over the PUBLIC issuer name; a name/key lookup, not an integrity or secrecy operation. SHA-256 CertIDs are §4.3-optional and rejected by most responders.
1297
+ var nameHash = nodeCrypto.createHash("sha1").update(iss.issuerNameDer).digest(); // lgtm[js/weak-cryptographic-algorithm]
1298
+ // lgtm[js/weak-cryptographic-algorithm] — RFC 6960 §4.1.1 CertID lookup hash over the PUBLIC issuer key; a name/key lookup, not an integrity or secrecy operation.
1299
+ var keyHash = nodeCrypto.createHash("sha1").update(iss.issuerKey).digest(); // lgtm[js/weak-cryptographic-algorithm]
1296
1300
  setImmediate(function () {
1297
1301
  try {
1298
1302
  var auditMod = require("./audit"); // allow:inline-require — circular-load defense (audit imports network-tls)
@@ -1339,6 +1343,77 @@ function buildOcspRequest(opts) {
1339
1343
  return { requestDer: requestDer, nonce: nonceBytes };
1340
1344
  }
1341
1345
 
1346
+ // _ocspResponderUrl — pull the OCSP responder URL out of a cert's
1347
+ // Authority Information Access extension. node:crypto exposes it as a
1348
+ // multi-line string ("OCSP - URI:http://...\nCA Issuers - URI:...\n").
1349
+ function _ocspResponderUrl(x509) {
1350
+ var ia = x509 && x509.infoAccess;
1351
+ if (typeof ia !== "string") return null;
1352
+ var m = ia.match(/OCSP\s*-\s*URI:(\S+)/i);
1353
+ return m ? m[1].trim() : null;
1354
+ }
1355
+
1356
+ // fetch — POST a freshly-built OCSPRequest to the cert's responder and
1357
+ // return the validated, known-good response bytes. Composes buildRequest +
1358
+ // b.httpClient + evaluate, completing the server-side-stapling fetch path
1359
+ // (the response is what a TLS server staples via its 'OCSPRequest' handler).
1360
+ // The responder URL is taken from the leaf cert's AIA extension unless
1361
+ // opts.responderUrl overrides it. Throws TlsTrustError on any failure
1362
+ // (no responder, transport error, non-good certStatus, signature mismatch);
1363
+ // callers that staple should treat a throw as "no staple this cycle".
1364
+ async function fetchOcspResponse(opts) {
1365
+ opts = opts || {};
1366
+ if (typeof opts.leafPem !== "string" || typeof opts.issuerPem !== "string") {
1367
+ throw new TlsTrustError("tls/ocsp-bad-input",
1368
+ "ocsp.fetch: opts.leafPem and opts.issuerPem (PEM strings) are required");
1369
+ }
1370
+ var leafX, issuerX;
1371
+ try {
1372
+ leafX = new nodeCrypto.X509Certificate(opts.leafPem);
1373
+ issuerX = new nodeCrypto.X509Certificate(opts.issuerPem);
1374
+ } catch (e) {
1375
+ throw new TlsTrustError("tls/ocsp-bad-cert",
1376
+ "ocsp.fetch: could not parse leaf/issuer PEM: " + ((e && e.message) || String(e)));
1377
+ }
1378
+ var responderUrl = opts.responderUrl || _ocspResponderUrl(leafX);
1379
+ if (!responderUrl) {
1380
+ throw new TlsTrustError("tls/ocsp-no-responder",
1381
+ "ocsp.fetch: cert has no AIA OCSP responder URL; pass opts.responderUrl");
1382
+ }
1383
+ var built = buildOcspRequest({
1384
+ leafCertDer: leafX.raw, issuerCertDer: issuerX.raw,
1385
+ nonce: opts.nonce, nonceLen: opts.nonceLen,
1386
+ });
1387
+ var res;
1388
+ try {
1389
+ res = await httpClient().request({
1390
+ url: responderUrl,
1391
+ method: "POST",
1392
+ headers: { "content-type": "application/ocsp-request", "accept": "application/ocsp-response" },
1393
+ body: built.requestDer,
1394
+ responseMode: "buffer",
1395
+ timeoutMs: opts.timeoutMs || C.TIME.seconds(10),
1396
+ });
1397
+ } catch (e) {
1398
+ throw new TlsTrustError("tls/ocsp-fetch-failed",
1399
+ "ocsp.fetch: responder request to " + responderUrl + " failed: " + ((e && e.message) || String(e)));
1400
+ }
1401
+ if (res.status !== 200 || !Buffer.isBuffer(res.body) || res.body.length === 0) {
1402
+ throw new TlsTrustError("tls/ocsp-fetch-bad-status",
1403
+ "ocsp.fetch: responder returned status " + res.status + " with an empty/non-buffer body");
1404
+ }
1405
+ var evald = evaluateOcspResponse(res.body, {
1406
+ issuerPem: opts.issuerPem,
1407
+ serialHex: opts.serialHex || null,
1408
+ expectedNonce: opts.nonce === false ? null : built.nonce,
1409
+ });
1410
+ if (!evald.ok) {
1411
+ throw new TlsTrustError("tls/ocsp-not-good",
1412
+ "ocsp.fetch: response is not good: " + (evald.errors || []).join("; "));
1413
+ }
1414
+ return { ocspDer: res.body, evaluation: evald, responderUrl: responderUrl };
1415
+ }
1416
+
1342
1417
  var ocsp = Object.freeze({
1343
1418
  // Connect with OCSP requested. Returns { authorized, ocspBytes,
1344
1419
  // peerCert }. requireStapled: true makes empty / not-stapled responses
@@ -1379,6 +1454,7 @@ var ocsp = Object.freeze({
1379
1454
  },
1380
1455
  parseResponse: parseOcspResponse,
1381
1456
  evaluate: evaluateOcspResponse,
1457
+ fetch: fetchOcspResponse,
1382
1458
  // buildRequest — construct a DER-encoded OCSPRequest for a single
1383
1459
  // (leafCertDer, issuerCertDer) pair. RFC 8954 nonce extension is ON
1384
1460
  // by default (16 random bytes; opts.nonceLen overrides within RFC
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.13.44",
3
+ "version": "0.13.45",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
@@ -0,0 +1,31 @@
1
+ {
2
+ "$schema": "../scripts/release-notes-schema.json",
3
+ "version": "0.13.45",
4
+ "date": "2026-05-29",
5
+ "headline": "`b.cert` now fetches and staples a validated OCSP response per certificate, and validates declared compliance postures at create()",
6
+ "summary": "Two capabilities that b.cert documented but did not act on are now wired through. OCSP stapling: the cert manager fetches the leaf's OCSP response from the responder named in its Authority Information Access extension, validates it against the issuer (status, nonce, serial) via b.network.tls.ocsp, caches the DER, and exposes it on getContext().ocspResponse so a TLS server's OCSPRequest handler can staple it. The fetch runs in the background on a refresh timer and never blocks cert.start() — a slow or unreachable responder produces an audited per-certificate failure, not a stalled boot. Compliance postures: opts.compliance names are now validated against b.compliance.KNOWN_POSTURES at create() (an unknown name throws cert/unknown-compliance-posture instead of being silently recorded) and are surfaced on getContext().compliance for an auditor. Storage-confidentiality postures hold by construction because cert keys and certificates are always sealed at rest. The supporting composition primitive b.network.tls.ocsp.fetch (build request, POST to the responder through b.httpClient, validate the response) is now part of the public OCSP surface.",
7
+ "sections": [
8
+ {
9
+ "heading": "Added",
10
+ "items": [
11
+ {
12
+ "title": "b.network.tls.ocsp.fetch — fetch and validate an OCSP response",
13
+ "body": "The OCSP helper set previously built requests and evaluated responses but had no way to actually retrieve one. b.network.tls.ocsp.fetch({ leafPem, issuerPem, nonce?, timeoutMs? }) reads the responder URL from the leaf certificate's Authority Information Access extension, builds the request, POSTs it through b.httpClient (so the SSRF guard and pinned DNS apply), and validates the response against the issuer — returning the validated DER plus the parsed evaluation. It rejects when the leaf carries no OCSP responder URL or the response fails validation."
14
+ },
15
+ {
16
+ "title": "b.cert staples a validated OCSP response per certificate",
17
+ "body": "With ocsp.stapling enabled (the default), the cert manager refreshes each certificate's OCSP response on a timer (ocsp.refreshMs, default 12h) and caches the validated DER. getContext(serverName).ocspResponse returns that DER for a TLS server to hand back from its OCSPRequest handler. The refresh runs in the background and is never on the path of cert.start(): an unreachable or slow responder is recorded as an audited cert.ocsp.refresh failure for that certificate and leaves the rest of the manager running."
18
+ }
19
+ ]
20
+ },
21
+ {
22
+ "heading": "Changed",
23
+ "items": [
24
+ {
25
+ "title": "opts.compliance posture names are validated at create()",
26
+ "body": "b.cert.create now checks each name in opts.compliance against b.compliance.KNOWN_POSTURES and throws cert/unknown-compliance-posture on an unrecognized name, so a typo is caught at construction rather than being silently recorded. The declared postures are surfaced on getContext().compliance. Cert keys and certificates are always sealed at rest, so storage-confidentiality postures are satisfied by construction."
27
+ }
28
+ ]
29
+ }
30
+ ]
31
+ }
@@ -245,6 +245,49 @@ function testFactoryRefusesBadOpts() {
245
245
  });
246
246
  check("cert name '" + JSON.stringify(badName) + "' refused as path-segment", e && e.code === "cert/bad-cert-name");
247
247
  });
248
+
249
+ // ---- compliance posture validation ----
250
+ // opts.compliance names are validated against b.compliance.KNOWN_POSTURES
251
+ // at create() so a typo is caught at boot rather than silently recorded.
252
+ var goodChallenge = { type: "http-01", provision: function () {}, cleanup: function () {} };
253
+ var eBadPosture = threw(function () {
254
+ b.cert.create({
255
+ storage: { type: "sealed-disk", rootDir: _tmpDir(), vault: _ephemeralVault() },
256
+ acme: { directory: "https://example/", accountKey: "auto" },
257
+ certs: [{ name: "m", domains: ["a.com"], challenge: goodChallenge }],
258
+ compliance: ["not-a-real-posture"],
259
+ audit: false,
260
+ });
261
+ });
262
+ check("unknown compliance posture → cert/unknown-compliance-posture",
263
+ eBadPosture && eBadPosture.code === "cert/unknown-compliance-posture");
264
+
265
+ var eGoodPosture = threw(function () {
266
+ b.cert.create({
267
+ storage: { type: "sealed-disk", rootDir: _tmpDir(), vault: _ephemeralVault() },
268
+ acme: { directory: "https://example/", accountKey: "auto" },
269
+ certs: [{ name: "m", domains: ["a.com"], challenge: goodChallenge }],
270
+ compliance: ["hipaa", "pci-dss"],
271
+ audit: false,
272
+ });
273
+ });
274
+ check("known compliance postures accepted", !eGoodPosture);
275
+
276
+ // ---- b.network.tls.ocsp.fetch composition surface ----
277
+ check("b.network.tls.ocsp.fetch is a function",
278
+ typeof b.network.tls.ocsp.fetch === "function");
279
+ }
280
+
281
+ // b.network.tls.ocsp.fetch rejects on missing leafPem/issuerPem rather than
282
+ // issuing an outbound request with undefined inputs.
283
+ async function testOcspFetchRejectsBadInput() {
284
+ var rejected = false;
285
+ try {
286
+ await b.network.tls.ocsp.fetch({});
287
+ } catch (_e) {
288
+ rejected = true;
289
+ }
290
+ check("ocsp.fetch({}) rejects (no leafPem/issuerPem)", rejected);
248
291
  }
249
292
 
250
293
  // ---- Sealed-disk storage roundtrip ----
@@ -699,6 +742,7 @@ async function testCorruptAccountKeyClearError() {
699
742
  async function run() {
700
743
  testSurface();
701
744
  testFactoryRefusesBadOpts();
745
+ await testOcspFetchRejectsBadInput();
702
746
  await testStorageRoundtrip();
703
747
  await testSniCallback();
704
748
  await testSniWildcardSingleLabel();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.2.26",
3
+ "version": "0.2.27",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {