@blamejs/blamejs-shop 0.0.126 → 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/CHANGELOG.md CHANGED
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.0.x
10
10
 
11
+ - v0.0.127 (2026-05-24) — **Self-serve returns — customers request RMAs on their orders, operators approve and refund.** Signed-in customers can request a return against one of their own orders — pick the items and a reason at the order, then track status at /account/returns. Operators work the queue at /admin/returns: approve (with a refund amount), mark received, refund, or reject with a reason, following the pending → approved → received → refunded lifecycle. The customer request route loads the order and confirms it belongs to the signed-in customer before showing it, and builds the return lines from the order's own records, so a foreign or guessed order id returns 404 and a client can't return items it never bought. **Added:** *Customer return requests + status* — `/account/orders/:order_id/return` shows the order's items with a reason picker; `/account/returns` lists the customer's RMAs with status (pending / approved / received / refunded / rejected) and any rejection reason. The account dashboard links to it. Empty selection or a bad reason re-renders the form with the message. · *Operator return queue* — `GET /admin/returns?status=pending` lists the queue across all orders; `GET /admin/returns/:id` reads one; `POST /admin/returns/:id/approve` (with refund amount), `/received`, `/refund`, and `/reject` (with reason) walk the lifecycle. Bearer-token-gated; an illegal transition is a 409 and a bad id a 404, never a 500. · *`returns.listByStatus(status, opts)`* — Lists return authorizations across all orders by status, newest first, with the same opaque cursor as `listForCustomer`. Backs the operator queue.
12
+
11
13
  - v0.0.126 (2026-05-24) — **Address book — saved shipping and billing addresses on the account page.** Signed-in customers can now keep a book of addresses at /account/addresses: add, edit, set a default shipping or billing address, and remove. Addresses are per-customer; every action that targets a specific address first confirms it belongs to the signed-in customer, so a guessed id can't read or change someone else's address. **Added:** *Address book at `/account/addresses`* — List, add, and edit saved addresses (recipient, company, two street lines, city, region, postal code, ISO country, phone). The account dashboard links to it. A blank product catalog isn't required — this is account surface only. · *Default shipping / billing* — Mark one address as the default shipping and one as the default billing; promoting a new default clears the previous one. Surfaced as badges on each card. · *Ownership-checked mutations* — Edit, update, set-default, and remove all resolve the address by id and verify it belongs to the signed-in customer before acting — a foreign or guessed id returns 404, never another customer's data. Add/edit re-render the form with the validation message on bad input.
12
14
 
