@blamejs/blamejs-shop 0.0.124 → 0.0.127

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/lib/storefront.js CHANGED
@@ -1032,6 +1032,222 @@ function renderSaved(opts) {
1032
1032
  });
1033
1033
  }
1034
1034
 
1035
+ // One labelled text input for the address form. `required` and other
1036
+ // attrs are passed through; `value` is pre-filled (escaped) for edit.
1037
+ function _addrField(name, labelText, value, opts) {
1038
+ var esc = _b().template.escapeHtml;
1039
+ opts = opts || {};
1040
+ var attrs = "";
1041
+ if (opts.required) attrs += " required";
1042
+ if (opts.maxlength) attrs += " maxlength=\"" + opts.maxlength + "\"";
1043
+ if (opts.pattern) attrs += " pattern=\"" + esc(opts.pattern) + "\"";
1044
+ if (opts.autocomplete) attrs += " autocomplete=\"" + esc(opts.autocomplete) + "\"";
1045
+ var req = opts.required ? " <span class=\"form-field__req\" aria-hidden=\"true\">*</span>" : "";
1046
+ return "<label class=\"form-field\">" +
1047
+ "<span class=\"form-field__label\">" + esc(labelText) + req + "</span>" +
1048
+ "<input type=\"text\" name=\"" + esc(name) + "\" value=\"" + esc(value == null ? "" : String(value)) + "\"" + attrs + ">" +
1049
+ "</label>";
1050
+ }
1051
+
1052
+ // Shared add/edit address form. `addr` pre-fills for edit (null = add).
1053
+ function _addressForm(action, addr, submitLabel) {
1054
+ var esc = _b().template.escapeHtml;
1055
+ addr = addr || {};
1056
+ function _checked(v) { return Number(v) === 1 ? " checked" : ""; }
1057
+ return "<form class=\"address-form form-stack\" method=\"post\" action=\"" + esc(action) + "\">" +
1058
+ _addrField("recipient_name", "Recipient name", addr.recipient_name, { required: true, maxlength: 120, autocomplete: "name" }) +
1059
+ _addrField("label", "Label (e.g. Home, Work)", addr.label, { maxlength: 60 }) +
1060
+ _addrField("company", "Company", addr.company, { maxlength: 120, autocomplete: "organization" }) +
1061
+ _addrField("street_line1", "Street address", addr.street_line1, { required: true, maxlength: 200, autocomplete: "address-line1" }) +
1062
+ _addrField("street_line2", "Apt / suite / unit", addr.street_line2, { maxlength: 200, autocomplete: "address-line2" }) +
1063
+ "<div class=\"form-row form-row--inline\">" +
1064
+ _addrField("city", "City", addr.city, { required: true, maxlength: 120, autocomplete: "address-level2" }) +
1065
+ _addrField("region", "State / region", addr.region, { maxlength: 120, autocomplete: "address-level1" }) +
1066
+ "</div>" +
1067
+ "<div class=\"form-row form-row--inline\">" +
1068
+ _addrField("postal_code", "Postal code", addr.postal_code, { required: true, maxlength: 32, autocomplete: "postal-code" }) +
1069
+ _addrField("country", "Country (ISO 3166-1)", addr.country || "US", { required: true, maxlength: 2, pattern: "[A-Za-z]{2}", autocomplete: "country" }) +
1070
+ "</div>" +
1071
+ _addrField("phone", "Phone", addr.phone, { maxlength: 40, autocomplete: "tel" }) +
1072
+ "<label class=\"address-form__check\"><input type=\"checkbox\" name=\"is_default_shipping\" value=\"1\"" + _checked(addr.is_default_shipping) + "> Default shipping address</label>" +
1073
+ "<label class=\"address-form__check\"><input type=\"checkbox\" name=\"is_default_billing\" value=\"1\"" + _checked(addr.is_default_billing) + "> Default billing address</label>" +
1074
+ "<button type=\"submit\" class=\"btn-primary\">" + esc(submitLabel) + "</button>" +
1075
+ "</form>";
1076
+ }
1077
+
1078
+ // Account address book. `opts.addresses` is the customer's non-archived
1079
+ // rows; `opts.edit` (when set) pre-fills the form for editing that row,
1080
+ // otherwise the form is a blank "add" form.
1081
+ function renderAddresses(opts) {
1082
+ var esc = _b().template.escapeHtml;
1083
+ var list = opts.addresses || [];
1084
+ var rowsHtml = "";
1085
+ for (var i = 0; i < list.length; i += 1) {
1086
+ var a = list[i];
1087
+ var badges =
1088
+ (Number(a.is_default_shipping) === 1 ? "<span class=\"address-card__badge\">Default shipping</span>" : "") +
1089
+ (Number(a.is_default_billing) === 1 ? "<span class=\"address-card__badge\">Default billing</span>" : "");
1090
+ var lines = [a.recipient_name, a.company, a.street_line1, a.street_line2,
1091
+ [a.city, a.region, a.postal_code].filter(Boolean).join(", "), a.country, a.phone]
1092
+ .filter(function (x) { return x != null && String(x).length; })
1093
+ .map(function (x) { return "<span>" + esc(String(x)) + "</span>"; }).join("");
1094
+ rowsHtml +=
1095
+ "<li class=\"address-card\">" +
1096
+ (a.label ? "<p class=\"address-card__label\">" + esc(a.label) + "</p>" : "") +
1097
+ (badges ? "<p class=\"address-card__badges\">" + badges + "</p>" : "") +
1098
+ "<address class=\"address-card__body\">" + lines + "</address>" +
1099
+ "<div class=\"address-card__actions\">" +
1100
+ "<a class=\"btn-ghost btn-ghost--sm\" href=\"/account/addresses/" + esc(a.id) + "/edit\">Edit</a>" +
1101
+ (Number(a.is_default_shipping) === 1 ? "" : "<form method=\"post\" action=\"/account/addresses/" + esc(a.id) + "/default-shipping\"><button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">Set default shipping</button></form>") +
1102
+ (Number(a.is_default_billing) === 1 ? "" : "<form method=\"post\" action=\"/account/addresses/" + esc(a.id) + "/default-billing\"><button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">Set default billing</button></form>") +
1103
+ "<form method=\"post\" action=\"/account/addresses/" + esc(a.id) + "/archive\"><button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">Remove</button></form>" +
1104
+ "</div>" +
1105
+ "</li>";
1106
+ }
1107
+ var listHtml = rowsHtml
1108
+ ? "<ul class=\"address-list\">" + rowsHtml + "</ul>"
1109
+ : "<p class=\"address-empty\">No saved addresses yet. Add one below to speed up checkout.</p>";
1110
+ var notice = opts.notice
1111
+ ? "<p class=\"form-notice form-notice--error\" role=\"alert\">" + esc(String(opts.notice)) + "</p>"
1112
+ : "";
1113
+ var editing = opts.edit || null;
1114
+ var formHeading = editing ? "Edit address" : "Add an address";
1115
+ var formAction = editing ? ("/account/addresses/" + editing.id) : "/account/addresses";
1116
+ var body =
1117
+ "<section class=\"account-addresses\">" +
1118
+ "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
1119
+ "<li><a href=\"/account\">Account</a></li>" +
1120
+ "<li aria-current=\"page\">Addresses</li>" +
1121
+ "</ol></nav>" +
1122
+ "<h1 class=\"account-addresses__title\">Addresses</h1>" +
1123
+ listHtml +
1124
+ "<h2 class=\"account-addresses__form-title\">" + esc(formHeading) + "</h2>" +
1125
+ notice +
1126
+ _addressForm(formAction, editing, editing ? "Save changes" : "Add address") +
1127
+ "</section>";
1128
+ return _wrap({
1129
+ title: "Addresses",
1130
+ shop_name: opts.shop_name || "blamejs.shop",
1131
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
1132
+ theme_css: opts.theme_css,
1133
+ body: body,
1134
+ });
1135
+ }
1136
+
1137
+ var RETURN_REASONS = [
1138
+ ["defective", "Defective / doesn't work"],
1139
+ ["wrong-item", "Wrong item received"],
1140
+ ["not-as-described", "Not as described"],
1141
+ ["no-longer-needed", "No longer needed"],
1142
+ ["damaged-in-transit", "Damaged in transit"],
1143
+ ["other", "Other"],
1144
+ ];
1145
+
1146
+ function _returnStatusBadge(status) {
1147
+ return "<span class=\"return-status return-status--" + _b().template.escapeHtml(String(status)) + "\">" +
1148
+ _b().template.escapeHtml(String(status)) + "</span>";
1149
+ }
1150
+
1151
+ // Customer-facing return-request form for one order. `opts.order` is the
1152
+ // order row, `opts.lines` its order_lines. `opts.notice` is an optional
1153
+ // error bounced back from a failed POST.
1154
+ function renderReturnForm(opts) {
1155
+ var esc = _b().template.escapeHtml;
1156
+ var order = opts.order;
1157
+ var lines = opts.lines || [];
1158
+ var lineRows = "";
1159
+ for (var i = 0; i < lines.length; i += 1) {
1160
+ var l = lines[i];
1161
+ lineRows +=
1162
+ "<li class=\"return-line\">" +
1163
+ "<label class=\"return-line__pick\">" +
1164
+ "<input type=\"checkbox\" name=\"return_" + esc(l.id) + "\" value=\"1\">" +
1165
+ "<span class=\"return-line__sku\"><code>" + esc(l.sku) + "</code></span>" +
1166
+ "</label>" +
1167
+ "<label class=\"return-line__qty\">Qty to return " +
1168
+ "<input type=\"number\" name=\"qty_" + esc(l.id) + "\" value=\"" + (Number(l.qty) || 1) + "\" min=\"1\" max=\"" + (Number(l.qty) || 1) + "\">" +
1169
+ " <span class=\"return-line__of\">of " + (Number(l.qty) || 1) + "</span>" +
1170
+ "</label>" +
1171
+ "</li>";
1172
+ }
1173
+ var reasonOpts = RETURN_REASONS.map(function (r) {
1174
+ return "<option value=\"" + esc(r[0]) + "\">" + esc(r[1]) + "</option>";
1175
+ }).join("");
1176
+ var notice = opts.notice
1177
+ ? "<p class=\"form-notice form-notice--error\" role=\"alert\">" + esc(String(opts.notice)) + "</p>"
1178
+ : "";
1179
+ var body =
1180
+ "<section class=\"return-form-page\">" +
1181
+ "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
1182
+ "<li><a href=\"/account\">Account</a></li>" +
1183
+ "<li><a href=\"/account/returns\">Returns</a></li>" +
1184
+ "<li aria-current=\"page\">Request a return</li>" +
1185
+ "</ol></nav>" +
1186
+ "<h1 class=\"return-form-page__title\">Request a return</h1>" +
1187
+ "<p class=\"return-form-page__order\">Order <code>" + esc(order.id) + "</code></p>" +
1188
+ notice +
1189
+ "<form class=\"return-form form-stack\" method=\"post\" action=\"/account/orders/" + esc(order.id) + "/return\">" +
1190
+ "<fieldset class=\"return-form__lines\"><legend>Which items?</legend>" +
1191
+ "<ul class=\"return-line-list\">" + lineRows + "</ul>" +
1192
+ "</fieldset>" +
1193
+ "<label class=\"form-field\"><span class=\"form-field__label\">Reason</span>" +
1194
+ "<select name=\"reason\" required>" + reasonOpts + "</select></label>" +
1195
+ "<label class=\"form-field\"><span class=\"form-field__label\">Notes (optional)</span>" +
1196
+ "<textarea name=\"customer_notes\" maxlength=\"2000\" rows=\"4\"></textarea></label>" +
1197
+ "<button type=\"submit\" class=\"btn-primary\">Request return</button>" +
1198
+ "</form>" +
1199
+ "</section>";
1200
+ return _wrap({
1201
+ title: "Request a return",
1202
+ shop_name: opts.shop_name || "blamejs.shop",
1203
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
1204
+ theme_css: opts.theme_css,
1205
+ body: body,
1206
+ });
1207
+ }
1208
+
1209
+ // Customer's return-authorization list.
1210
+ function renderReturns(opts) {
1211
+ var esc = _b().template.escapeHtml;
1212
+ var rmas = opts.rmas || [];
1213
+ var rowsHtml = "";
1214
+ for (var i = 0; i < rmas.length; i += 1) {
1215
+ var r = rmas[i];
1216
+ var date = r.created_at ? new Date(Number(r.created_at)).toISOString().slice(0, 10) : "";
1217
+ rowsHtml +=
1218
+ "<li class=\"return-card\">" +
1219
+ "<div class=\"return-card__head\">" +
1220
+ "<code class=\"return-card__rma\">" + esc(r.rma_code) + "</code>" +
1221
+ _returnStatusBadge(r.status) +
1222
+ "</div>" +
1223
+ "<p class=\"return-card__meta\">" + esc(String(r.reason || "")) +
1224
+ (date ? " &middot; <time datetime=\"" + esc(date) + "\">" + esc(date) + "</time>" : "") +
1225
+ (Number(r.refund_amount_minor) > 0 ? " &middot; refund " + esc(pricing.format(Number(r.refund_amount_minor), r.refund_currency || "USD")) : "") +
1226
+ "</p>" +
1227
+ (r.status === "rejected" && r.rejected_reason ? "<p class=\"return-card__reject\">" + esc(String(r.rejected_reason)) + "</p>" : "") +
1228
+ "</li>";
1229
+ }
1230
+ var inner = rowsHtml
1231
+ ? "<ul class=\"return-list\">" + rowsHtml + "</ul>"
1232
+ : "<p class=\"return-empty\">No returns yet. Start one from an order in your account.</p>";
1233
+ var body =
1234
+ "<section class=\"account-returns\">" +
1235
+ "<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
1236
+ "<li><a href=\"/account\">Account</a></li>" +
1237
+ "<li aria-current=\"page\">Returns</li>" +
1238
+ "</ol></nav>" +
1239
+ "<h1 class=\"account-returns__title\">Returns</h1>" +
1240
+ inner +
1241
+ "</section>";
1242
+ return _wrap({
1243
+ title: "Returns",
1244
+ shop_name: opts.shop_name || "blamejs.shop",
1245
+ cart_count: opts.cart_count == null ? 0 : opts.cart_count,
1246
+ theme_css: opts.theme_css,
1247
+ body: body,
1248
+ });
1249
+ }
1250
+
1035
1251
  // Product-level "Save to wishlist" control + social-proof count.
1036
1252
  // Byte-compatible with the edge renderer (`worker/render/product.js`)
1037
1253
  // so both paths emit identical markup. Action-only label — the toggle
@@ -1963,6 +2179,8 @@ var ACCOUNT_DASH_PAGE =
1963
2179
  " <div class=\"account-dash__actions\">\n" +
1964
2180
  " <a class=\"btn-secondary\" href=\"/account/wishlist\">Wishlist</a>\n" +
1965
2181
  " <a class=\"btn-secondary\" href=\"/account/saved\">Saved for later</a>\n" +
2182
+ " <a class=\"btn-secondary\" href=\"/account/addresses\">Addresses</a>\n" +
2183
+ " <a class=\"btn-secondary\" href=\"/account/returns\">Returns</a>\n" +
1966
2184
  " <form method=\"post\" action=\"/account/logout\"><button type=\"submit\" class=\"btn-ghost\">Sign out</button></form>\n" +
1967
2185
  " </div>\n" +
1968
2186
  " </header>\n" +
@@ -2988,6 +3206,225 @@ function mount(router, deps) {
2988
3206
  });
2989
3207
  }
