@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 +4 -0
- package/lib/admin.js +592 -6
- package/lib/asset-manifest.json +1 -1
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +2 -0
- package/lib/vendor/blamejs/README.md +1 -1
- package/lib/vendor/blamejs/api-snapshot.json +6 -2
- package/lib/vendor/blamejs/lib/cert.js +71 -9
- package/lib/vendor/blamejs/lib/network-tls.js +81 -5
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.13.45.json +31 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/cert.test.js +44 -0
- package/package.json +1 -1
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
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
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,
|
|
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:
|
|
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");
|
package/lib/asset-manifest.json
CHANGED
package/lib/vendor/MANIFEST.json
CHANGED
|
@@ -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.
|
|
7
|
-
"tag": "v0.13.
|
|
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.
|
|
4
|
-
"createdAt": "2026-05-
|
|
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
|
|
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` →
|
|
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
|
|
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
|
|
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
|
-
//
|
|
1198
|
-
//
|
|
1199
|
-
//
|
|
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
|
-
|
|
1295
|
-
var
|
|
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
|
|
@@ -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