@blamejs/blamejs-shop 0.2.26 → 0.2.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.2.x
10
10
 
11
+ - v0.2.28 (2026-05-29) — **Sales reports and printable receipts in the admin console.** The admin console gains a Reports screen and printable order documents. Reports aggregates a sales and revenue summary over a date range you choose — order count, gross and net revenue, refunds, average order value, an order-status funnel, a by-day revenue table, and top products — with a CSV export of the daily series. Every order detail page now offers a printable receipt and a printable packing slip (print-optimized, carrying your shop name and contact), so you can pull a revenue report or print an order without querying the database directly. **Added:** *Reports screen* — `/admin/reports` aggregates a sales/revenue summary over a selectable date range: order count, gross and net revenue, refunds and refund rate, average order value, an order-status funnel, a by-day revenue table, and top products. The daily series exports to CSV. The screen reads only — it never writes. · *Printable receipts and packing slips* — `GET /admin/orders/:id/receipt` and `GET /admin/orders/:id/packing-slip` render self-contained, print-optimized order documents (lines, totals, ship-to, tracking barcode) with your shop name and contact in the masthead. Both are linked from a Documents panel on the admin order detail screen.
12
+
13
+ - 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.
14
+
11
15
  - 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
16
 
13
17
  - 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) {
@@ -349,10 +371,16 @@ function mount(router, deps) {
349
371
  var returns = deps.returns || null; // RMA moderation endpoints disabled when absent
350
372
  var customers = deps.customers || null; // read-only customers console disabled when absent
351
373
  var orderTracking = deps.orderTracking || null; // shipment/tracking panel disabled when absent
374
+ var salesReports = deps.salesReports || null; // /admin/reports degrades to an unconfigured notice when absent
375
+ var printReceipts = deps.printReceipts || null; // order receipt document disabled when absent
376
+ var packingSlips = deps.packingSlips || null; // order packing-slip document disabled when absent
352
377
 
353
378
  // Which optional console sections are wired — gates their nav links so a
354
379
  // signed-in admin is never sent to a route that wasn't mounted. Passed
355
380
  // into every authed render call as `nav_available`.
381
+ // `reports` is always present in the nav (read-only sales summary needs no
382
+ // extra dep); its route mounts unconditionally and renders an unconfigured
383
+ // notice when the salesReports primitive isn't wired.
356
384
  var navAvailable = { returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, customerSurveys: !!deps.customerSurveys, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, autoDiscount: !!deps.autoDiscount };
357
385
 
358
386
  try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
@@ -1007,14 +1035,24 @@ function mount(router, deps) {
1007
1035
  if (buf.length === 0) {
1008
1036
  return { status: 422, code: "source-empty", detail: "source_url returned an empty body" };
1009
1037
  }
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.
1038
+ return await _storeAndAttach(buf, body.content_type, body);
1039
+ }
1040
+
1041
+ // Store bytes to R2 and attach the media row — the tail shared by the
1042
+ // upload-from-URL flow and the direct-file upload flow. Generates the
1043
+ // R2 key (extension inferred from the declared content-type so the
1044
+ // operator can preview without a content-disposition round-trip),
1045
+ // pushes through the same r2_bridge put path, then records the catalog
1046
+ // row. Returns `{ status, code, detail }` on an operational failure or
1047
+ // `{ rec }` on success. Never throws on an R2 / attach failure — the
1048
+ // caller renders the problem.
1049
+ async function _storeAndAttach(buf, contentType, body) {
1050
+ var declared = String(contentType).split(";")[0].trim().toLowerCase();
1013
1051
  var ext = _extFromContentType(declared);
1014
1052
  var id = b.uuid.v7();
1015
1053
  var key = "media/" + id + (ext ? "." + ext : "");
1016
1054
  try {
1017
- await r2.put(key, buf, body.content_type);
1055
+ await r2.put(key, buf, contentType);
1018
1056
  } catch (e) {
1019
1057
  return { status: 502, code: "r2-upload-failed", detail: (e && e.message) || String(e) };
1020
1058
  }
@@ -1024,7 +1062,7 @@ function mount(router, deps) {
1024
1062
  product_id: body.product_id || undefined,
1025
1063
  variant_id: body.variant_id || undefined,
1026
1064
  r2_key: key,
1027
- content_type: body.content_type,
1065
+ content_type: contentType,
1028
1066
  width: body.width || 0,
1029
1067
  height: body.height || 0,
1030
1068
  position: body.position || 0,
@@ -1042,6 +1080,91 @@ function mount(router, deps) {
1042
1080
  return { rec: Object.assign({}, m, { asset_url: assetPrefix + key }) };
1043
1081
  }
1044
1082
 
1083
+ // Direct-file upload: validate + store an image picked from the
1084
+ // operator's device. The framework's multipart body-parser has already
1085
+ // streamed the part to a tmp file (req.files[N] = { field, filename,
1086
+ // mimeType, path, size, hash }); this reads it back, checks the
1087
+ // declared MIME against the image allowlist, enforces the media-budget
1088
+ // size cap, cross-checks the magic bytes against the declared type
1089
+ // (defense against an image/png label on a non-image body), then hands
1090
+ // off to _storeAndAttach. Request-shape reader: returns `{ status,
1091
+ // code, detail }` for any bad/oversized/disallowed file rather than
1092
+ // throwing — the upload must never crash the request that carries it.
1093
+ async function _performFileUpload(file, body) {
1094
+ body = body || {};
1095
+ if (!body.product_id && !body.variant_id) {
1096
+ return { status: 400, code: "missing-target", detail: "one of product_id / variant_id required" };
1097
+ }
1098
+ if (!file || typeof file.path !== "string" || !file.path.length) {
1099
+ return { status: 400, code: "no-file", detail: "no file part received (expected a multipart `file` field)" };
1100
+ }
1101
+ var declaredCT = String(file.mimeType || "").split(";")[0].trim().toLowerCase();
1102
+ if (!_UPLOAD_IMAGE_CT[declaredCT]) {
1103
+ return { status: 415, code: "unsupported-type",
1104
+ detail: "`" + (declaredCT || "(none)") + "` is not an accepted image type " +
1105
+ "(png, jpeg, webp, gif, avif, svg)" };
1106
+ }
1107
+ // The multipart parser caps file size globally, but the media route
1108
+ // pins its own budget so the limit is explicit here and the message
1109
+ // names the media cap. file.size is the streamed byte count.
1110
+ if (typeof file.size === "number" && file.size > _UPLOAD_MAX_BYTES) {
1111
+ return { status: 413, code: "file-too-large",
1112
+ detail: "file is " + file.size + " bytes, exceeds the " + _UPLOAD_MAX_BYTES + "-byte media upload cap" };
1113
+ }
1114
+ // Read the streamed tmp file back through the framework's atomic
1115
+ // reader with the media cap as maxBytes — this re-checks the on-disk
1116
+ // byte count against the budget (the parser's own cap is global) and
1117
+ // catches a size header that under-reported the streamed bytes,
1118
+ // throwing `atomic-file/too-large` rather than buffering past the cap.
1119
+ var buf;
1120
+ try {
1121
+ buf = b.atomicFile.readSync(file.path, { maxBytes: _UPLOAD_MAX_BYTES });
1122
+ } catch (e) {
1123
+ if (e && e.code === "atomic-file/too-large") {
1124
+ return { status: 413, code: "file-too-large",
1125
+ detail: "file exceeds the " + _UPLOAD_MAX_BYTES + "-byte media upload cap" };
1126
+ }
1127
+ return { status: 500, code: "tmp-read-failed", detail: (e && e.message) || String(e) };
1128
+ }
1129
+ if (buf.length === 0) {
1130
+ return { status: 422, code: "file-empty", detail: "uploaded file is empty (0 bytes)" };
1131
+ }
1132
+ // Magic-byte cross-check: refuse a body whose sniffed type doesn't
1133
+ // match the declared image type. svg is text (no magic bytes) so
1134
+ // b.fileType.detect returns null — skip the cross-check for it and
1135
+ // trust the declared type, same as the upload-from-URL flow.
1136
+ if (declaredCT !== "image/svg+xml") {
1137
+ var sniffed = b.fileType.detect(buf);
1138
+ var sniffedMime = sniffed && sniffed.mime;
1139
+ // jpg/jpeg are synonyms; normalize both sides to one token.
1140
+ var declNorm = declaredCT === "image/jpg" ? "image/jpeg" : declaredCT;
1141
+ var sniffNorm = sniffedMime === "image/jpg" ? "image/jpeg" : sniffedMime;
1142
+ if (!sniffNorm) {
1143
+ return { status: 422, code: "unrecognized-bytes",
1144
+ detail: "could not classify the file's bytes as a known image format" };
1145
+ }
1146
+ if (sniffNorm !== declNorm) {
1147
+ return { status: 422, code: "content-type-mismatch",
1148
+ detail: "file bytes sniff as `" + sniffNorm + "` but the part declared `" + declaredCT + "`" };
1149
+ }
1150
+ }
1151
+ return await _storeAndAttach(buf, declaredCT, body);
1152
+ }
1153
+
1154
+ // Pull the first uploaded file out of req.files. The multipart parser
1155
+ // exposes every accepted file part as req.files[] = { field, filename,
1156
+ // mimeType, path, size, hash }; the media form names its part `file`,
1157
+ // but accept any single file part so an API caller using a different
1158
+ // field name still works.
1159
+ function _firstUploadFile(req) {
1160
+ var files = (req && Array.isArray(req.files)) ? req.files : [];
1161
+ if (!files.length) return null;
1162
+ for (var i = 0; i < files.length; i++) {
1163
+ if (files[i] && files[i].field === "file") return files[i];
1164
+ }
1165
+ return files[0];
1166
+ }
1167
+
1045
1168
  router.post("/admin/media/upload", W("media.upload", async function (req, res) {
1046
1169
  var out = await _performMediaUpload(req.body || {});
1047
1170
  if (out.rec) { _json(res, 201, out.rec); return out.rec; }
@@ -1070,6 +1193,33 @@ function mount(router, deps) {
1070
1193
  _redirect(res, "/admin/products/" + enc + "?saved=1");
1071
1194
  },
1072
1195
  ));
1196
+
1197
+ // Direct-file upload (multipart/form-data). The JSON API route takes
1198
+ // product_id / variant_id from the form fields; the browser alias
1199
+ // scopes it to the product in the path and PRGs back to the detail.
1200
+ router.post("/admin/media/upload-file", W("media.upload", async function (req, res) {
1201
+ var out = await _performFileUpload(_firstUploadFile(req), req.body || {});
1202
+ if (out.rec) { _json(res, 201, out.rec); return out.rec; }
1203
+ return _problem(res, out.status, out.code, out.detail);
1204
+ }));
1205
+
1206
+ router.post("/admin/products/:id/media/upload-file", _pageOrApi(false,
1207
+ W("media.upload", async function (req, res) {
1208
+ var body = Object.assign({}, req.body || {}, { product_id: req.params.id });
1209
+ var out = await _performFileUpload(_firstUploadFile(req), body);
1210
+ if (out.rec) { _json(res, 201, out.rec); return out.rec; }
1211
+ return _problem(res, out.status, out.code, out.detail);
1212
+ }),
1213
+ async function (req, res) {
1214
+ var id = req.params.id;
1215
+ var enc = encodeURIComponent(id);
1216
+ var body = Object.assign({}, req.body || {}, { product_id: id });
1217
+ var out = await _performFileUpload(_firstUploadFile(req), body);
1218
+ if (!out.rec) return _redirect(res, "/admin/products/" + enc + "?err=1");
1219
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".media.upload", outcome: "success", metadata: { id: id } });
1220
+ _redirect(res, "/admin/products/" + enc + "?saved=1");
1221
+ },
1222
+ ));
1073
1223
  }
1074
1224
 
1075
1225
  router.delete("/admin/media/:id", W("media.delete", async function (req, res) {
@@ -1235,6 +1385,10 @@ function mount(router, deps) {
1235
1385
  // Shipment/tracking panel only renders when the tracking primitive
1236
1386
  // is wired; the carrier + status enums drive its form selects.
1237
1387
  can_track: !!orderTracking,
1388
+ // Printable-document links — shown only when the render primitive is
1389
+ // wired (its route is mounted only then).
1390
+ can_receipt: !!printReceipts,
1391
+ can_packing_slip: !!packingSlips,
1238
1392
  shipments: shipments,
1239
1393
  carriers: orderTracking ? orderTracking.CARRIERS : null,
1240
1394
  statuses: orderTracking ? orderTracking.STATUSES : null,
@@ -3016,6 +3170,288 @@ function mount(router, deps) {
3016
3170
  ));
3017
3171
  }
3018
3172
 
3173
+ // ---- reporting ------------------------------------------------------
3174
+ //
3175
+ // A sales/revenue report over a selectable date range: order count,
3176
+ // gross/net revenue, refunds, AOV, top products, broken down by day and
3177
+ // by status. Aggregated from the salesReports primitive (pure read-only
3178
+ // SQL over orders/order_lines). The route mounts unconditionally so the
3179
+ // always-present "Reports" nav link never points at a missing route; when
3180
+ // the salesReports primitive isn't wired it renders an unconfigured
3181
+ // notice. CSV export of the by-day series is exposed via `?format=csv`.
3182
+ //
3183
+ // Date range: `from`/`to` are epoch-ms query params (defaults to the last
3184
+ // 30 days). A malformed range re-renders the page with a 400 + the
3185
+ // validator's message rather than crashing — the report is config/entry
3186
+ // tier, so a bad operator-typed range surfaces as a correction, not a 500.
3187
+
3188
+ // Parse a "YYYY-MM-DD" calendar-date param to the epoch-ms at UTC
3189
+ // midnight. Returns null when the param is absent; throws TypeError on a
3190
+ // malformed value so the config/entry-tier 400 path catches it. The
3191
+ // browser date <input> submits this shape; the JSON API can use either
3192
+ // this or the raw epoch-ms `from`/`to`.
3193
+ function _parseDateParam(str, label) {
3194
+ if (str == null || str === "") return null;
3195
+ if (typeof str !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(str)) {
3196
+ throw new TypeError("admin: " + label + " must be a YYYY-MM-DD date");
3197
+ }
3198
+ var ms = Date.parse(str + "T00:00:00Z");
3199
+ if (!isFinite(ms)) throw new TypeError("admin: " + label + " is not a valid date");
3200
+ return ms;
3201
+ }
3202
+
3203
+ // Resolve the report window from the query string. Returns
3204
+ // `{ from, to }` (epoch-ms) or throws TypeError on a malformed value.
3205
+ // Accepts either the raw epoch-ms `from`/`to` pair (machine clients) or
3206
+ // the calendar-date `from-date`/`to-date` pair (the browser date inputs);
3207
+ // the epoch-ms form wins when both are present. Defaults: to = now,
3208
+ // from = now - 30d. The salesReports primitive re-validates (from < to,
3209
+ // span ≤ 1y), so this only coerces the shape.
3210
+ function _reportWindow(url) {
3211
+ var from = _parseEpochMs(url && url.searchParams.get("from"), "from");
3212
+ var to = _parseEpochMs(url && url.searchParams.get("to"), "to");
3213
+ if (from == null) from = _parseDateParam(url && url.searchParams.get("from-date"), "from");
3214
+ // `to-date` is the inclusive end day — advance to the next UTC midnight
3215
+ // so the window covers the whole selected day (salesReports treats `to`
3216
+ // as exclusive: `updated_at < to`).
3217
+ if (to == null) {
3218
+ var toDate = _parseDateParam(url && url.searchParams.get("to-date"), "to");
3219
+ if (toDate != null) to = toDate + b.constants.TIME.days(1);
3220
+ }
3221
+ var now = Date.now();
3222
+ return {
3223
+ to: to == null ? now : to,
3224
+ from: from == null ? (now - b.constants.TIME.days(30)) : from,
3225
+ };
3226
+ }
3227
+
3228
+ // Aggregate the report payload from the salesReports primitive. Composes
3229
+ // the by-day revenue series, AOV, refund rate, the status funnel, and the
3230
+ // top products into one object the HTML + CSV + JSON surfaces all read.
3231
+ async function _buildReport(win) {
3232
+ var byDay = await salesReports.revenueByDay({ from: win.from, to: win.to });
3233
+ var aov = await salesReports.aov({ from: win.from, to: win.to });
3234
+ var refund = await salesReports.refundRate({ from: win.from, to: win.to });
3235
+ var funnel = await salesReports.funnel({ from: win.from, to: win.to });
3236
+ var top = await salesReports.topProducts({ from: win.from, to: win.to, limit: 10 });
3237
+
3238
+ // Headline totals from the by-day series — gross/net/refunds summed
3239
+ // across every currency bucket in the window. The per-currency split
3240
+ // stays on the by-day rows for the operator who needs it; the headline
3241
+ // is the single-number summary a dashboard leads with.
3242
+ var orderCount = 0, gross = 0, net = 0, refunds = 0;
3243
+ var currency = (aov && aov.currency) || "USD";
3244
+ for (var i = 0; i < byDay.length; i += 1) {
3245
+ var row = byDay[i];
3246
+ orderCount += row.order_count;
3247
+ gross += row.gross_revenue_minor;
3248
+ net += row.net_revenue_minor;
3249
+ refunds += row.refund_total_minor;
3250
+ }
3251
+ return {
3252
+ from: win.from,
3253
+ to: win.to,
3254
+ currency: currency,
3255
+ order_count: orderCount,
3256
+ gross_revenue_minor: gross,
3257
+ net_revenue_minor: net,
3258
+ refund_total_minor: refunds,
3259
+ aov_minor: (aov && aov.aov_minor) || 0,
3260
+ refund_rate_bps: (refund && refund.refund_rate_bps) || 0,
3261
+ by_day: byDay,
3262
+ by_status: funnel,
3263
+ top_products: (top && top.rows) || [],
3264
+ };
3265
+ }
3266
+
3267
+ // CSV body for the by-day revenue series. Composes `b.csv.stringify`
3268
+ // (RFC 4180) — the cell values are integer aggregates + ISO date buckets
3269
+ // + 3-letter currency codes, all framework-internal (never raw
3270
+ // operator/customer prose), so the RFC-4180 quoting is sufficient here;
3271
+ // there is no untrusted free-text column that would require b.guardCsv's
3272
+ // formula-injection defenses.
3273
+ function _reportCsv(report) {
3274
+ var rows = report.by_day.map(function (r) {
3275
+ return {
3276
+ date: r.bucket_start,
3277
+ currency: r.currency,
3278
+ order_count: r.order_count,
3279
+ gross_revenue_minor: r.gross_revenue_minor,
3280
+ net_revenue_minor: r.net_revenue_minor,
3281
+ refund_total_minor: r.refund_total_minor,
3282
+ };
3283
+ });
3284
+ return b.csv.stringify(rows, {
3285
+ columns: ["date", "currency", "order_count", "gross_revenue_minor", "net_revenue_minor", "refund_total_minor"],
3286
+ header: true,
3287
+ eol: "\n",
3288
+ });
3289
+ }
3290
+
3291
+ router.get("/admin/reports", _pageOrApi(true,
3292
+ R(async function (req, res) {
3293
+ if (!salesReports) return _problem(res, 503, "reporting-unconfigured", "the salesReports primitive is not wired");
3294
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
3295
+ // A malformed from/to surfaces as a 400 problem (config/entry tier).
3296
+ var win;
3297
+ try { win = _reportWindow(url); }
3298
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
3299
+ var report;
3300
+ try { report = await _buildReport(win); }
3301
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
3302
+ if (url && url.searchParams.get("format") === "csv") {
3303
+ res.status(200);
3304
+ if (res.setHeader) {
3305
+ res.setHeader("content-type", "text/csv; charset=utf-8");
3306
+ res.setHeader("content-disposition", "attachment; filename=\"sales-report.csv\"");
3307
+ }
3308
+ var csv = _reportCsv(report);
3309
+ if (res.end) res.end(csv); else res.send(csv);
3310
+ return;
3311
+ }
3312
+ _json(res, 200, report);
3313
+ }),
3314
+ async function (req, res) {
3315
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
3316
+ if (!salesReports) {
3317
+ return _sendHtml(res, 200, renderAdminReports({
3318
+ shop_name: deps.shop_name, nav_available: navAvailable, report: null,
3319
+ notice: "Reporting isn't configured on this deployment.",
3320
+ }));
3321
+ }
3322
+ var win, notice = null;
3323
+ try { win = _reportWindow(url); }
3324
+ catch (e) {
3325
+ if (!(e instanceof TypeError)) throw e;
3326
+ // Bad range → re-render with the default window + a correction
3327
+ // notice (config/entry tier: the operator fixes the typo).
3328
+ win = { to: Date.now(), from: Date.now() - b.constants.TIME.days(30) };
3329
+ notice = e.message.replace(/^admin:\s*/, "");
3330
+ }
3331
+ // CSV download from the browser surface too (a link, not a fetch).
3332
+ if (url && url.searchParams.get("format") === "csv") {
3333
+ var report0;
3334
+ try { report0 = await _buildReport(win); }
3335
+ catch (e2) { if (!(e2 instanceof TypeError)) throw e2; report0 = null; }
3336
+ if (report0) {
3337
+ res.status(200);
3338
+ if (res.setHeader) {
3339
+ res.setHeader("content-type", "text/csv; charset=utf-8");
3340
+ res.setHeader("content-disposition", "attachment; filename=\"sales-report.csv\"");
3341
+ }
3342
+ var csv0 = _reportCsv(report0);
3343
+ if (res.end) res.end(csv0); else res.send(csv0);
3344
+ return;
3345
+ }
3346
+ }
3347
+ var report;
3348
+ try { report = await _buildReport(win); }
3349
+ catch (e3) {
3350
+ if (!(e3 instanceof TypeError)) throw e3;
3351
+ win = { to: Date.now(), from: Date.now() - b.constants.TIME.days(30) };
3352
+ notice = e3.message.replace(/^salesReports:\s*/, "");
3353
+ report = await _buildReport(win);
3354
+ }
3355
+ _sendHtml(res, 200, renderAdminReports({
3356
+ shop_name: deps.shop_name, nav_available: navAvailable, report: report, notice: notice,
3357
+ }));
3358
+ },
3359
+ ));
3360
+
3361
+ // ---- printable order documents --------------------------------------
3362
+ //
3363
+ // Operator-facing receipt + packing slip for an order. These are the
3364
+ // warehouse/fulfilment paper trail (the customer's own order page is a
3365
+ // separate storefront surface — untouched here). Each renders clean,
3366
+ // self-contained, print-optimized HTML the operator prints or pipes to a
3367
+ // PDF. Mounted only when the corresponding render primitive is wired.
3368
+ //
3369
+ // Failure modes: a malformed order id (TypeError from the primitive's id
3370
+ // reader) → 404; a missing order → 404. Only TypeError is swallowed to a
3371
+ // 404 — any other error propagates to the 500 path.
3372
+
3373
+ // The receipt + packing-slip render primitives emit a self-contained
3374
+ // document driven purely by the order; the shop's own name + contact live
3375
+ // in shop_config, so we read them here and inject a small masthead at the
3376
+ // top of the document `<body>`. Both renderers emit a literal "<body>\n"
3377
+ // marker; the header slots in right after it. When config is unwired or
3378
+ // the keys are unset, the document renders without a masthead (the order
3379
+ // data alone is still a complete, valid document).
3380
+ async function _shopMastheadHtml() {
3381
+ if (!deps.config) return "";
3382
+ var name = null, contact = null;
3383
+ try {
3384
+ name = await deps.config.get("shop.name", deps.shop_name || null);
3385
+ // The setup wizard persists the operator's contact under
3386
+ // "shop.contact_email"; fall back to the conventional
3387
+ // "shop.support_email" key the config module documents.
3388
+ contact = await deps.config.get("shop.contact_email", null);
3389
+ if (contact == null) contact = await deps.config.get("shop.support_email", null);
3390
+ } catch (_e) {
3391
+ // Config read failure must not break the print document — degrade to
3392
+ // no masthead rather than 500-ing the operator's print job.
3393
+ return "";
3394
+ }
3395
+ if (!name && !contact) return "";
3396
+ var parts = "";
3397
+ if (name) parts += "<strong>" + _htmlEscape(String(name)) + "</strong>";
3398
+ if (contact) parts += (name ? "<br>" : "") + _htmlEscape(String(contact));
3399
+ // Inline style is acceptable here: this document is served standalone
3400
+ // (not under the admin console's strict style-src CSP) and is built for
3401
+ // printing, where an external stylesheet round-trip is undesirable.
3402
+ return "<div style=\"margin-bottom:8mm;font-size:11pt;\">" + parts + "</div>\n";
3403
+ }
3404
+
3405
+ function _injectMasthead(html, masthead) {
3406
+ if (!masthead) return html;
3407
+ var marker = "<body>\n";
3408
+ var at = html.indexOf(marker);
3409
+ if (at === -1) return html; // renderer changed shape — emit the doc unaltered
3410
+ return html.slice(0, at + marker.length) + masthead + html.slice(at + marker.length);
3411
+ }
3412
+
3413
+ if (printReceipts) {
3414
+ router.get("/admin/orders/:id/receipt", async function (req, res) {
3415
+ if (!_htmlAuthed(req, expectedToken)) {
3416
+ if (req.method === "GET" || !req.method) return _sendHtml(res, 200, renderAdminLogin({ shop_name: deps.shop_name }));
3417
+ return _problem(res, 401, "unauthorized");
3418
+ }
3419
+ var html;
3420
+ try {
3421
+ html = await printReceipts.htmlPdf({ order_id: req.params.id });
3422
+ } catch (e) {
3423
+ if (e instanceof TypeError) {
3424
+ return _sendHtml(res, 404, renderAdminOrders({
3425
+ shop_name: deps.shop_name, nav_available: navAvailable, orders: [], notice: "Order not found.",
3426
+ }));
3427
+ }
3428
+ return _problem(res, 500, "internal-error", (e && e.message) || String(e));
3429
+ }
3430
+ _sendHtml(res, 200, _injectMasthead(html, await _shopMastheadHtml()));
3431
+ });
3432
+ }
3433
+
3434
+ if (packingSlips) {
3435
+ router.get("/admin/orders/:id/packing-slip", async function (req, res) {
3436
+ if (!_htmlAuthed(req, expectedToken)) {
3437
+ if (req.method === "GET" || !req.method) return _sendHtml(res, 200, renderAdminLogin({ shop_name: deps.shop_name }));
3438
+ return _problem(res, 401, "unauthorized");
3439
+ }
3440
+ var html;
3441
+ try {
3442
+ html = await packingSlips.renderHtml({ order_id: req.params.id });
3443
+ } catch (e) {
3444
+ if (e instanceof TypeError) {
3445
+ return _sendHtml(res, 404, renderAdminOrders({
3446
+ shop_name: deps.shop_name, nav_available: navAvailable, orders: [], notice: "Order not found.",
3447
+ }));
3448
+ }
3449
+ return _problem(res, 500, "internal-error", (e && e.message) || String(e));
3450
+ }
3451
+ _sendHtml(res, 200, _injectMasthead(html, await _shopMastheadHtml()));
3452
+ });
3453
+ }
3454
+
3019
3455
  // ---- analytics ------------------------------------------------------
3020
3456
 
3021
3457
  var analytics = deps.analytics || null;
@@ -4361,6 +4797,7 @@ var ADMIN_NAV_ITEMS = [
4361
4797
  { key: "products", href: "/admin/products", label: "Products" },
4362
4798
  { key: "inventory", href: "/admin/inventory", label: "Inventory" },
4363
4799
  { key: "orders", href: "/admin/orders", label: "Orders" },
4800
+ { key: "reports", href: "/admin/reports", label: "Reports" },
4364
4801
  { key: "customers", href: "/admin/customers", label: "Customers", requires: "customers" },
4365
4802
  { key: "returns", href: "/admin/returns", label: "Returns", requires: "returns" },
4366
4803
  { key: "reviews", href: "/admin/reviews", label: "Reviews", requires: "reviews" },
@@ -4649,6 +5086,123 @@ function renderAdminOrders(opts) {
4649
5086
  return _renderAdminShell(opts.shop_name, "Orders", body, "orders", opts.nav_available);
4650
5087
  }
4651
5088
 
5089
+ // epoch-ms → "YYYY-MM-DD" for a date <input>'s value. Mirrors `_fmtDate`'s
5090
+ // guard so a bad value never throws inside the template.
5091
+ function _dateInputValue(v) {
5092
+ var n = typeof v === "number" ? v : Date.parse(v);
5093
+ if (!isFinite(n)) return "";
5094
+ return new Date(n).toISOString().slice(0, 10);
5095
+ }
5096
+
5097
+ // Sales/revenue report screen. Renders the headline totals (orders,
5098
+ // gross/net revenue, refunds, AOV, refund rate), the order-status funnel,
5099
+ // a by-day revenue table, and the top products — all for the selected
5100
+ // date range. A date-range form lets the operator retune the window; a CSV
5101
+ // link exports the by-day series. `opts.report` is null when reporting
5102
+ // isn't configured (renders the notice only).
5103
+ function renderAdminReports(opts) {
5104
+ opts = opts || {};
5105
+ var report = opts.report;
5106
+ var notice = opts.notice ? "<div class=\"banner banner--warn\">" + _htmlEscape(opts.notice) + "</div>" : "";
5107
+
5108
+ if (!report) {
5109
+ var emptyBody = "<section><h2>Reports</h2>" + notice +
5110
+ "<p class=\"empty\">No sales report available.</p></section>";
5111
+ return _renderAdminShell(opts.shop_name, "Reports", emptyBody, "reports", opts.nav_available);
5112
+ }
5113
+
5114
+ var fromVal = _dateInputValue(report.from);
5115
+ // The window's `to` is exclusive (next UTC midnight after the operator's
5116
+ // chosen end day); render the inclusive end day back into the date input
5117
+ // by stepping one day back.
5118
+ var toVal = _dateInputValue(report.to - b.constants.TIME.days(1));
5119
+
5120
+ // Date-range form (GET so the window lives in the URL — bookmarkable +
5121
+ // shareable). The browser submits calendar-date params; the CSV link
5122
+ // carries the same window as raw epoch-ms.
5123
+ var csvHref = "/admin/reports?format=csv" +
5124
+ "&from=" + encodeURIComponent(String(report.from)) +
5125
+ "&to=" + encodeURIComponent(String(report.to));
5126
+ var rangeForm =
5127
+ "<form method=\"get\" action=\"/admin/reports\" class=\"order-filters\">" +
5128
+ "<label class=\"form-field\"><span>From</span><input type=\"date\" name=\"from-date\" value=\"" + _htmlEscape(fromVal) + "\"></label>" +
5129
+ "<label class=\"form-field\"><span>To</span><input type=\"date\" name=\"to-date\" value=\"" + _htmlEscape(toVal) + "\"></label>" +
5130
+ "<button class=\"btn\" type=\"submit\">Apply</button>" +
5131
+ "<a class=\"btn btn--ghost\" href=\"" + _htmlEscape(csvHref) + "\">Export CSV</a>" +
5132
+ "</form>";
5133
+
5134
+ // Headline stat cards. Money formats through the same pricing helper the
5135
+ // dashboard + order pages use, in the report's headline currency.
5136
+ var cur = report.currency;
5137
+ var statsBlock =
5138
+ "<section><h2>Summary</h2><div class=\"stat-grid\">" +
5139
+ _statCard("Orders", String(report.order_count)) +
5140
+ _statCard("Gross revenue", pricing.format(report.gross_revenue_minor, cur)) +
5141
+ _statCard("Net revenue", pricing.format(report.net_revenue_minor, cur)) +
5142
+ _statCard("Refunds", pricing.format(report.refund_total_minor, cur)) +
5143
+ _statCard("Avg order value", pricing.format(report.aov_minor, cur)) +
5144
+ _statCard("Refund rate", (report.refund_rate_bps / 100).toFixed(2) + "%") +
5145
+ "</div></section>";
5146
+
5147
+ // Order-status funnel — each milestone counts orders that reached at
5148
+ // least that stage within the window.
5149
+ var f = report.by_status || {};
5150
+ var funnelRows = [
5151
+ ["Checkout started", f.checkout_started],
5152
+ ["Payment intent created", f.payment_intent_created],
5153
+ ["Paid", f.paid],
5154
+ ["Fulfilled", f.fulfilled],
5155
+ ["Refunded", f.refunded],
5156
+ ].map(function (pair) {
5157
+ return "<tr><td>" + _htmlEscape(pair[0]) + "</td><td class=\"num\">" + _htmlEscape(String(pair[1] == null ? 0 : pair[1])) + "</td></tr>";
5158
+ }).join("");
5159
+ var funnelBlock =
5160
+ "<section><h2>Order status</h2><div class=\"panel\">" +
5161
+ "<table><thead><tr><th scope=\"col\">Stage</th><th scope=\"col\" class=\"num\">Orders</th></tr></thead><tbody>" + funnelRows + "</tbody></table>" +
5162
+ "</div></section>";
5163
+
5164
+ // Top products by gross revenue across the window.
5165
+ var top = report.top_products || [];
5166
+ var topRows = top.length
5167
+ ? top.map(function (r) {
5168
+ return "<tr><td>" + _htmlEscape(r.sku) + "</td>" +
5169
+ "<td class=\"num\">" + _htmlEscape(String(r.units_sold)) + "</td>" +
5170
+ "<td class=\"num\">" + _htmlEscape(pricing.format(r.gross_revenue_minor, r.currency)) + "</td></tr>";
5171
+ }).join("")
5172
+ : "<tr><td colspan=\"3\" class=\"empty\">No sales in this window.</td></tr>";
5173
+ var topBlock =
5174
+ "<section><h2>Top products</h2><div class=\"panel\">" +
5175
+ "<table><thead><tr><th scope=\"col\">SKU</th><th scope=\"col\" class=\"num\">Units</th><th scope=\"col\" class=\"num\">Revenue</th></tr></thead><tbody>" + topRows + "</tbody></table>" +
5176
+ "</div></section>";
5177
+
5178
+ // By-day revenue series — one row per (day, currency) bucket.
5179
+ var byDay = report.by_day || [];
5180
+ var dayRows = byDay.length
5181
+ ? byDay.map(function (r) {
5182
+ return "<tr>" +
5183
+ "<td>" + _htmlEscape(r.bucket_start) + "</td>" +
5184
+ "<td>" + _htmlEscape(r.currency) + "</td>" +
5185
+ "<td class=\"num\">" + _htmlEscape(String(r.order_count)) + "</td>" +
5186
+ "<td class=\"num\">" + _htmlEscape(pricing.format(r.gross_revenue_minor, r.currency)) + "</td>" +
5187
+ "<td class=\"num\">" + _htmlEscape(pricing.format(r.net_revenue_minor, r.currency)) + "</td>" +
5188
+ "<td class=\"num\">" + _htmlEscape(pricing.format(r.refund_total_minor, r.currency)) + "</td>" +
5189
+ "</tr>";
5190
+ }).join("")
5191
+ : "<tr><td colspan=\"6\" class=\"empty\">No sales in this window.</td></tr>";
5192
+ var dayBlock =
5193
+ "<section><h2>By day</h2><div class=\"panel\">" +
5194
+ "<table><thead><tr><th scope=\"col\">Date</th><th scope=\"col\">Currency</th><th scope=\"col\" class=\"num\">Orders</th><th scope=\"col\" class=\"num\">Gross</th><th scope=\"col\" class=\"num\">Net</th><th scope=\"col\" class=\"num\">Refunds</th></tr></thead><tbody>" + dayRows + "</tbody></table>" +
5195
+ "</div></section>";
5196
+
5197
+ var body =
5198
+ "<section><h2>Reports</h2>" + notice +
5199
+ "<p class=\"meta\">Window: " + _htmlEscape(_fmtDate(report.from)) + " → " + _htmlEscape(_fmtDate(report.to)) + "</p>" +
5200
+ rangeForm +
5201
+ "</section>" +
5202
+ statsBlock + funnelBlock + topBlock + dayBlock;
5203
+ return _renderAdminShell(opts.shop_name, "Reports", body, "reports", opts.nav_available);
5204
+ }
5205
+
4652
5206
  function renderAdminOrder(opts) {
4653
5207
  opts = opts || {};
4654
5208
  var o = opts.order;
@@ -4710,6 +5264,22 @@ function renderAdminOrder(opts) {
4710
5264
  }).filter(Boolean).join(" ");
4711
5265
  var actions = actionForms || "<span class=\"meta\">This order is in a final state — no further changes.</span>";
4712
5266
 
5267
+ // Printable operator documents — a receipt + a packing slip, each opening
5268
+ // the print-optimized HTML in a new tab. Rendered only when the matching
5269
+ // render primitive is wired (the routes mount only then).
5270
+ var docLinks = [];
5271
+ if (opts.can_receipt) {
5272
+ docLinks.push("<a class=\"btn btn--ghost\" href=\"/admin/orders/" + _htmlEscape(o.id) + "/receipt\" target=\"_blank\" rel=\"noopener\">Print receipt</a>");
5273
+ }
5274
+ if (opts.can_packing_slip) {
5275
+ docLinks.push("<a class=\"btn btn--ghost\" href=\"/admin/orders/" + _htmlEscape(o.id) + "/packing-slip\" target=\"_blank\" rel=\"noopener\">Print packing slip</a>");
5276
+ }
5277
+ var documentsPanel = docLinks.length
5278
+ ? "<div class=\"panel mt\"><h3 class=\"subhead\">Documents</h3>" +
5279
+ "<div class=\"order-actions\">" + docLinks.join(" ") + "</div>" +
5280
+ "</div>"
5281
+ : "";
5282
+
4713
5283
  // Shipment + tracking panel. Renders only when the tracking primitive is
4714
5284
  // wired (`can_track`). For each existing shipment: the carrier, status,
4715
5285
  // tracking number (linked to the carrier's public URL when known), and
@@ -4794,6 +5364,7 @@ function renderAdminOrder(opts) {
4794
5364
  "<div class=\"panel mt\"><h3 class=\"subhead\">Actions</h3>" +
4795
5365
  "<div class=\"order-actions\">" + actions + "</div>" +
4796
5366
  "</div>" +
5367
+ documentsPanel +
4797
5368
  trackingPanel +
4798
5369
  "</section>";
4799
5370
  return _renderAdminShell(opts.shop_name, "Order " + o.id.slice(0, 8), body, "orders", opts.nav_available);
@@ -6573,6 +7144,21 @@ function renderAdminProduct(opts) {
6573
7144
  "</form>" +
6574
7145
  "</div>";
6575
7146
 
7147
+ var fileUploadForm = uploadWired
7148
+ ? "<div class=\"panel mt-1 mw-40\">" +
7149
+ "<h3 class=\"subhead\">Upload an image from your device</h3>" +
7150
+ "<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>" +
7151
+ "<form method=\"post\" enctype=\"multipart/form-data\" action=\"/admin/products/" + pid + "/media/upload-file\">" +
7152
+ "<label class=\"form-field\"><span>Image file</span>" +
7153
+ "<input type=\"file\" name=\"file\" accept=\"image/png,image/jpeg,image/webp,image/gif,image/avif,image/svg+xml\" required>" +
7154
+ "<small>Up to 10 MB. The file's bytes are checked against its type.</small>" +
7155
+ "</label>" +
7156
+ _setupField("Alt text", "alt_text", "", "text", "Describes the image for screen readers + SEO.", " maxlength=\"500\"") +
7157
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Upload + attach</button></div>" +
7158
+ "</form>" +
7159
+ "</div>"
7160
+ : "";
7161
+
6576
7162
  var uploadForm = uploadWired
6577
7163
  ? "<div class=\"panel mt-1 mw-40\">" +
6578
7164
  "<h3 class=\"subhead\">Upload media from a URL</h3>" +
@@ -6586,7 +7172,7 @@ function renderAdminProduct(opts) {
6586
7172
  "</div>"
6587
7173
  : "";
6588
7174
 
6589
- var mediaSection = "<section class=\"mt\"><h3 class=\"fs-105\">Media</h3>" + mediaGrid + attachForm + uploadForm + "</section>";
7175
+ var mediaSection = "<section class=\"mt\"><h3 class=\"fs-105\">Media</h3>" + mediaGrid + fileUploadForm + attachForm + uploadForm + "</section>";
6590
7176
 
6591
7177
  // ---- head + assembly -------------------------------------------------
6592
7178
  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.28",
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.28",
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": {