2990
3208
 
3209
+ // Address book — per-customer saved addresses. Every by-id route
3210
+ // verifies the address belongs to the authed customer before acting
3211
+ // (the primitive operates by id alone, so ownership is enforced here
3212
+ // to prevent cross-customer access via a guessed id).
3213
+ if (deps.addresses) {
3214
+ function _addrAuth(req, res) {
3215
+ var auth;
3216
+ try { auth = _currentCustomer(req); }
3217
+ catch (e) {
3218
+ if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
3219
+ throw e;
3220
+ }
3221
+ if (!auth) {
3222
+ res.status(303); res.setHeader && res.setHeader("location", "/account/login");
3223
+ res.end ? res.end() : res.send("");
3224
+ return null;
3225
+ }
3226
+ return auth;
3227
+ }
3228
+ async function _ownedAddress(req, res, auth) {
3229
+ var addr;
3230
+ try {
3231
+ addr = await deps.addresses.get(req.params && req.params.id);
3232
+ } catch (e) {
3233
+ // `get` throws TypeError on a non-UUID id — a malformed path
3234
+ // segment is a 404, not a 500.
3235
+ if (e instanceof TypeError) { _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme })); return null; }
3236
+ throw e;
3237
+ }
3238
+ if (!addr || addr.customer_id !== auth.customer_id || Number(addr.is_archived) === 1) {
3239
+ _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
3240
+ return null;
3241
+ }
3242
+ return addr;
3243
+ }
3244
+ async function _renderAddrPage(req, res, auth, editAddr, notice, code) {
3245
+ var rows = await deps.addresses.listForCustomer(auth.customer_id, { limit: 50 });
3246
+ var cartCount = await _cartCountForReq(req);
3247
+ _send(res, code || 200, renderAddresses({
3248
+ addresses: rows,
3249
+ edit: editAddr || null,
3250
+ notice: notice || null,
3251
+ shop_name: shopName,
3252
+ cart_count: cartCount,
3253
+ }));
3254
+ }
3255
+ function _addrInput(body, customerId) {
3256
+ return {
3257
+ customer_id: customerId,
3258
+ label: body.label,
3259
+ recipient_name: body.recipient_name,
3260
+ company: body.company,
3261
+ street_line1: body.street_line1,
3262
+ street_line2: body.street_line2,
3263
+ city: body.city,
3264
+ region: body.region,
3265
+ postal_code: body.postal_code,
3266
+ country: body.country,
3267
+ phone: body.phone,
3268
+ // Checkboxes arrive as "1" when ticked, absent otherwise — the
3269
+ // primitive's _bool wants an integer, so coerce here.
3270
+ is_default_shipping: body.is_default_shipping === "1" ? 1 : 0,
3271
+ is_default_billing: body.is_default_billing === "1" ? 1 : 0,
3272
+ };
3273
+ }
3274
+
3275
+ router.get("/account/addresses", async function (req, res) {
3276
+ var auth = _addrAuth(req, res); if (!auth) return;
3277
+ await _renderAddrPage(req, res, auth, null);
3278
+ });
3279
+
3280
+ router.get("/account/addresses/:id/edit", async function (req, res) {
3281
+ var auth = _addrAuth(req, res); if (!auth) return;
3282
+ var addr = await _ownedAddress(req, res, auth); if (!addr) return;
3283
+ await _renderAddrPage(req, res, auth, addr);
3284
+ });
3285
+
3286
+ router.post("/account/addresses", async function (req, res) {
3287
+ var auth = _addrAuth(req, res); if (!auth) return;
3288
+ try {
3289
+ await deps.addresses.add(_addrInput(req.body || {}, auth.customer_id));
3290
+ } catch (e) {
3291
+ if (e instanceof TypeError) return _renderAddrPage(req, res, auth, null, (e && e.message) || "Please check the address.", 400);
3292
+ throw e;
3293
+ }
3294
+ res.status(303); res.setHeader && res.setHeader("location", "/account/addresses");
3295
+ return res.end ? res.end() : res.send("");
3296
+ });
3297
+
3298
+ router.post("/account/addresses/:id", async function (req, res) {
3299
+ var auth = _addrAuth(req, res); if (!auth) return;
3300
+ var addr = await _ownedAddress(req, res, auth); if (!addr) return;
3301
+ try {
3302
+ await deps.addresses.update(addr.id, _addrInput(req.body || {}, auth.customer_id));
3303
+ } catch (e) {
3304
+ if (e instanceof TypeError) {
3305
+ var merged = Object.assign({}, addr, _addrInput(req.body || {}, auth.customer_id));
3306
+ return _renderAddrPage(req, res, auth, merged, (e && e.message) || "Please check the address.", 400);
3307
+ }
3308
+ throw e;
3309
+ }
3310
+ res.status(303); res.setHeader && res.setHeader("location", "/account/addresses");
3311
+ return res.end ? res.end() : res.send("");
3312
+ });
3313
+
3314
+ function _addrAction(verb, fn) {
3315
+ router.post("/account/addresses/:id/" + verb, async function (req, res) {
3316
+ var auth = _addrAuth(req, res); if (!auth) return;
3317
+ var addr = await _ownedAddress(req, res, auth); if (!addr) return;
3318
+ try { await fn(addr.id); }
3319
+ catch (e) {
3320
+ res.status(e instanceof TypeError ? 400 : 500);
3321
+ return res.end ? res.end((e && e.message) || "Error") : res.send((e && e.message) || "Error");
3322
+ }
3323
+ res.status(303); res.setHeader && res.setHeader("location", "/account/addresses");
3324
+ return res.end ? res.end() : res.send("");
3325
+ });
3326
+ }
3327
+ _addrAction("default-shipping", function (id) { return deps.addresses.setDefaultShipping(id); });
3328
+ _addrAction("default-billing", function (id) { return deps.addresses.setDefaultBilling(id); });
3329
+ _addrAction("archive", function (id) { return deps.addresses.archive(id); });
3330
+ }
3331
+
3332
+ // Self-serve returns — a customer requests an RMA against one of
3333
+ // their own orders and tracks its status. Operators action it via
3334
+ // the admin /admin/returns queue. Needs the returns primitive + an
3335
+ // order handle (to load + ownership-check the order being returned).
3336
+ if (deps.returns && deps.order) {
3337
+ function _returnsAuth(req, res) {
3338
+ var auth;
3339
+ try { auth = _currentCustomer(req); }
3340
+ catch (e) {
3341
+ if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return null; }
3342
+ throw e;
3343
+ }
3344
+ if (!auth) {
3345
+ res.status(303); res.setHeader && res.setHeader("location", "/account/login");
3346
+ res.end ? res.end() : res.send("");
3347
+ return null;
3348
+ }
3349
+ return auth;
3350
+ }
3351
+ // Load the order named in :order_id and confirm it belongs to the
3352
+ // signed-in customer. A malformed id (guardUuid TypeError), a
3353
+ // missing order, or someone else's order all return 404 — never a
3354
+ // 500, never a leak of another customer's order.
3355
+ async function _ownedOrder(req, res, auth) {
3356
+ var order;
3357
+ try { order = await deps.order.get(req.params && req.params.order_id); }
3358
+ catch (e) {
3359
+ if (e instanceof TypeError) { _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme })); return null; }
3360
+ throw e;
3361
+ }
3362
+ if (!order || order.customer_id !== auth.customer_id) {
3363
+ _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
3364
+ return null;
3365
+ }
3366
+ return order;
3367
+ }
3368
+
3369
+ router.get("/account/returns", async function (req, res) {
3370
+ var auth = _returnsAuth(req, res); if (!auth) return;
3371
+ var page = await deps.returns.listForCustomer(auth.customer_id, { limit: 50 });
3372
+ var cartCount = await _cartCountForReq(req);
3373
+ _send(res, 200, renderReturns({ rmas: page.rows, shop_name: shopName, cart_count: cartCount }));
3374
+ });
3375
+
3376
+ router.get("/account/orders/:order_id/return", async function (req, res) {
3377
+ var auth = _returnsAuth(req, res); if (!auth) return;
3378
+ var order = await _ownedOrder(req, res, auth); if (!order) return;
3379
+ var cartCount = await _cartCountForReq(req);
3380
+ _send(res, 200, renderReturnForm({ order: order, lines: order.lines || [], shop_name: shopName, cart_count: cartCount }));
3381
+ });
3382
+
3383
+ router.post("/account/orders/:order_id/return", async function (req, res) {
3384
+ var auth = _returnsAuth(req, res); if (!auth) return;
3385
+ var order = await _ownedOrder(req, res, auth); if (!order) return;
3386
+ var body = req.body || {};
3387
+ var cartCount = await _cartCountForReq(req);
3388
+ // Build the return lines from the order's own lines (authoritative
3389
+ // sku/qty), keyed by the checkboxes the customer ticked — never
3390
+ // trust a client-supplied sku.
3391
+ var orderLines = order.lines || [];
3392
+ var picked = [];
3393
+ for (var i = 0; i < orderLines.length; i += 1) {
3394
+ var ol = orderLines[i];
3395
+ if (body["return_" + ol.id] !== "1") continue;
3396
+ var wanted = parseInt(body["qty_" + ol.id], 10);
3397
+ var qty = Number.isFinite(wanted) && wanted >= 1 && wanted <= ol.qty ? wanted : ol.qty;
3398
+ picked.push({ order_line_id: ol.id, sku: ol.sku, qty: qty });
3399
+ }
3400
+ if (picked.length === 0) {
3401
+ return _send(res, 400, renderReturnForm({
3402
+ order: order, lines: orderLines, notice: "Select at least one item to return.",
3403
+ shop_name: shopName, cart_count: cartCount,
3404
+ }));
3405
+ }
3406
+ try {
3407
+ await deps.returns.request({
3408
+ order_id: order.id,
3409
+ customer_id: auth.customer_id,
3410
+ reason: body.reason,
3411
+ customer_notes: body.customer_notes,
3412
+ lines: picked,
3413
+ });
3414
+ } catch (e) {
3415
+ if (e instanceof TypeError) {
3416
+ return _send(res, 400, renderReturnForm({
3417
+ order: order, lines: orderLines, notice: (e && e.message) || "Please check your return request.",
3418
+ shop_name: shopName, cart_count: cartCount,
3419
+ }));
3420
+ }
3421
+ throw e;
3422
+ }
3423
+ res.status(303); res.setHeader && res.setHeader("location", "/account/returns");
3424
+ return res.end ? res.end() : res.send("");
3425
+ });
3426
+ }
3427
+
2991
3428
  // Product reviews — submission requires a logged-in customer AND a