13
15
  - v0.0.124 (2026-05-24) — **Save for later — move cart items into a holding list and back.** Each cart line now has a "Save for later" control that moves the item out of the cart into a per-customer holding list without losing it. Saved items live on a new account page where they can be moved back to the cart or removed. Moving an item back reprices it to the current catalog price and checks stock first, so a saved item that sold out (and isn't backorderable) can't silently re-enter the cart. Login-required, since the list is scoped to one customer. **Added:** *Save-for-later control on cart lines* — Each editable cart line gets a Save-for-later control. `POST /cart/lines/:line_id/save` moves the line out of the cart into the customer's saved list (`moveFromCart`). Login-gated — a signed-out shopper is redirected to sign in. · *`/account/saved` — the holding list* — A new account page lists saved items with a thumbnail, the saved price for reference, and Move-to-cart / Remove controls. Items whose product was archived render as "no longer available" rather than breaking the list. The account dashboard links to it (alongside Wishlist). · *Move back to cart, repriced + stock-checked* — `POST /saved/:save_id/move-to-cart` returns the item to the session cart at the current catalog price (not the stale snapshot) and refuses if the SKU is out of stock and not backorderable. `POST /saved/:save_id/remove` drops a saved row.
package/README.md CHANGED
@@ -67,9 +67,10 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
67
67
  | **`lib/wishlist.js`** | Per-customer saved products. The PDP renders a login-gated "Save to wishlist" toggle and a "N shoppers saved this" social-proof count; `/account/wishlist` lists saved items (remove + reopen, orphan-tolerant when a product is archived). `POST /wishlist/toggle` is idempotent (`INSERT OR IGNORE`) and redirects to the canonical product slug or a safe same-origin `return_to`. UUID-shape-validated ids, `b.pagination` HMAC cursors. |
68
68
  | **`lib/save-for-later.js`** | Per-customer cart holding list. Each cart line gets a login-gated "Save for later" control (`POST /cart/lines/:id/save` → `moveFromCart`); `/account/saved` lists items with Move-to-cart / Remove. `moveToCart` reprices to the current catalog price and stock-gates (out-of-stock + non-backorderable is refused). Composes `catalog.inventory` + `catalog.prices` + `catalog.variants`. |
69
69
  | **`lib/addresses.js`** | Per-customer address book at `/account/addresses` — add / edit / set default shipping or billing / remove. One-default-per-role invariant (promoting clears the prior). Every by-id route confirms the address belongs to the signed-in customer before acting (a guessed id returns 404). `b.guardUuid` ids, 2-char ISO country. |
70
+ | **`lib/returns.js`** | Self-serve RMAs. Customer requests a return against their own order at `/account/orders/:id/return` (items + reason, ownership-checked, lines built from the order's own records) and tracks status at `/account/returns`. Operators work `/admin/returns` — approve (refund amount) / mark received / refund / reject — over the pending → approved → received → refunded FSM; illegal transitions are 409, bad ids 404. |
70
71
  | **`lib/subscriptions.js`** | Stripe-backed recurring billing — `subscription_plans` (interval / amount / trial) + `subscriptions` (mirrors Stripe's object byte-for-byte). `subscriptions.create` POSTs to Stripe via the payment dep, then persists the returned object locally. `handleStripeEvent` replays `customer.subscription.*` events into the local row so the shop has an authoritative view without round-tripping. |
71
72
  | **`lib/newsletter.js`** | Operator-collected email broadcast list — `signup({ email, source })` composes `b.guardEmail` for shape validation, `b.crypto.namespaceHash` for the dedup key, and `INSERT OR IGNORE` for idempotency. Storefront POST `/newsletter` route renders a designed thank-you card with separate copy for the `new` vs `dedup` branches. |
72
- | **`lib/admin.js`** | Bearer-token-gated CRUD over catalog + orders + refunds + bulk CSV import + subscription plans + review moderation. Token compared via `b.crypto.timingSafeEqual`. Errors as RFC 9457 problem documents via `b.problemDetails`. Audit emission on every mutation. |
73
+ | **`lib/admin.js`** | Bearer-token-gated CRUD over catalog + orders + refunds + bulk CSV import + subscription plans + review moderation + return moderation. Token compared via `b.crypto.timingSafeEqual`. Errors as RFC 9457 problem documents via `b.problemDetails`. Audit emission on every mutation. |
73
74
  | **`lib/catalog-import.js`** | Bulk CSV import — `POST /admin/catalog/import` accepts a `text/csv` body, parses via `b.csv`, content-safety-filters every cell through `b.guardCsv` (formula-injection / bidi / control / dangerous-function denylist), validates exact header order, de-dupes rows by `product_slug`, returns per-row errors without aborting. Default 1 MiB / 10000 rows caps. |
74
75
  | **`lib/theme.js`** | File-backed templates with fallback chain. Operators register a named theme under `<themesDir>/<name>/*.html` and the storefront dispatches every renderer through it. `assetUrl(path)` resolves to `/assets/themes/<name>/<path>`. The shipped `default` theme is the fallback. |
75
76
 
@@ -88,6 +89,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
88
89
  - `migrations-d1/0012_wishlist.sql` — per-customer saved products (unique customer + product + variant)
89
90
  - `migrations-d1/0041_save_for_later.sql` — per-customer cart holding list (price snapshot + source line)
90
91
  - `migrations-d1/0026_customer_addresses.sql` — per-customer address book (default shipping/billing flags)
92
+ - `migrations-d1/0023_returns.sql` — return authorizations + lines (RMA lifecycle FSM)
91
93
 
92
94
  ### Demo seed
93
95
 
package/lib/admin.js CHANGED
@@ -163,6 +163,7 @@ function mount(router, deps) {
163
163
  var assetPrefix = typeof deps.asset_prefix === "string" ? deps.asset_prefix : "/assets/";
164
164
  var catalogImport = deps.catalogImport || null; // bulk-import route disabled when absent
165
165
  var reviews = deps.reviews || null; // moderation endpoints disabled when absent
166
+ var returns = deps.returns || null; // RMA moderation endpoints disabled when absent
166
167
 
167
168
  try { _b().audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
168
169
 
@@ -580,6 +581,118 @@ function mount(router, deps) {
580
581
  }));
581
582
  }
582
583
 
584
+ // ---- returns (moderation) -------------------------------------------
585
+
586
+ // Operator-side RMA moderation. The queue lists return
587
+ // authorizations across all orders in one status (defaults to
588
+ // `pending`); approve / received / refund / reject walk the same FSM
589
+ // the customer-facing request path leaves in `pending`. A bad state
590
+ // transition (e.g. refund-from-pending) and a malformed :id both
591
+ // surface as client errors (4xx), never a 500. Endpoints are omitted
592
+ // entirely when no returns primitive is wired.
593
+ if (returns) {
594
+ function _returnsClientError(e) {
595
+ // A transition refused by the FSM or a not-found row is the
596
+ // caller's problem, not the server's. `_currentStatus` raises a
597
+ // not-found TypeError; `_assertTransition` raises an Error tagged
598
+ // RMA_TRANSITION_REFUSED. Map both to 4xx. (Bad-shape input is a
599
+ // plain TypeError, which the wrapper already maps to 400.)
600
+ if (!e) return null;
601
+ if (e.code === "RMA_NOT_FOUND") return { status: 404, slug: "return-not-found" };
602
+ if (e.code === "RMA_TRANSITION_REFUSED") return { status: 409, slug: "return-transition-refused" };
603
+ return null;
604
+ }
605
+
606
+ router.get("/admin/returns", R(async function (req, res) {
607
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
608
+ var status = (url && url.searchParams.get("status")) || "pending";
609
+ var cursor = url && url.searchParams.get("cursor");
610
+ var limitS = url && url.searchParams.get("limit");
611
+ var limit = limitS == null ? undefined : parseInt(limitS, 10);
612
+ var page = await returns.listByStatus(status, { cursor: cursor || undefined, limit: limit });
613
+ _json(res, 200, page);
614
+ }));
615
+
616
+ router.get("/admin/returns/:id", R(async function (req, res) {
617
+ var rma;
618
+ try {
619
+ rma = await returns.get(req.params.id);
620
+ } catch (e) {
621
+ // A non-UUID :id raises a guardUuid TypeError — surface it as a
622
+ // 404 (the route is a defensive request-shape reader, never a
623
+ // 500). Re-raise anything that isn't the bad-id shape so the
624
+ // wrapper's generic handling applies.
625
+ if (e instanceof TypeError) return _problem(res, 404, "return-not-found");
626
+ throw e;
627
+ }
628
+ if (!rma) return _problem(res, 404, "return-not-found");
629
+ _json(res, 200, rma);
630
+ }));
631
+
632
+ router.post("/admin/returns/:id/approve", W("return.approve", async function (req, res) {
633
+ var body = req.body || {};
634
+ var rma;
635
+ try {
636
+ rma = await returns.approve(req.params.id, {
637
+ refund_amount_minor: body.refund_amount_minor,
638
+ refund_currency: body.refund_currency,
639
+ operator_notes: body.operator_notes,
640
+ });
641
+ } catch (e) {
642
+ var ce = _returnsClientError(e);
643
+ if (ce) return _problem(res, ce.status, ce.slug, e.message);
644
+ throw e;
645
+ }
646
+ _json(res, 200, rma);
647
+ return rma;
648
+ }));
649
+
650
+ router.post("/admin/returns/:id/received", W("return.received", async function (req, res) {
651
+ var body = req.body || {};
652
+ var rma;
653
+ try {
654
+ rma = await returns.markReceived(req.params.id, { operator_notes: body.operator_notes });
655
+ } catch (e) {
656
+ var ce = _returnsClientError(e);
657
+ if (ce) return _problem(res, ce.status, ce.slug, e.message);
658
+ throw e;
659
+ }
660
+ _json(res, 200, rma);
661
+ return rma;
662
+ }));
663
+
664
+ router.post("/admin/returns/:id/refund", W("return.refund", async function (req, res) {
665
+ var body = req.body || {};
666
+ var rma;
667
+ try {
668
+ rma = await returns.refund(req.params.id, { operator_notes: body.operator_notes });
669
+ } catch (e) {
670
+ var ce = _returnsClientError(e);
671
+ if (ce) return _problem(res, ce.status, ce.slug, e.message);
672
+ throw e;
673
+ }
674
+ _json(res, 200, rma);
675
+ return rma;
676
+ }));
677
+
678
+ router.post("/admin/returns/:id/reject", W("return.reject", async function (req, res) {
679
+ var body = req.body || {};
680
+ var rma;
681
+ try {
682
+ rma = await returns.reject(req.params.id, {
683
+ rejected_reason: body.rejected_reason,
684
+ operator_notes: body.operator_notes,
685
+ });
686
+ } catch (e) {
687
+ var ce = _returnsClientError(e);
688
+ if (ce) return _problem(res, ce.status, ce.slug, e.message);
689
+ throw e;
690
+ }
691
+ _json(res, 200, rma);
692
+ return rma;
693
+ }));
694
+ }
695
+
583
696
  // ---- config ---------------------------------------------------------
584
697
 
585
698
  var config = deps.config || null;
package/lib/returns.js CHANGED
@@ -516,6 +516,57 @@ function create(opts) {
516
516
  return { rows: rows, next_cursor: next };
517
517
  },
518
518
 
519
+ listByStatus: async function (status, listOpts) {
520
+ var statusFilter = _status(status);
521
+ listOpts = listOpts || {};
522
+ var limit = listOpts.limit == null ? DEFAULT_LIST_LIMIT : listOpts.limit;
523
+ if (!Number.isInteger(limit) || limit <= 0 || limit > MAX_LIST_LIMIT) {
524
+ throw new TypeError("returns.listByStatus: limit must be 1..." + MAX_LIST_LIMIT);
525
+ }
526
+ var cursorVals = null;
527
+ if (listOpts.cursor != null) {
528
+ if (typeof listOpts.cursor !== "string") {
529
+ throw new TypeError("returns.listByStatus: cursor must be an opaque string or null");
530
+ }
531
+ try {
532
+ var state = _b().pagination.decodeCursor(listOpts.cursor, cursorSecret);
533
+ if (JSON.stringify(state.orderKey) !== JSON.stringify(RMA_ORDER_KEY)) {
534
+ throw new TypeError("returns.listByStatus: cursor orderKey mismatch");
535
+ }
536
+ cursorVals = state.vals;
537
+ } catch (e) {
538
+ if (e instanceof TypeError) throw e;
539
+ throw new TypeError("returns.listByStatus: cursor — " + (e && e.message || "malformed"));
540
+ }
541
+ }
542
+
543
+ var sql, params;
544
+ if (cursorVals) {
545
+ sql = "SELECT * FROM return_authorizations WHERE status = ?1 AND " +
546
+ "(created_at < ?2 OR (created_at = ?2 AND id < ?3)) " +
547
+ "ORDER BY created_at DESC, id DESC LIMIT ?4";
548
+ params = [statusFilter, cursorVals[0], cursorVals[1], limit];
549
+ } else {
550
+ sql = "SELECT * FROM return_authorizations WHERE status = ?1 " +
551
+ "ORDER BY created_at DESC, id DESC LIMIT ?2";
552
+ params = [statusFilter, limit];
553
+ }
554
+ var rows = (await query(sql, params)).rows;
555
+ for (var i = 0; i < rows.length; i += 1) {
556
+ await _hydrate(rows[i]);
557
+ }
558
+ var last = rows[rows.length - 1];
559
+ var next = null;
560
+ if (last && rows.length === limit) {
561
+ next = _b().pagination.encodeCursor({
562
+ orderKey: RMA_ORDER_KEY,
563
+ vals: [last.created_at, last.id],
564
+ forward: true,
565
+ }, cursorSecret);
566
+ }
567
+ return { rows: rows, next_cursor: next };
568
+ },
569
+
519
570
  summaryForOperator: async function (input) {
520
571
  input = input || {};
521
572
  var from = _epochOrNull(input.from, "from");
package/lib/storefront.js CHANGED
@@ -1134,6 +1134,120 @@ function renderAddresses(opts) {
1134
1134
  });
1135
1135
  }
1136
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
+
1137
1251
  // Product-level "Save to wishlist" control + social-proof count.
1138
1252
  // Byte-compatible with the edge renderer (`worker/render/product.js`)
1139
1253
  // so both paths emit identical markup. Action-only label — the toggle
@@ -2066,6 +2180,7 @@ var ACCOUNT_DASH_PAGE =
2066
2180
  " <a class=\"btn-secondary\" href=\"/account/wishlist\">Wishlist</a>\n" +
2067
2181
  " <a class=\"btn-secondary\" href=\"/account/saved\">Saved for later</a>\n" +
2068
2182
  " <a class=\"btn-secondary\" href=\"/account/addresses\">Addresses</a>\n" +
2183
+ " <a class=\"btn-secondary\" href=\"/account/returns\">Returns</a>\n" +
2069
2184
  " <form method=\"post\" action=\"/account/logout\"><button type=\"submit\" class=\"btn-ghost\">Sign out</button></form>\n" +
2070
2185
  " </div>\n" +
2071
2186
  " </header>\n" +
@@ -3214,6 +3329,102 @@ function mount(router, deps) {
3214
3329
  _addrAction("archive", function (id) { return deps.addresses.archive(id); });
3215
3330
  }
3216
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
+
3217
3428
  // Product reviews — submission requires a logged-in customer AND a
3218
3429
  // verified purchase of the product (the gate, not just a badge).
3219
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.32",
7
- "tag": "v0.12.32",
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,8 @@ 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
+
11
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.
12
14
 
13
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.
@@ -126,6 +126,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
126
126
  - **JSON / SQL / schema** — `b.safeJson` (with `maxKeys` cap defending CVE-2026-21717 V8 HashDoS), `b.safeBuffer`, `b.safeSql`, `b.safeSchema`
127
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
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
129
130
  - **Document parsers** — `b.parsers` (XML / TOML / YAML / .env); `b.config` (schema-validated env)
130
131
  - **File-type detection** — `b.fileType` magic-byte content classification with deny-on-upload categories (image / document / archive / executable / etc.)
131
132
  ### Content-safety gates
@@ -376,6 +376,7 @@ This is the minimum-viable security posture for a production deployment. The fra
376
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
377
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
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
379
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
380
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
381
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 1,
3
- "frameworkVersion": "0.12.32",
4
- "createdAt": "2026-05-24T19:44:18.478Z",
3
+ "frameworkVersion": "0.12.33",
4
+ "createdAt": "2026-05-24T20:50:22.285Z",
5
5
  "exports": {
6
6
  "a2a": {
7
7
  "type": "object",
@@ -13452,6 +13452,52 @@
13452
13452
  }
13453
13453
  }
13454
13454
  },