2992
3429
  // verified purchase of the product (the gate, not just a badge).
2993
3430
  // Only mounts when both the reviews primitive and an order handle
@@ -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.12.29",
7
- "tag": "v0.12.29",
6
+ "version": "0.12.33",
7
+ "tag": "v0.12.33",
8
8
  "license": "Apache-2.0",
9
9
  "author": "blamejs contributors",
10
10
  "source": "https://github.com/blamejs/blamejs",
@@ -8,6 +8,14 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.12.x
10
10
 
11
+ - v0.12.33 (2026-05-24) — **`b.cose` — COSE_Sign1 sign / verify (RFC 9052) over the in-tree CBOR codec.** COSE is the signed-statement substrate under SCITT, CWT, and C2PA — the CBOR-native counterpart to JWS. `b.cose` ships COSE_Sign1 signing and verification composing the v0.12.32 `b.cbor` codec for the deterministic Sig_structure encoding. It signs with the classical COSE algorithms that interoperate today — ES256 / ES384 / ES512 (ECDSA) and EdDSA (Ed25519), all with final IANA algorithm ids (RFC 9053) — and with ML-DSA-87 (FIPS 204) for PQC-forward deployments. Verification accepts the same set, so the framework both produces COSE other implementations read today and consumes third-party COSE. There is no classical default: the caller names the algorithm and supplies the key. **Added:** *`b.cose.sign(payload, opts)` / `b.cose.verify(coseSign1, opts)`* — `sign` produces a tagged COSE_Sign1 with `alg` in the integrity-protected header; `verify` returns `{ payload, alg, protectedHeaders, unprotectedHeaders }`. The Sig_structure (`["Signature1", protected, external_aad, payload]`) is deterministically CBOR-encoded; ECDSA signatures use the IEEE-P1363 fixed-width encoding COSE mandates (RFC 9053 §2.1), not ASN.1 DER. `external_aad` is bound into the signature. v1 is single-signer with an attached payload; detached payload, COSE_Sign (multi-signer), COSE_Mac0, and COSE_Encrypt are deferred-with-condition (operator demand). **Security:** *Bounded, alg-allowlisted, crit-checked verification* — `verify` decodes the COSE_Sign1 bytes AND the protected-header bstr through the bounded `b.cbor.decode` (depth + size caps, indefinite-length / tag / duplicate-key refusal). `opts.algorithms` is a required allowlist (no defaults — name the accepted algorithms). A `crit` header (label 2) listing a header label the verifier does not understand is refused (RFC 9052 §3.1 crit-bypass defense), as is a `crit` label absent from the protected header. The COSE algorithm switch refuses any unrecognized id at the default branch. · *ML-DSA-87 COSE algorithm id is a non-final draft* — ML-DSA-87 uses COSE algorithm id `-50`, a requested (non-final) IANA assignment from draft-ietf-cose-dilithium — an ML-DSA-87 COSE_Sign1 is not yet broadly interoperable and the id may change; it is pinned deliberately with the re-open condition being IANA finalization. SLH-DSA-SHAKE-256f has no registered COSE algorithm id at all and cannot be represented in COSE. The COSE_Sign1 mechanism and the classical algorithms are stable; ML-DSA-87 is the forward-looking opt-in.
12
+
13
+ - v0.12.32 (2026-05-24) — **`b.cbor` — bounded, deterministic in-tree CBOR codec (RFC 8949).** CBOR is the binary serialization underneath COSE (RFC 9052), CWT, SCITT, and WebAuthn attestation — a foundational substrate the framework needs in-tree to build signed-statement primitives without a third-party parser. `b.cbor` is that codec, bounded by default like every parser the framework ships: a binary decoder is attack surface, so the defaults refuse the shapes a hostile encoder uses to exhaust memory or stack. The encoder emits Deterministically Encoded CBOR (RFC 8949 §4.2) — shortest-form heads, definite lengths, map keys sorted by encoded bytes, no indefinite-length items — so two semantically-equal values encode to byte-identical output, the property COSE signatures and SCITT receipts depend on. **Added:** *`b.cbor.encode(value, opts?)` / `b.cbor.decode(buffer, opts?)` / `b.cbor.Tag`* — `encode` produces deterministic CBOR from numbers (integers + float64), bigint (64-bit range), strings, `Buffer` / `Uint8Array`, arrays, `Map` or plain objects, `b.cbor.Tag`, and the simple values. `decode` returns the value with maps decoded to a `Map` (CBOR keys may be integers — COSE header labels are) and byte strings to `Buffer`. `b.cbor.Tag(tag, value)` carries a major-type-6 tagged item. `decode(buf, { requireDeterministic: true })` additionally asserts the input was itself canonically encoded (decode → re-encode → byte-compare), refusing a non-canonical re-encoding on a signature-verify path where it would be a malleability vector. **Security:** *Bounded-by-default decoder* — `maxDepth` (default 64, ceiling 256) caps nesting against stack exhaustion; `maxBytes` (default 16 MiB, ceiling 64 MiB) caps total input, and a declared string / array / map length exceeding the remaining bytes is refused before any allocation (no length-prefix memory bomb). Indefinite-length items (additional-info 31) are refused — a streaming-complexity / DoS vector forbidden by deterministic encoding. Reserved additional-info (28–30) is refused. Tags are refused unless allowlisted via `allowedTags` (a tag triggers semantic reprocessing — an un-vetted tag is a confused-deputy vector). Duplicate map keys (RFC 8949 §5.6) and trailing bytes after the data item are refused.
14
+
15
+ - v0.12.31 (2026-05-24) — **`b.auth.jar.parse` — verify RFC 9101 JWT-Secured Authorization Requests (server side).** A plain OAuth authorization request carries its parameters in the URL query string, where a browser, proxy, or referer log can tamper with or leak them. RFC 9101 JAR packs those parameters into a JWT the client signs — the request object — so the authorization server can confirm they arrived exactly as sent. `b.auth.jar.parse(jar, opts)` is the server-side verifier and the request-side counterpart to the existing JARM response handling (`b.auth.oauth.parseJarmResponse`). It delegates the signature check to `b.auth.jwt.verifyExternal` — which already enforces a mandatory `algorithms` allowlist and refuses the alg-confusion (`alg: "none"`, HMAC-vs-RSA) and JWE-on-a-JWS-verifier shapes against a JWKS public-key trust source — then pins `iss` and the `client_id` claim to the expected client, pins `aud` to this server's issuer identifier, refuses a nested `request` / `request_uri` (RFC 9101 §6.3 recursion / confused-deputy vector), and returns the authorization parameters with the JWT envelope claims stripped. **Added:** *`b.auth.jar.parse(jar, opts)` — request-object verification* — `opts.clientId` (the expected client — pins `iss` + the `client_id` claim), `opts.audience` (this server's issuer identifier — pins `aud`), `opts.algorithms` (required signature allowlist — no defaults, the alg-confusion defense), and one of `opts.jwks` / `opts.jwksUri` / `opts.keyResolver` (the client's verification key). Returns `{ params, claims }` where `params` is the authorization parameters (`response_type`, `redirect_uri`, `scope`, `state`, `nonce`, …) with the JWT envelope claims (`iss`, `aud`, `exp`, `iat`, `nbf`, `jti`) removed. A request object whose `client_id` claim disagrees with `opts.clientId`, or that nests a `request` / `request_uri`, is refused. Emitting a request object (the client side) is deferred-with-condition: it requires signing with the client's key under a classical JWS algorithm, and the framework's own JWT signer is PQC-only for the tokens it issues — a PQC-signed request object would not interoperate with a standard authorization server; client-side emission re-opens when a classical JWS signer lands or operators surface the need. Until then clients sign request objects with their existing JOSE tooling.
16
+
17
+ - v0.12.30 (2026-05-24) — **`bundleAdapterStorage.keyRotation(opts)` — verified whole-repository envelope key rotation.** Rotating the key that wraps a backup repository is only safe if you can prove every bundle still reads under the new key — a rotation that silently corrupts one bundle is a time-bomb the operator discovers at restore time, exactly when they can least afford it. `storage.keyRotation(opts)` rotates every bundle's envelope from the old key to the new key (composing `rewrapAllBundles`) and then re-reads every bundle under the NEW key (composing `verifyAllBundles`), so a bad rotation surfaces as `verifyFailed > 0` immediately instead of at restore. It emits a `backup/key-rotated` audit event with the rotation id + per-status counts — a key-rotation event is a compliance record (SOC 2 CC6.1, PCI DSS 3.6.4) operators wire into their signed audit chain. Works for both `recipient` (hybrid PQC envelope) and `passphrase` (Argon2id) storage; refused cleanly on plaintext (`cryptoStrategy: "none"`) storage and when the new key is missing. **Added:** *`bundleAdapterStorage.keyRotation(opts)` — rotate then prove* — `opts.newRecipient` / `opts.newPassphrase` is the key bundles rotate TO (matched to the storage's `cryptoStrategy`); `opts.oldRecipient` / `opts.oldPassphrase` unwraps the current envelope when it differs from the configured key. Returns `{ rotationId, rotatedAt, total, rotated, skipped, failed, verified, verifyFailed, rotateResults, verifyResults }`. `opts.verify` (default true) runs the post-rotation read-back under the new key; `opts.concurrency` / `opts.stopOnFirstFailure` forward to the batch passes. Plaintext bundles + non-wrappable formats are skipped cleanly; a rotation that leaves any bundle unreadable reports `verifyFailed > 0` and emits the audit event with `outcome: "failure"`. A true overlap window where BOTH the old and new key decrypt a bundle (`dualWrap: true`) is refused with `backup/dual-wrap-unsupported` — it needs multi-recipient archive envelopes `b.archive.wrap` does not yet emit, and re-opens when the wrap layer gains them; until then stage a rotation by keeping the old key available to readers until `keyRotation` reports `failed: 0` + `verifyFailed: 0`, then retire it.
18
+
11
19
  - v0.12.29 (2026-05-24) — **`b.ai.dp` — float-safe differential privacy: snapping-mechanism Laplace + discrete Gaussian + Rényi-DP budgets.** Differential privacy adds calibrated noise so an aggregate is provably insensitive to any single record — but the guarantee is fragile: Mironov (2012) showed that a Laplace mechanism sampled with naive double-precision floats lets an attacker distinguish neighbouring datasets with > 35% probability from a single output, silently destroying the promise. `b.ai.dp` ships only mechanisms whose sampling is hardened against that attack class: Laplace via the snapping mechanism (clamp + CSPRNG sign + full-mantissa uniform + power-of-two-grid rounding) and the discrete Gaussian (Canonne–Kamath–Steinke 2020) via integer-exact rejection sampling built from Bernoulli(exp(−γ)) over exact rationals — no floating-point noise at all. All randomness comes from `b.crypto.generateBytes` (SHAKE256 over the OS CSPRNG), never `Math.random`. `b.ai.dp.budget({ scope, epsilon, delta })` tracks a privacy budget per scope and refuses a `consume` that would exceed it, accounting composition either by basic summation (default) or a Rényi-DP accountant (Mironov 2017) for a much tighter bound under repeated Gaussian releases. NIST SP 800-226 (2025) is the evaluation standard; Dwork & Roth is the canonical reference. The exponential and sparse-vector mechanisms are deferred-with-condition — their float-safe constructions (base-2 / permute-and-flip; snapped SVT) re-open on operator demand, since shipping them float-unsafe would defeat the module's purpose. **Added:** *`b.ai.dp.mechanism({ type, sensitivity, epsilon, ... })` — float-safe noise mechanisms* — `type: "laplace"` is the snapping mechanism (pure ε-DP, real-valued, requires a clamp `bound` the guarantee depends on); `type: "gaussian"` is the discrete Gaussian (integer-valued, (ε, δ)-DP, requires `delta`). The Gaussian uses the classic calibration σ = √(2 ln(1.25/δ))·Δ/ε, proven for ε ≤ 1 — larger ε is refused with a pointer to splitting the release under an rdp budget. Descriptors are validated + frozen at construction so a malformed parameter fails fast. · *`b.ai.dp.budget({ scope, epsilon, delta, accounting })` — per-scope privacy budget* — Returns `{ consume, remaining, spent, reset }`. `consume(mechanism, value)` adds the mechanism's noise, charges the accountant, and throws `aiDp/budget-exhausted` if the release would push the scope past its (ε, δ). `accounting: "basic"` (default) sums per-release ε and δ; `accounting: "rdp"` runs a Rényi-DP accountant across a grid of orders and converts to (ε, δ) at the scope's δ for a tight composition bound under repeated Gaussian releases (requires `delta > 0`). The scope budget is enforced on both ε and δ independently. **Security:** *`b.crypto.generateBytes` uniformity fix at 1-byte length* — Node's SHAKE256 XOF is non-uniform at `outputLength: 1` — the byte values 0x00 and 0xff never occur and the low bit skews to ~0.54. `b.crypto.generateBytes(1)` (and the underlying `random(1)`) now draws at least 2 bytes and slices, so a single-byte CSPRNG request is uniform. Surfaced by `b.ai.dp` per-byte noise sampling; any per-byte consumer of `generateBytes` inherits the fix. A regression test asserts 0x00 / 0xff occur and the low bit is balanced.
12
20
 
13
21
  - v0.12.28 (2026-05-24) — **`b.ai.capability` — model-capability registry + cheapest-satisfying-model router.** `b.ai.capability.create({ models })` turns a fleet of AI model descriptors into a routing decision: given a set of requirements (context window, input/output modalities, tool use, structured output, reasoning tier, citation support, prompt-caching size), it picks the cheapest model that satisfies all of them. NIST AI RMF (AI 100-1) MAP 2.x requires documenting each model's capabilities and limitations; the Model Cards convention (Mitchell et al., 2019) formalizes that descriptor — this primitive makes the descriptor actionable. Routing to the cheapest sufficient model is a front-line defense against over-provisioning spend and composes directly with `b.ai.quota`'s `cost-usd` dimension (the chosen descriptor's rate feeds the budget charge); refusing to route a request to a model that cannot satisfy it (missing modality, too-small context window, no tool use) catches a capability mismatch before the inference call burns tokens on a guaranteed-bad result. Cost ranking uses a supplied `costBasis` (`{ inputTokens, outputTokens }`) for real per-call spend, else the sum of the per-1k rates; ties break by model id so the choice is deterministic across calls and nodes. **Added:** *`b.ai.capability.create({ models })` — capability registry + router* — Returns `{ describe, list, register, satisfies, route }`. A descriptor carries `maxContextTokens`, `maxOutputTokens`, `modalitiesIn` / `modalitiesOut` (arrays), `toolUse`, `structuredOutput`, `fineTunable`, `reasoningTier` (`none` / `basic` / `standard` / `advanced`, ordered), `citationSupport`, `promptCachingMaxTokens`, and the cost rates `costPer1kInputTokens` / `costPer1kOutputTokens`. Descriptors are validated + frozen at registration so a typo (negative cost, unknown reasoning tier, non-array modality list) surfaces at config time rather than as a silent mis-route. `describe(modelId)` returns the frozen descriptor; `register(modelId, descriptor)` adds or replaces one at runtime. · *`route({ requirements, fallback?, costBasis? })` — cheapest-satisfying selection* — Collects every model whose descriptor satisfies all requirements, then returns the cheapest (`{ modelId, descriptor, estimatedCost, reason }`). Requirements: `minContextTokens`, `minOutputTokens`, `modalitiesIn` / `modalitiesOut` (model must support every listed modality), `toolUse`, `structuredOutput`, `fineTunable`, `minReasoningTier` (tier ordering — `standard` is met by `standard` or `advanced`), `citationSupport`, `minPromptCachingTokens`. When no model matches, `fallback` (a registered model id) is returned with `reason: "fallback"`, or the call refuses with `aiCapability/no-candidate` if no fallback was supplied. Routing decisions emit `ai/capability-routed` / `ai/capability-fallback` / `ai/capability-no-candidate` through the drop-silent audit chain. · *`satisfies(modelId, requirements)` — precise capability-mismatch reasons* — Returns `{ ok, failures }` where each failure names the `requirement`, the `need`, and what the model `have`s — so a caller surfaces a precise reason (e.g. `minReasoningTier need advanced have basic`) instead of a bare boolean. Use it to explain a routing miss or to gate a request against a specific model before calling it.
@@ -75,6 +75,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
75
75
  - RP-Initiated / Front-Channel / Back-Channel Logout 1.0 (`parseFrontchannelLogoutRequest` + `verifyBackchannelLogoutToken` with jti-replay defense)
76
76
  - RFC 9207 AS Issuer Identifier validation on callbacks (`parseCallback` — refuses iss mismatch + OP `error=` redirect)
77
77
  - OAuth 2.0 JARM signed-response decode (`parseJarmResponse`)
78
+ - RFC 9101 JWT-Secured Authorization Request verification — server-side request-object parse with mandatory alg allowlist + iss/client_id/aud binding + anti-nesting (`b.auth.jar.parse`)
78
79
  - One-time-use refresh-token rotation with operator-supplied replay-defense callback (RFC 9700 §4.13 / OAuth 2.1 §6.1 — `refreshAccessToken({ seen })`)
79
80
  - **Federation / VC** — CIBA Core 1.0 (`b.auth.ciba`, poll/ping/push); OpenID Federation 1.0 trust chain + metadata_policy (`b.auth.openidFederation`); SAML 2.0 SP with XMLDSig signature-wrapping defense + RFC 9525 server-identity (`b.auth.saml`); OpenID4VCI 1.0 issuer (`b.auth.oid4vci`); OpenID4VP 1.0 verifier with DCQL (`b.auth.oid4vp`); SD-JWT VC with `key_attestation` extension (`b.auth.sdJwtVc`)
80
81
  - **Sessions** — `b.session`
@@ -124,6 +125,8 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
124
125
 
125
126
  - **JSON / SQL / schema** — `b.safeJson` (with `maxKeys` cap defending CVE-2026-21717 V8 HashDoS), `b.safeBuffer`, `b.safeSql`, `b.safeSchema`
126
127
  - **URL + path** — `b.safeUrl` (IDN mixed-script / homograph refuse); `b.safeJsonPath` (refuses filter `?(...)`, deep-scan `$..`, script-shape `(@.x)` for safe Postgres JSONB ops)
128
+ - **Binary codec** — `b.cbor` bounded deterministic CBOR (RFC 8949 §4.2): depth/size caps, indefinite-length + reserved-info + tag + duplicate-key refusal, `requireDeterministic` canonical-form check; the in-tree substrate under COSE / CWT / SCITT / WebAuthn attestation
129
+ - **COSE signing** — `b.cose` COSE_Sign1 sign/verify (RFC 9052) over `b.cbor`: classical ES256/384/512 + EdDSA (final COSE ids, interoperable today) plus ML-DSA-87 (PQC-forward, draft id); bounded + alg-allowlisted + crit-bypass-checked verification; the signed-statement substrate under SCITT / CWT / C2PA
127
130
  - **Document parsers** — `b.parsers` (XML / TOML / YAML / .env); `b.config` (schema-validated env)
128
131
  - **File-type detection** — `b.fileType` magic-byte content classification with deny-on-upload categories (image / document / archive / executable / etc.)
129
132
  ### Content-safety gates
@@ -292,6 +292,7 @@ This is the minimum-viable security posture for a production deployment. The fra
292
292
  - [ ] Off-site at least one bundle (different region / cloud / physical location)
293
293
  - [ ] Retain bundles per compliance window; the prev-hash chain across bundles makes silent deletion detectable
294
294
  - [ ] Under `hipaa` / `pci-dss` postures, `b.backup.create` refuses `encrypt: false` at boot — the framework enforces backup encryption on these regulatory regimes
295
+ - [ ] Rotate the backup envelope key periodically (PCI DSS 3.6.4 / SOC 2 CC6.1) via `storage.keyRotation({ newRecipient | newPassphrase })` — it rotates every bundle's envelope AND re-reads each under the new key, so a rotation that corrupts a bundle reports `verifyFailed > 0` immediately rather than failing at restore time; the emitted `backup/key-rotated` audit event is the rotation record auditors expect
295
296
  - [ ] Generate a posture-appropriate disaster-recovery runbook with `b.drRunbook.emit({ posture, services, rtoMs, rpoMs })` and commit it under `docs/dr/` — ships RTO/RPO + role-recovery + breach-disclosure deadlines + restore procedure
296
297
 
297
298
  **Multi-tenant deployments**
@@ -340,6 +341,7 @@ This is the minimum-viable security posture for a production deployment. The fra
340
341
  - [ ] **Default-on (v0.7.18+) — transport-layer smuggling defenses:** `b.middleware.bodyParser` rejects requests carrying both `Content-Length` and `Transfer-Encoding` (CL.TE / TE.CL smuggling — CVE-2022-31394 / CVE-2024-27316 class), multiple Content-Length values, Transfer-Encoding whose final coding is not `chunked`, and duplicate `chunked` tokens (TE.TE smuggling) — each rejected with HTTP 400 + `Connection: close`. `b.staticServe` resolves every requested path through `fs.realpathSync` (defeats symlink escape) AND validates the basename through `b.guardFilename` at the balanced profile (rejects path traversal / null-byte / NTFS alternate data streams / UNC / RTLO bidi / overlong UTF-8 / Windows reserved device names). `b.mail` SMTP transport runs `b.guardEmail.validateMessage` at strict profile on the produced RFC 822 wire BEFORE opening the socket — refuses outbound SMTP smuggling (CVE-2023-51764 Postfix / CVE-2023-51765 Sendmail / CVE-2023-51766 Exim / CVE-2026-32178 .NET class) even when operator-supplied subject / body / headers contain bare CR / bare LF + smuggled SMTP verbs. `b.mail.dkim.create` throws on `opts.bodyLength` (M³AAWG / Gmail / Microsoft 365 guidance — `l=` enables append-after-signature attacks). All four protections require zero operator wiring
341
342
  - [ ] **Default-on (v0.7.12+):** `b.fileUpload` and `b.staticServe` wire `b.guardAll.byExtension({ profile: "strict" })` automatically; `b.fileUpload` additionally wires `b.guardFilename.gate({ profile: "strict" })` as `filenameSafety`. No explicit operator action required for the baseline defense-in-depth. To opt up to a broader content vocabulary (e.g. you serve operator-built HTML with links + images), pass `contentSafety: b.guardAll.byExtension({ profile: "balanced" })` explicitly. To opt out entirely (test fixtures, raw-bytes uploads), pass `contentSafety: null` / `filenameSafety: null` with `contentSafetyDisabledReason` / `filenameSafetyDisabledReason` strings — both fire audit rows at create() time so a security review can reconstruct which deploys disabled the default-on protection. To skip a single guard while keeping the rest, pass `contentSafety: b.guardAll.byExtension({ exceptFor: { name: { reason: "..." } } })` — the reason lands in the `guardAll.gate.created` audit row. Future guards added to the family auto-extend the deploy without re-wiring
342
343
  - [ ] For OAuth / OIDC RP callbacks: call `b.auth.oauth.parseCallback(query, opts?)` BEFORE consuming `code` — validates RFC 9207 AS Issuer Identifier (refuses iss-mismatch + OP `error=` redirects + state-mismatch CSRF). For FAPI 2.0 deployments add `b.fapi2.assertCallback(query)` (refuses missing iss; refuses bare-param under `fapi-2.0-message-signing` posture, requiring JARM `response`) and `b.fapi2.assertAuthzRequest(authzParams)` BEFORE issuing the redirect (refuses non-JAR authorization requests). For refresh-token flows, pass `seen({ jti, iss })` to `b.auth.oauth.refreshAccessToken` so reuse of an already-rotated token refuses BEFORE the HTTP exchange (RFC 9700 §4.13 / OAuth 2.1 §6.1)
344
+ - [ ] For OAuth authorization servers accepting RFC 9101 request objects (JAR): verify them with `b.auth.jar.parse(jar, { clientId, audience, algorithms, jwks })` BEFORE honoring the authorization request — it delegates the signature check to the alg-allowlisted `verifyExternal` (refuses `alg: "none"` / HMAC-vs-RSA confusion / JWE-on-JWS), pins `iss` + the `client_id` claim to the expected client and `aud` to your issuer, and refuses a nested `request` / `request_uri` (RFC 9101 §6.3); never accept an unsigned `request` parameter or trust query-string authorization parameters when a signed request object is available
343
345
  - [ ] For Model Context Protocol servers exposing tools to LLM agents: wire `b.mcp.toolResult.sanitize(result, { posture: "refuse" })` over EVERY tool output before returning it to the model — defends OWASP LLM07 (sensitive tool output / prompt-injection echo back into the agent loop), refuses dangerous-HTML + off-allowlist URLs. Wrap each tool's input handler with `b.mcp.validateToolInput(toolName, input, schema)` (JSON Schema 2020-12 subset — `type` / `properties` / `required` / `enum` / `const` / length + range caps) so an LLM-supplied argument shape that doesn't match refuses BEFORE the tool runs. Define each tool's required scopes via `b.mcp.capability.create(scopes)` and gate execution on `cap.satisfiedBy(grantedScopes)` (LLM08 least-privilege)
344
346
  - [ ] For AI inference endpoints (especially pay-per-use provider models): wire `b.ai.quota.create({ dimension, period, limit, enforcement: "hard" })` and call `quota.consume(tenant, model, amount)` BEFORE every inference — caps tokens / requests / cost-usd / compute-hours per tenant per window so one tenant cannot run up an unbounded bill (OWASP LLM10:2025 unbounded consumption / denial-of-wallet). For multi-node deployments supply an `opts.store` whose `reserve` (atomic conditional test-and-charge) + `add` are atomic on the shared backend (Redis Lua / a SQL `UPDATE ... WHERE used + :amt <= :limit`) so the ceiling is enforced on the cluster-wide aggregate, not per-process
345
347
  - [ ] For endpoints returning aggregate statistics over personal data (counts / sums / means / histograms): add calibrated noise with `b.ai.dp` and gate spend with `b.ai.dp.budget({ scope, epsilon, delta })` — use `type: "laplace"` (snapping mechanism) or `type: "gaussian"` (discrete Gaussian), NEVER a hand-rolled `Math.random`-based noise generator: a naive double-precision Laplace sampler lets an attacker distinguish neighbouring datasets from a single output (Mironov 2012), silently breaking the guarantee. Track the per-scope ε/δ budget so repeated queries can't be averaged to cancel the noise
@@ -373,6 +375,8 @@ This is the minimum-viable security posture for a production deployment. The fra
373
375
  - [ ] For routes that build JSONB queries from operator input — `b.db.from(table).where(field, "@>", value)` and the `?` / `?|` / `?&` JSONB key operators route the value through `b.safeJsonPath.validateContainment` / `validateKey` automatically; refuses NUL / control / bidi / zero-width characters in any string leaf or key. Operators building a literal JSONpath expression for `@?` / `@@` call `b.safeJsonPath.validateExpression(expr)` before binding — refuses filter predicates `?(...)`, recursive descent `$..`, script-shape `(@.x.y)`, JS-source hints, and bracket depth bombs
374
376
  - [ ] For idempotency-key middleware on multi-process fleets — use `b.middleware.idempotencyKey.dbStore({ db: b.db })` instead of `memoryStore`. As of v0.9.15 the dbStore defaults to `hashKeys: true` (operator-supplied keys are sha3-512 namespace-hashed before insert/lookup so the DB never sees raw keys that might carry PII — order numbers / emails / vendor prefixes) and `seal: true` (cached response `headers` + `body` are sealed via `b.cryptoField.sealRow` AEAD envelope when vault is initialized so a DB dump leaks neither). Forensic columns (`status_code` / `fingerprint` / `expires_at`) stay plaintext-queryable without unsealing. Opt-out via `{ hashKeys: false, seal: false }` only with a documented justification
375
377
  - [ ] For long-running daemons exposing live metrics — use `b.metrics.snapshot.startWriter({ path, intervalMs, fields })` to flush an atomic JSON snapshot to disk; let a CLI/sidecar consume it via `b.metrics.snapshot.read(path)` + `b.metrics.snapshot.render(snap, { format: "prometheus" | "text" })`. Avoids opening an HTTP port for scrape access. Snapshot read uses `b.safeJson.parse` with a 4 MiB ceiling so a hostile writer with disk-write access can't OOM the reader
378
+ - [ ] For decoding CBOR from untrusted sources (COSE / CWT / WebAuthn attestation objects, IoT payloads): use `b.cbor.decode(buffer, opts)` — bounded by default (depth + total-size caps, indefinite-length + reserved-info refusal), never a raw streaming parser. Allowlist only the tags you process via `allowedTags` (an un-vetted tag triggers semantic reprocessing — a confused-deputy vector), and on a signature-verify path pass `requireDeterministic: true` so a non-canonical re-encoding can't slip a malleable representation past the verifier
379
+ - [ ] For verifying COSE_Sign1 (RFC 9052) signed statements (SCITT receipts, CWT, C2PA manifests): use `b.cose.verify(coseSign1, { algorithms, publicKey })` — `algorithms` is a required allowlist (no defaults, the alg-confusion defense), the COSE bytes + protected header decode through the bounded `b.cbor`, and a `crit` header naming a label the verifier doesn't understand is refused (§3.1 crit-bypass). Bind request context with `externalAad`. ML-DSA-87's COSE id is a non-final draft — prefer the classical ES256 / EdDSA ids for interoperable signing today
376
380
  - [ ] For install-pipeline contexts that run BEFORE the framework is installed (Dockerfile build stages, `install.sh`, `update.sh`, SEA bundle verification) — use `b.selfUpdate.standaloneVerifier` (since v0.9.13). It's a zero-dep verifier (only `node:crypto` + `node:fs`) for ECDSA P-384 / Ed25519 / ML-DSA-87 signatures. Operators physically copy the file via `cp "$(node -p "require('@blamejs/core').selfUpdate.standaloneVerifier.path")" install/standalone-verifier.js` into their install pipeline alongside an operator-owned pubkey
377
381
  - [ ] For daemons that rotate TLS posture without restarting (pinset reload / certificate refresh / `C.TLS_GROUP_PREFERENCE` updates) — call `b.pqcAgent.reload()` after the posture change so the next `b.pqcAgent.agent` access rebuilds against current TLS state. Existing in-flight sockets complete naturally; idle keep-alive sockets are torn down
378
382
  - [ ] For SBOM regeneration / vendor-data integrity sweeps / release-asset bundling — use `b.crypto.hashFilesParallel(filePaths, { algorithms, concurrency, onProgress })` to hash N files in parallel in a single-pass per file. Operator-tunable concurrency cap (default `min(8, paths.length)`, range 1..256) + tunable algorithms list (default `["sha256", "sha3-512"]` for PQC-first + legacy compat). Returns rows in input order