13455
+ "cose": {
13456
+ "type": "object",
13457
+ "members": {
13458
+ "ALGORITHMS": {
13459
+ "type": "object",
13460
+ "members": {
13461
+ "ES256": {
13462
+ "type": "primitive",
13463
+ "valueType": "number"
13464
+ },
13465
+ "ES384": {
13466
+ "type": "primitive",
13467
+ "valueType": "number"
13468
+ },
13469
+ "ES512": {
13470
+ "type": "primitive",
13471
+ "valueType": "number"
13472
+ },
13473
+ "EdDSA": {
13474
+ "type": "primitive",
13475
+ "valueType": "number"
13476
+ },
13477
+ "ML-DSA-87": {
13478
+ "type": "primitive",
13479
+ "valueType": "number"
13480
+ }
13481
+ }
13482
+ },
13483
+ "COSE_SIGN1_TAG": {
13484
+ "type": "primitive",
13485
+ "valueType": "number"
13486
+ },
13487
+ "CoseError": {
13488
+ "type": "function",
13489
+ "arity": 4
13490
+ },
13491
+ "sign": {
13492
+ "type": "function",
13493
+ "arity": 2
13494
+ },
13495
+ "verify": {
13496
+ "type": "function",
13497
+ "arity": 2
13498
+ }
13499
+ }
13500
+ },
13455
13501
  "cra": {
13456
13502
  "type": "object",
13457
13503
  "members": {
@@ -456,6 +456,7 @@ module.exports = {
456
456
  // the codepoint-stability contract.
457
457
  jose: { jwe: { experimental: require("./lib/jose-jwe-experimental") } },
458
458
  cbor: require("./lib/cbor"),
459
+ cose: require("./lib/cose"),
459
460
  queue: queue,
460
461
  logStream: logStream,
461
462
  redact: redact,
@@ -0,0 +1,339 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.cose
4
+ * @nav Crypto
5
+ * @title COSE signing (RFC 9052)
6
+ *
7
+ * @intro
8
+ * COSE_Sign1 signing and verification (RFC 9052 / 9053), composing
9
+ * the in-tree <code>b.cbor</code> codec for the deterministic
10
+ * Sig_structure encoding. COSE is the signed-statement substrate
11
+ * under SCITT, CWT, and C2PA — a CBOR-native counterpart to JWS.
12
+ *
13
+ * <strong>Signing</strong> supports the classical COSE signature
14
+ * algorithms that are interoperable today — ES256 / ES384 / ES512
15
+ * (ECDSA) and EdDSA (Ed25519), all with final IANA algorithm ids
16
+ * (RFC 9053) — alongside ML-DSA-87 (FIPS 204) for PQC-forward
17
+ * deployments. There is no classical <em>default</em>: the caller
18
+ * names the algorithm and supplies the key. <strong>Verification</strong>
19
+ * accepts the same set, so the framework both produces COSE other
20
+ * implementations can read today and consumes third-party COSE.
21
+ *
22
+ * <strong>Standards-maturity caveat on the PQC algorithm:</strong>
23
+ * the COSE algorithm identifier for ML-DSA-87 is <code>-50</code>, a
24
+ * <em>requested</em> (non-final) IANA assignment from
25
+ * draft-ietf-cose-dilithium; it may change before that draft is
26
+ * published, so an ML-DSA-87 COSE_Sign1 is not yet broadly
27
+ * interoperable — pin the identifier deliberately, re-open on IANA
28
+ * finalization. SLH-DSA-SHAKE-256f (the framework's default PQC
29
+ * signature elsewhere) has <strong>no</strong> COSE algorithm
30
+ * identifier registered at all (the COSE SPHINCS+ draft registers
31
+ * only the Category-1 'small' sets), so it cannot be represented in
32
+ * COSE and is not offered here. The COSE_Sign1 mechanism itself, and
33
+ * the classical algorithms, are stable; ML-DSA-87 is the forward-
34
+ * looking opt-in.
35
+ *
36
+ * <strong>Verify is bounded.</strong> The COSE_Sign1 bytes and the
37
+ * protected-header bstr are decoded through <code>b.cbor.decode</code>
38
+ * (depth + size caps, indefinite-length / tag / duplicate-key
39
+ * refusal). The protected header is the integrity-protected one;
40
+ * <code>alg</code> (label 1) lives there. A <code>crit</code> (label
41
+ * 2) listing a header label the verifier does not understand is
42
+ * refused (RFC 9052 §3.1) — a crit-bypass defense.
43
+ *
44
+ * v1 ships COSE_Sign1 (single-signer) with an attached payload.
45
+ * Detached payload, COSE_Sign (multi-signer), COSE_Mac0, and
46
+ * COSE_Encrypt are deferred-with-condition (operator demand).
47
+ *
48
+ * @card
49
+ * COSE_Sign1 sign / verify (RFC 9052) over the in-tree CBOR codec —
50
+ * ML-DSA-87 signing (experimental, draft alg id) + classical verify,
51
+ * bounded + crit-checked. The substrate under SCITT / CWT / C2PA.
52
+ */
53
+
54
+ var nodeCrypto = require("node:crypto");
55
+ var cbor = require("./cbor");
56
+ var validateOpts = require("./validate-opts");
57
+ var { defineClass } = require("./framework-error");
58
+
59
+ var CoseError = defineClass("CoseError", { alwaysPermanent: true });
60
+
61
+ var COSE_SIGN1_TAG = 18; // allow:raw-byte-literal — RFC 9052 COSE_Sign1 CBOR tag
62
+ var HDR_ALG = 1; // RFC 9052 §3.1 header label: alg
63
+ var HDR_CRIT = 2; // header label: crit
64
+ var HDR_CONTENT_TYPE = 3; // header label: content type
65
+ var HDR_KID = 4; // header label: kid
66
+
67
+ // COSE algorithm identifiers. ML-DSA-87 is a NON-FINAL requested
68
+ // assignment (draft-ietf-cose-dilithium) — pinned deliberately, re-open
69
+ // on IANA finalization. The classical ECDSA / EdDSA ids are final
70
+ // (RFC 9053). SLH-DSA is intentionally absent (no registered COSE id).
71
+ var ALG_NAME_TO_ID = {
72
+ "ML-DSA-87": -50,
73
+ "ES256": -7, "ES384": -35, "ES512": -36, "EdDSA": -8, // allow:raw-byte-literal — COSE algorithm identifiers (RFC 9053), not byte sizes
74
+ };
75
+ var ALG_ID_TO_NAME = {};
76
+ Object.keys(ALG_NAME_TO_ID).forEach(function (k) { ALG_ID_TO_NAME[ALG_NAME_TO_ID[k]] = k; });
77
+
78
+ // Signable algorithms: the classical ECDSA / EdDSA set (final COSE
79
+ // ids, interoperable today) plus ML-DSA-87 (draft id, PQC-forward).
80
+ // All are accepted for VERIFY as well. There is no classical default —
81
+ // the caller names the algorithm explicitly.
82
+ var SIGNABLE = ["ML-DSA-87", "ES256", "ES384", "ES512", "EdDSA"];
83
+
84
+ // Header labels this verifier understands — a `crit` entry naming any
85
+ // other label is refused (RFC 9052 §3.1 crit-bypass defense).
86
+ var UNDERSTOOD_LABELS = [HDR_ALG, HDR_CRIT, HDR_CONTENT_TYPE, HDR_KID];
87
+
88
+ function _toKeyObject(key, kind) {
89
+ if (key && typeof key === "object" && typeof key.asymmetricKeyType === "string") return key;
90
+ try {
91
+ return kind === "private" ? nodeCrypto.createPrivateKey(key) : nodeCrypto.createPublicKey(key);
92
+ } catch (e) {
93
+ throw new CoseError("cose/bad-key", "cose: could not load " + kind + " key: " + e.message);
94
+ }
95
+ }
96
+
97
+ function _algParamsFor(algId) {
98
+ switch (algId) {
99
+ case -50: return { nodeAlg: null }; // ML-DSA-87 (KeyObject specifies the hash)
100
+ case -8: return { nodeAlg: null }; // allow:raw-byte-literal — EdDSA COSE alg id (RFC 9053), not a size
101
+ case -7: return { nodeAlg: "sha256", dsaEncoding: "ieee-p1363" }; // ES256
102
+ case -35: return { nodeAlg: "sha384", dsaEncoding: "ieee-p1363" }; // ES384
103
+ case -36: return { nodeAlg: "sha512", dsaEncoding: "ieee-p1363" }; // ES512
104
+ default:
105
+ throw new CoseError("cose/unknown-alg", "cose: unrecognized COSE algorithm id " + algId);
106
+ }
107
+ }
108
+
109
+ function _bstr(x) {
110
+ if (Buffer.isBuffer(x)) return x;
111
+ if (x instanceof Uint8Array) return Buffer.from(x);
112
+ if (typeof x === "string") return Buffer.from(x, "utf8");
113
+ throw new CoseError("cose/bad-bytes", "cose: expected bytes (Buffer / Uint8Array / string)");
114
+ }
115
+
116
+ // Sig_structure (RFC 9052 §4.4) for COSE_Sign1:
117
+ // [ "Signature1", body_protected (bstr), external_aad (bstr), payload (bstr) ]
118
+ // deterministically CBOR-encoded — the bytes that are signed / verified.
119
+ function _toBeSigned(protectedBstr, externalAad, payload) {
120
+ return cbor.encode(["Signature1", protectedBstr, externalAad, payload]);
121
+ }
122
+
123
+ /**
124
+ * @primitive b.cose.sign
125
+ * @signature b.cose.sign(payload, opts)
126
+ * @since 0.12.33
127
+ * @status stable
128
+ * @related b.cose.verify, b.cbor.encode
129
+ *
130
+ * Produce a tagged COSE_Sign1 (RFC 9052) over <code>payload</code>
131
+ * (bytes). <code>alg</code> is one of the classical ECDSA / EdDSA
132
+ * algorithms (final COSE ids, interoperable today) or
133
+ * <code>"ML-DSA-87"</code> (draft id <code>-50</code>, PQC-forward).
134
+ * <code>alg</code> is placed in the integrity-protected header.
135
+ *
136
+ * @opts
137
+ * {
138
+ * alg: string, // "ES256" | "ES384" | "ES512" | "EdDSA" | "ML-DSA-87"
139
+ * privateKey: object, // matching KeyObject or PEM
140
+ * kid?: string, // → unprotected header label 4
141
+ * contentType?: number, // → protected header label 3
142
+ * externalAad?: Buffer, // default empty — bound into the signature
143
+ * unprotectedHeaders?: object, // extra unprotected map entries (numeric keys)
144
+ * }
145
+ *
146
+ * @example
147
+ * var coseSign1 = await b.cose.sign(Buffer.from("statement"), {
148
+ * alg: "ES256", privateKey: ecKey, kid: "key-1",
149
+ * });
150
+ */
151
+ async function sign(payload, opts) {
152
+ validateOpts.requireObject(opts, "cose.sign", CoseError);
153
+ validateOpts(opts, ["alg", "privateKey", "kid", "contentType", "externalAad", "unprotectedHeaders"], "cose.sign");
154
+ if (SIGNABLE.indexOf(opts.alg) === -1) {
155
+ throw new CoseError("cose/unsignable-alg",
156
+ "cose.sign: alg must be one of " + SIGNABLE.join(" / ") +
157
+ " (SLH-DSA has no COSE algorithm id and is not offered)");
158
+ }
159
+ if (!opts.privateKey) {
160
+ throw new CoseError("cose/no-key", "cose.sign: opts.privateKey is required");
161
+ }
162
+ var algId = ALG_NAME_TO_ID[opts.alg];
163
+ var params = _algParamsFor(algId);
164
+ var key = _toKeyObject(opts.privateKey, "private");
165
+
166
+ var protMap = new Map();
167
+ protMap.set(HDR_ALG, algId);
168
+ if (typeof opts.contentType === "number") protMap.set(HDR_CONTENT_TYPE, opts.contentType);
169
+ var protectedBstr = cbor.encode(protMap);
170
+
171
+ var unprot = new Map();
172
+ if (typeof opts.kid === "string") unprot.set(HDR_KID, Buffer.from(opts.kid, "utf8"));
173
+ if (opts.unprotectedHeaders && typeof opts.unprotectedHeaders === "object") {
174
+ var uk = Object.keys(opts.unprotectedHeaders);
175
+ for (var i = 0; i < uk.length; i++) unprot.set(Number(uk[i]), opts.unprotectedHeaders[uk[i]]);
176
+ }
177
+
178
+ var payloadBytes = _bstr(payload);
179
+ var externalAad = opts.externalAad == null ? Buffer.alloc(0) : _bstr(opts.externalAad);
180
+ var toBeSigned = _toBeSigned(protectedBstr, externalAad, payloadBytes);
181
+
182
+ // ML-DSA-87 + EdDSA: the KeyObject specifies the algorithm, so a
183
+ // null digest name is correct. ECDSA: a digest + the IEEE-P1363
184
+ // fixed-width signature encoding COSE mandates (RFC 9053 §2.1, not
185
+ // ASN.1 DER).
186
+ var signature = (params.nodeAlg === null)
187
+ ? nodeCrypto.sign(null, toBeSigned, key)
188
+ : nodeCrypto.sign(params.nodeAlg, toBeSigned, { key: key, dsaEncoding: params.dsaEncoding });
189
+
190
+ var sign1 = [protectedBstr, unprot, payloadBytes, signature];
191
+ return cbor.encode(new cbor.Tag(COSE_SIGN1_TAG, sign1));
192
+ }
193
+
194
+ /**
195
+ * @primitive b.cose.verify
196
+ * @signature b.cose.verify(coseSign1, opts)
197
+ * @since 0.12.33
198
+ * @status experimental
199
+ * @related b.cose.sign, b.cbor.decode
200
+ *
201
+ * Verify a COSE_Sign1 (RFC 9052) and return its payload + headers.
202
+ * The bytes are decoded through the bounded <code>b.cbor</code> codec;
203
+ * <code>alg</code> is read from the integrity-protected header and must
204
+ * be in <code>opts.algorithms</code>; a <code>crit</code> header naming
205
+ * a label the verifier does not understand is refused. Accepts ML-DSA-87
206
+ * plus the classical ECDSA / EdDSA COSE algorithms.
207
+ *
208
+ * @opts
209
+ * {
210
+ * algorithms: string[], // required — accepted alg names (allowlist)
211
+ * publicKey?: object, // the verification key (KeyObject / PEM)
212
+ * keyResolver?: function, // (protectedHeaders, unprotectedHeaders) → key
213
+ * externalAad?: Buffer, // must match what was signed
214
+ * maxBytes?: number, // forwarded to b.cbor.decode
215
+ * maxDepth?: number,
216
+ * }
217
+ *
218
+ * @example
219
+ * var out = await b.cose.verify(coseSign1, { algorithms: ["ML-DSA-87"], publicKey: pub });
220
+ * // → { payload: <Buffer>, alg: "ML-DSA-87", protectedHeaders: Map, unprotectedHeaders: Map }
221
+ */
222
+ async function verify(coseSign1, opts) {
223
+ validateOpts.requireObject(opts, "cose.verify", CoseError);
224
+ validateOpts(opts, ["algorithms", "publicKey", "keyResolver", "externalAad", "maxBytes", "maxDepth"], "cose.verify");
225
+ if (!Array.isArray(opts.algorithms) || opts.algorithms.length === 0) {
226
+ throw new CoseError("cose/algorithms-required",
227
+ "cose.verify: opts.algorithms is required (no defaults — name the accepted algorithms)");
228
+ }
229
+ for (var ai = 0; ai < opts.algorithms.length; ai++) {
230
+ if (!(opts.algorithms[ai] in ALG_NAME_TO_ID)) {
231
+ throw new CoseError("cose/unknown-alg", "cose.verify: unknown algorithm '" + opts.algorithms[ai] + "'");
232
+ }
233
+ }
234
+ if (!opts.publicKey && typeof opts.keyResolver !== "function") {
235
+ throw new CoseError("cose/no-key", "cose.verify: pass publicKey or keyResolver");
236
+ }
237
+
238
+ var decoded = cbor.decode(_bstr(coseSign1), {
239
+ allowedTags: [COSE_SIGN1_TAG],
240
+ maxBytes: opts.maxBytes,
241
+ maxDepth: opts.maxDepth,
242
+ });
243
+ // Accept tagged (18) or bare COSE_Sign1 array.
244
+ var arr = (decoded instanceof cbor.Tag && decoded.tag === COSE_SIGN1_TAG) ? decoded.value : decoded;
245
+ if (!Array.isArray(arr) || arr.length !== 4) {
246
+ throw new CoseError("cose/malformed", "cose.verify: not a COSE_Sign1 (expected a 4-element array)");
247
+ }
248
+ var protectedBstr = arr[0];
249
+ var unprotected = arr[1];
250
+ var payload = arr[2];
251
+ var signature = arr[3];
252
+ if (!Buffer.isBuffer(protectedBstr) || !Buffer.isBuffer(signature)) {
253
+ throw new CoseError("cose/malformed", "cose.verify: protected header and signature must be byte strings");
254
+ }
255
+ if (payload === null || payload === undefined) {
256
+ throw new CoseError("cose/detached-unsupported",
257
+ "cose.verify: detached payload (nil) is not supported in v1 — attached payload only");
258
+ }
259
+ // COSE_Sign1 payload is a bstr (RFC 9052 §4.2) — refuse a non-byte
260
+ // payload rather than return a value that violates the documented
261
+ // { payload: Buffer } shape.
262
+ if (!Buffer.isBuffer(payload)) {
263
+ throw new CoseError("cose/malformed", "cose.verify: payload must be a byte string (bstr)");
264
+ }
265
+ // The unprotected header is a CBOR map — refuse a non-map rather
266
+ // than silently coerce it to empty (callers read kid etc. from it).
267
+ if (!(unprotected instanceof Map)) {
268
+ throw new CoseError("cose/malformed", "cose.verify: unprotected header must be a CBOR map");
269
+ }
270
+
271
+ // Decode the protected header (bounded) — empty bstr means no protected headers.
272
+ var protMap = protectedBstr.length === 0 ? new Map()
273
+ : cbor.decode(protectedBstr, { maxBytes: opts.maxBytes, maxDepth: opts.maxDepth });
274
+ if (!(protMap instanceof Map)) {
275
+ throw new CoseError("cose/malformed", "cose.verify: protected header is not a CBOR map");
276
+ }
277
+
278
+ // crit-bypass defense: every label in a crit array must be one the
279
+ // verifier understands AND must be present in the protected header.
280
+ if (protMap.has(HDR_CRIT)) {
281
+ var crit = protMap.get(HDR_CRIT);
282
+ if (!Array.isArray(crit)) {
283
+ throw new CoseError("cose/bad-crit", "cose.verify: crit (label 2) must be an array");
284
+ }
285
+ for (var ci = 0; ci < crit.length; ci++) {
286
+ if (UNDERSTOOD_LABELS.indexOf(crit[ci]) === -1) {
287
+ throw new CoseError("cose/crit-unknown",
288
+ "cose.verify: crit lists header label " + crit[ci] + " which is not understood (RFC 9052 §3.1)");
289
+ }
290
+ if (!protMap.has(crit[ci])) {
291
+ throw new CoseError("cose/crit-absent",
292
+ "cose.verify: crit lists label " + crit[ci] + " not present in the protected header");
293
+ }
294
+ }
295
+ }
296
+
297
+ var algId = protMap.get(HDR_ALG);
298
+ var algName = ALG_ID_TO_NAME[algId];
299
+ if (algName === undefined) {
300
+ throw new CoseError("cose/unknown-alg", "cose.verify: unrecognized protected alg id " + algId);
301
+ }
302
+ if (opts.algorithms.indexOf(algName) === -1) {
303
+ throw new CoseError("cose/alg-not-allowed",
304
+ "cose.verify: alg '" + algName + "' is not in the allowlist");
305
+ }
306
+ var params = _algParamsFor(algId); // throws cose/unknown-alg on an unrecognized id
307
+
308
+ var key = opts.publicKey
309
+ ? _toKeyObject(opts.publicKey, "public")
310
+ : _toKeyObject(opts.keyResolver(protMap, unprotected), "public");
311
+
312
+ var externalAad = opts.externalAad == null ? Buffer.alloc(0) : _bstr(opts.externalAad);
313
+ var toBeSigned = _toBeSigned(protectedBstr, externalAad, payload);
314
+
315
+ var ok;
316
+ if (params.nodeAlg === null) {
317
+ ok = nodeCrypto.verify(null, toBeSigned, key, signature);
318
+ } else {
319
+ ok = nodeCrypto.verify(params.nodeAlg, toBeSigned,
320
+ { key: key, dsaEncoding: params.dsaEncoding }, signature);
321
+ }
322
+ if (!ok) {
323
+ throw new CoseError("cose/bad-signature", "cose.verify: signature verification failed");
324
+ }
325
+ return {
326
+ payload: payload,
327
+ alg: algName,
328
+ protectedHeaders: protMap,
329
+ unprotectedHeaders: unprotected,
330
+ };
331
+ }
332
+
333
+ module.exports = {
334
+ sign: sign,
335
+ verify: verify,
336
+ ALGORITHMS: ALG_NAME_TO_ID,
337
+ COSE_SIGN1_TAG: COSE_SIGN1_TAG,
338
+ CoseError: CoseError,
339
+ };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.12.32",
3
+ "version": "0.12.33",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
@@ -0,0 +1,31 @@
1
+ {
2
+ "$schema": "../scripts/release-notes-schema.json",
3
+ "version": "0.12.33",
4
+ "date": "2026-05-24",
5
+ "headline": "`b.cose` — COSE_Sign1 sign / verify (RFC 9052) over the in-tree CBOR codec",
6
+ "summary": "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.",
7
+ "sections": [
8
+ {
9
+ "heading": "Added",
10
+ "items": [
11
+ {
12
+ "title": "`b.cose.sign(payload, opts)` / `b.cose.verify(coseSign1, opts)`",
13
+ "body": "`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)."
14
+ }
15
+ ]
16
+ },
17
+ {
18
+ "heading": "Security",
19
+ "items": [
20
+ {
21
+ "title": "Bounded, alg-allowlisted, crit-checked verification",
22
+ "body": "`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."
23
+ },
24
+ {
25
+ "title": "ML-DSA-87 COSE algorithm id is a non-final draft",
26
+ "body": "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."
27
+ }
28
+ ]
29
+ }
30
+ ]
31
+ }
@@ -2237,6 +2237,21 @@ async function testNoDuplicateCodeBlocks() {
2237
2237
  ],
2238
2238
  reason: "v0.12.29 — input-shape validation prelude (`validateOpts(allowedKeys) + chained typeof / range guards + typed-error throw`). ai-dp.mechanism validates a DP mechanism descriptor (type / sensitivity / epsilon / delta / bound); dora._validateReportInput validates a DORA Art. 17 incident report; config.loadDbBacked validates DB-backed config opts; guard-snapshot-envelope.validate validates a sealed snapshot envelope. Each enforces a distinct spec's field set with a primitive-specific typed error; the shingle is the validateOpts-then-guard idiom, not behaviour.",
2239
2239
  },
2240
+ {
2241
+ mode: "family-subset",
2242
+ files: [
2243
+ "lib/cose.js:verify",
2244
+ "lib/auth/sd-jwt-vc-issuer.js:create",
2245
+ "lib/break-glass.js:_validatePolicySet",
2246
+ "lib/calendar.js:validate",
2247
+ "lib/db.js:declareRequireDualControl",
2248
+ "lib/dsr.js:create",
2249
+ "lib/fedcm.js:wellKnown",
2250
+ "lib/middleware/assetlinks.js:create",
2251
+ "lib/network-heartbeat.js:start",
2252
+ ],
2253
+ reason: "v0.12.33 — opts / structure validation prelude (`validateOpts(allowedKeys) + chained required-field + typeof guards + typed-error throw`). cose.verify validates a COSE_Sign1 opts blob + decoded structure (RFC 9052); the peers each validate a distinct spec's shape (SD-JWT-VC issuer opts / break-glass policy set / JSCalendar object / DDL dual-control declaration / DSR request / FedCM well-known manifest / Android Asset Links / heartbeat config). Each throws a primitive-specific typed error; the shingle is the validateOpts-then-guard idiom, not behaviour. Same family as the v0.12.29 input-shape-validation cluster.",
2254
+ },
2240
2255
  {
2241
2256
  mode: "family-subset",
2242
2257
  files: [
@@ -0,0 +1,130 @@
1
+ "use strict";
2
+ /**
3
+ * Layer 0 — b.cose COSE_Sign1 (RFC 9052) sign/verify over the in-tree
4
+ * b.cbor codec. Classical ECDSA / EdDSA (final COSE ids, useable
5
+ * today) + ML-DSA-87 (draft id, PQC-forward). Bounded decode +
6
+ * crit-bypass + alg-allowlist + tamper + external-aad binding.
7
+ */
8
+
9
+ var b = require("../../index");
10
+ var helpers = require("../helpers");
11
+ var check = helpers.check;
12
+ var nodeCrypto = require("node:crypto");
13
+
14
+ var EC = nodeCrypto.generateKeyPairSync("ec", { namedCurve: "P-256" });
15
+ var ED = nodeCrypto.generateKeyPairSync("ed25519");
16
+ var ML = nodeCrypto.generateKeyPairSync("ml-dsa-87");
17
+
18
+ function testSurface() {
19
+ check("b.cose.sign exposed", typeof b.cose.sign === "function");
20
+ check("b.cose.verify exposed", typeof b.cose.verify === "function");
21
+ check("b.cose.ALGORITHMS exposes COSE alg ids", b.cose.ALGORITHMS["ES256"] === -7 && b.cose.ALGORITHMS["ML-DSA-87"] === -50);
22
+ check("b.cose.COSE_SIGN1_TAG is 18", b.cose.COSE_SIGN1_TAG === 18);
23
+ check("b.cose.CborError-style CoseError exposed", typeof b.cose.CoseError === "function");
24
+ }
25
+
26
+ async function testClassicalUseableToday() {
27
+ var s = await b.cose.sign(Buffer.from("hello"), { alg: "ES256", privateKey: EC.privateKey, kid: "k1" });
28
+ check("ES256: output is a tagged COSE_Sign1 (tag 18 → 0xd2)", s[0] === 0xd2);
29
+ var v = await b.cose.verify(s, { algorithms: ["ES256"], publicKey: EC.publicKey });
30
+ check("ES256: round-trips payload + alg", v.payload.toString() === "hello" && v.alg === "ES256");
31
+ check("ES256: kid surfaced in unprotected headers", Buffer.isBuffer(v.unprotectedHeaders.get(4)) && v.unprotectedHeaders.get(4).toString() === "k1");
32
+ check("ES256: alg in protected header", v.protectedHeaders.get(1) === -7);
33
+
34
+ var sed = await b.cose.sign("msg", { alg: "EdDSA", privateKey: ED.privateKey });
35
+ check("EdDSA: round-trips (string payload → bstr)", (await b.cose.verify(sed, { algorithms: ["EdDSA"], publicKey: ED.publicKey })).payload.toString() === "msg");
36
+ }
37
+
38
+ async function testPqcForward() {
39
+ var s = await b.cose.sign(Buffer.from("pqc"), { alg: "ML-DSA-87", privateKey: ML.privateKey });
40
+ var v = await b.cose.verify(s, { algorithms: ["ML-DSA-87"], publicKey: ML.publicKey });
41
+ check("ML-DSA-87: round-trips (COSE alg -50, draft)", v.payload.toString() === "pqc" && v.alg === "ML-DSA-87" && v.protectedHeaders.get(1) === -50);
42
+ }
43
+
44
+ async function testTamperAndAllowlist() {
45
+ var s = await b.cose.sign(Buffer.from("data"), { alg: "ES256", privateKey: EC.privateKey });
46
+ var t = Buffer.from(s); t[t.length - 1] ^= 0xff;
47
+ var tampered = null;
48
+ try { await b.cose.verify(t, { algorithms: ["ES256"], publicKey: EC.publicKey }); } catch (e) { tampered = e; }
49
+ check("verify: tampered signature refused", tampered && tampered.code === "cose/bad-signature");
50
+
51
+ var notAllowed = null;
52
+ try { await b.cose.verify(s, { algorithms: ["EdDSA"], publicKey: EC.publicKey }); } catch (e) { notAllowed = e; }
53
+ check("verify: alg not in allowlist refused", notAllowed && notAllowed.code === "cose/alg-not-allowed");
54
+
55
+ // external_aad must match what was signed.
56
+ var sa = await b.cose.sign(Buffer.from("d"), { alg: "ES256", privateKey: EC.privateKey, externalAad: Buffer.from("ctx-A") });
57
+ var aadMismatch = null;
58
+ try { await b.cose.verify(sa, { algorithms: ["ES256"], publicKey: EC.publicKey, externalAad: Buffer.from("ctx-B") }); } catch (e) { aadMismatch = e; }
59
+ check("verify: external_aad mismatch refused", aadMismatch && aadMismatch.code === "cose/bad-signature");
60
+ var aadOk = await b.cose.verify(sa, { algorithms: ["ES256"], publicKey: EC.publicKey, externalAad: Buffer.from("ctx-A") });
61
+ check("verify: matching external_aad accepted", aadOk.payload.toString() === "d");
62
+ }
63
+
64
+ async function testCritBypassDefense() {
65
+ // Craft a COSE_Sign1 whose protected header lists an unknown crit
66
+ // label (99). The crit check fires before signature verification —
67
+ // an unknown mandatory label must be refused (RFC 9052 §3.1).
68
+ var protMap = new Map([[1, -7], [2, [99]]]);
69
+ var protectedBstr = b.cbor.encode(protMap);
70
+ var arr = [protectedBstr, new Map(), Buffer.from("p"), Buffer.from([0, 0])];
71
+ var coseBytes = b.cbor.encode(new b.cbor.Tag(18, arr));
72
+ var refused = null;
73
+ try { await b.cose.verify(coseBytes, { algorithms: ["ES256"], publicKey: EC.publicKey }); } catch (e) { refused = e; }
74
+ check("verify: unknown crit label refused (crit-bypass defense)", refused && refused.code === "cose/crit-unknown");
75
+ }
76
+
77
+ async function testValidation() {
78
+ var bads = [
79
+ [function () { return b.cose.sign(Buffer.from("x"), { alg: "SLH-DSA-SHAKE-256f", privateKey: ML.privateKey }); }, "cose/unsignable-alg"],
80
+ [function () { return b.cose.sign(Buffer.from("x"), { alg: "ES256" }); }, "cose/no-key"],
81
+ [function () { return b.cose.verify(Buffer.from([0x84]), { publicKey: EC.publicKey }); }, "cose/algorithms-required"],
82
+ [function () { return b.cose.verify(Buffer.from([0x84]), { algorithms: ["ES256"] }); }, "cose/no-key"],
83
+ ];
84
+ var ok = true;
85
+ for (var i = 0; i < bads.length; i++) {
86
+ var caught = null;
87
+ try { await bads[i][0](); } catch (e) { caught = e; }
88
+ if (!caught || caught.code !== bads[i][1]) { ok = false; check("validation " + i + " expected " + bads[i][1] + " got " + (caught && caught.code), false); }
89
+ }
90
+ check("sign/verify: malformed args throw the right codes", ok);
91
+
92
+ // Detached payload (nil) is explicitly unsupported in v1.
93
+ var protBstr = b.cbor.encode(new Map([[1, -7]]));
94
+ var detached = b.cbor.encode(new b.cbor.Tag(18, [protBstr, new Map(), null, Buffer.from([0, 0])]));
95
+ var det = null;
96
+ try { await b.cose.verify(detached, { algorithms: ["ES256"], publicKey: EC.publicKey }); } catch (e) { det = e; }
97
+ check("verify: detached payload refused (v1 attached-only)", det && det.code === "cose/detached-unsupported");
98
+
99
+ // Codex P2 on PR #184 — a non-byte payload (text string here) must
100
+ // be refused, not returned as a non-Buffer.
101
+ var textPayload = b.cbor.encode(new b.cbor.Tag(18, [protBstr, new Map(), "not-bytes", Buffer.from([0, 0])]));
102
+ var np = null;
103
+ try { await b.cose.verify(textPayload, { algorithms: ["ES256"], publicKey: EC.publicKey }); } catch (e) { np = e; }
104
+ check("verify: non-byte payload refused", np && np.code === "cose/malformed");
105
+
106
+ // Codex P2 on PR #184 — a non-map unprotected header must be refused,
107
+ // not silently coerced to empty.
108
+ var badUnprot = b.cbor.encode(new b.cbor.Tag(18, [protBstr, ["array-not-map"], Buffer.from("p"), Buffer.from([0, 0])]));
109
+ var bu = null;
110
+ try { await b.cose.verify(badUnprot, { algorithms: ["ES256"], publicKey: EC.publicKey }); } catch (e) { bu = e; }
111
+ check("verify: non-map unprotected header refused", bu && bu.code === "cose/malformed");
112
+ }
113
+
114
+ async function run() {
115
+ testSurface();
116
+ await testClassicalUseableToday();
117
+ await testPqcForward();
118
+ await testTamperAndAllowlist();
119
+ await testCritBypassDefense();
120
+ await testValidation();
121
+ }
122
+
123
+ module.exports = { run: run };
124
+
125
+ if (require.main === module) {
126
+ run().then(
127
+ function () { console.log("[cose] OK — " + helpers.getChecks() + " checks passed"); },
128
+ function (e) { console.error("FAIL:", e && e.stack || e); process.exit(1); }
129
+ );
130
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.0.126",
3
+ "version": "0.0.127",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {