@blamejs/blamejs-shop 0.1.30 → 0.1.31
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 +2 -0
- package/README.md +1 -0
- package/lib/asset-manifest.json +1 -1
- package/lib/storefront.js +415 -0
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +4 -0
- package/lib/vendor/blamejs/README.md +1 -0
- package/lib/vendor/blamejs/api-snapshot.json +44 -2
- package/lib/vendor/blamejs/index.js +2 -0
- package/lib/vendor/blamejs/lib/acme.js +2 -2
- package/lib/vendor/blamejs/lib/auth/dpop.js +14 -44
- package/lib/vendor/blamejs/lib/dbsc.js +5 -18
- package/lib/vendor/blamejs/lib/jwk.js +127 -0
- package/lib/vendor/blamejs/lib/middleware/bot-guard.js +43 -6
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.12.68.json +27 -0
- package/lib/vendor/blamejs/release-notes/v0.12.69.json +27 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/bot-guard.test.js +102 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +4 -1
- package/lib/vendor/blamejs/test/layer-0-primitives/jwk.test.js +72 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.1.x
|
|
10
10
|
|
|
11
|
+
- v0.1.31 (2026-05-26) — **Product compare — a side-by-side comparison table any shopper can build from the product pages.** Shoppers can now line products up against each other. Every product page gains an "Add to compare" control; a "Compare" link in the footer opens a side-by-side table of the chosen products — image, price, availability, and each product's attributes in aligned rows. No sign-in is required: the comparison basket rides the visitor's existing sealed session cookie, and a signed-in shopper's customer id is carried alongside. The basket holds up to four products (a fifth add is refused with a notice rather than quietly dropping one), the toggle is idempotent, and a product that goes archived or out of stock resolves out of the table with a remove control instead of breaking the page. The control and table render identically from the edge and the container, and the comparison page reuses the catalog grid styling, so no new stylesheet ships. **Added:** *Add to compare on the product page* — Each product page shows an "Add to compare" / "Remove from compare" control reflecting whether the product is already in the basket. `POST /compare/toggle` adds or removes it and redirects back to where the shopper was (the redirect target is validated, so a crafted `return_to` can't bounce the visitor off-site). Comparing needs no account — the basket is keyed on the visitor's existing sealed session cookie; a signed-in shopper's customer id is captured alongside. The basket is capped at four products: a fifth add is refused with a notice and the basket is left unchanged. · *The comparison table* — `GET /compare` renders the selected products side by side — image, title, price, availability, and the per-product attributes resolved through the catalog, in aligned rows. Each column has a remove control, and `POST /compare/clear` empties the whole basket. A product that has since been archived or sold out shows in a gone/out-of-stock state with a remove control rather than failing the render. An empty basket shows a friendly empty state and mints no cookie. A "Compare" link sits in the footer on every page.
|
|
12
|
+
|
|
11
13
|
- v0.1.30 (2026-05-26) — **Product Q&A — customer questions and seller answers on the product page.** Products gain a question-and-answer thread alongside reviews. A signed-in shopper asks a question from the product page; it lands pending and surfaces only after an operator approves it. Operators moderate at `/admin/questions`: a status-filtered queue of every question across the catalog, and a per-question detail page where they approve or reject the question, post the authoritative seller answer, approve / reject individual answers, and pin one answer as the definitive 'top answer'. Approved questions and their approved answers render on the product page in both the edge and container render paths, reusing the reviews section's styling so no new CSS ships. As with reviews, a missing Q&A table degrades the product page to an empty Q&A state rather than failing the render. **Added:** *Ask a question on the product page* — `GET /products/:slug/question` renders an auth-gated question form; `POST /products/:slug/question` submits it. Asking requires a signed-in customer — a logged-out shopper is redirected to sign in. The question is stored pending and is not shown on the product page until an operator approves it. A malformed submission re-renders the form with the reason and a 400; an unknown product is a 404. · *Published Q&A on the product page* — The product page shows approved questions with their approved answers below the reviews section. Each answer carries a source badge — 'Answered by the seller', 'Customer answer', or 'Automated answer' — and the operator-pinned answer is flagged 'Top answer'. The empty state invites the first question. Rendered identically by the edge worker and the container so the page is the same whichever serves it; the read degrades to the empty state when the Q&A tables aren't present. · *Q&A moderation console* — `/admin/questions` is the question queue across all products, with pending / approved / rejected status-filter chips. Each question opens a detail page showing its full answer thread (every status), an approve / reject control for the question, a form to post the seller's answer, and per-answer approve / reject / pin controls. Posting an answer leaves it pending; approving it publishes it on the product page. Pinning floats one operator answer to the top of the thread. Every path content-negotiates: a bearer-token client gets a JSON contract, a signed-in browser gets the HTML console. An unknown id or a refused transition redirects back with a notice, never a 500. · *productQA read APIs for the storefront and console* — `answersForQuestion` returns the approved answers under a question (pinned first) for the product-page thread; `listAnswersForQuestion` returns every answer regardless of status for the moderation detail; `listQuestionsByStatus` and `listAnswersByStatus` return the cross-product moderation queues. **Changed:** *Console nav gains Q&A; product page wires the Q&A primitive* — The signed-in admin nav includes Q&A, shown only when the Q&A primitive is wired so the link never points at an unmounted route. The storefront and admin share one Q&A instance, wired with the customer primitive so an authenticated questioner's id is verified before the row is stored.
|
|
12
14
|
|
|
13
15
|
- v0.1.29 (2026-05-26) — **Category navigation goes live — public browse pages for the storefront's category tree, with breadcrumbs and sub-category grids.** The category tree now has a customer-facing surface. A new /categories index lists the active top-level categories as a card grid, and a new /categories/<slug> page renders a single category: its title and optional description, a breadcrumb chain from the catalog root down to the current category, and a grid of the category's direct child sub-categories. Each page reads fresh against the active tree, so archived or unpublished categories drop out of every surface. An unknown, archived, or malformed slug returns a 404, and a category with no children renders a graceful empty state. The pages are linked from the site footer's Shop column. **Added:** *Category index at /categories* — A public page listing the active top-level categories as a card grid, each card carrying the category's title, optional description, and optional hero image, and linking into its browse page. Archived and unpublished categories are excluded. Linked from the site footer's Shop column. · *Category browse page at /categories/<slug>* — A public page for a single category showing its title and optional description, a breadcrumb chain from the catalog root down to the current category (each ancestor linked, the current category shown as plain text), an optional hero image, and a card grid of the category's direct child sub-categories. A category with no children renders a graceful empty state. An unknown, archived, or malformed slug returns a 404.
|
package/README.md
CHANGED
|
@@ -71,6 +71,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
|
|
|
71
71
|
| **`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. |
|
|
72
72
|
| **`lib/loyalty.js`** + **`lib/loyalty-earn-rules.js`** + **`lib/loyalty-redemption.js`** | Customer rewards. `loyalty` owns the points balance, lifetime total, tier (bronze → platinum on operator-tunable thresholds), and an audited transaction ledger. `loyalty-earn-rules` defines how points are minted (per-dollar-spent, per-order, signup, birthday, …) keyed to lifecycle events; `loyalty-redemption` is the reward catalog customers spend points against. Customers see all of it at `/account/loyalty` — balance + tier, the earn rules in plain language, the reward catalog with a one-click Redeem, past redemptions, and the paginated earn/redeem ledger (login-gated). Paid orders award points automatically: the order FSM's paid transition fans out to the earn rules fire-and-forget, deduped on the order id so a re-delivered payment webhook never double-credits. At checkout a signed-in customer can spend points for a credit against the order total — valued by the redemption ratio (100 points = $1 default), capped at the order's worth and the balance, debited once behind a balance-guarded SQL decrement, stacking with any gift-card credit; surplus points stay in the balance. |
|
|
73
73
|
| **`lib/referrals.js`** + **`lib/referral-leaderboard.js`** | Refer-a-friend with two-sided rewards. `referrals` issues each customer an 8-character code (32-glyph confusion-resistant alphabet via `b.crypto.generateBytes`) and tracks each referred friend through the funnel (invited → visited → joined → converted); referred emails are stored hash-only (`b.crypto.namespaceHash`), never plaintext. `referral-leaderboard` sits on top for top-referrer rankings + operator-tunable tier bonuses. Customers see all of it at `/account/referrals` — their code, an absolute shareable link, the friends they've referred (funnel stage + dates, no personal data), and an in-account leaderboard (rank + initials only). A public `/r/<code>` landing captures an inbound referral into a short-lived sealed first-party cookie (first-touch), which attributes the new customer to the referrer when they sign up by passkey / Google / Apple — self-referral and double-attribution guarded. When a referred customer's first order reaches paid, the order FSM's paid transition marks that referral converted and bumps the referrer's count fire-and-forget, deduped on the order id so a re-delivered webhook never double-counts; guest orders and a customer's later orders don't qualify. Issuing the payout itself (gift card / store credit / loyalty points) is an explicit operator action via the referrals reward API, so the reward instrument and any fraud review stay the operator's. |
|
|
74
|
+
| **`lib/product-compare.js`** | Side-by-side product comparison, no sign-in required. Each product page gets an "Add to compare" / "Remove from compare" control; `POST /compare/toggle` adds or removes the product and `GET /compare` renders the comparison table — image, price, availability, and the per-product attributes resolved through the catalog. The basket is keyed on the visitor's existing sealed session cookie (a signed-in shopper's customer id rides alongside), capped at four products (a fifth add is refused with a notice rather than silently dropping one), and is idempotent. Products that go archived or out of stock resolve out of the table with a remove control instead of breaking the render; an empty basket shows a friendly empty state. `POST /compare/clear` empties it. Linked from the footer on every page. |
|
|
74
75
|
| **`lib/recently-viewed.js`** | Signed-in customer browse history. A product-page visit records the view server-side against the customer's account (drop-silent — never blocks the page); `/account/recently-viewed` lists them newest-first as a grid with a Clear-history control. De-duped + capped per customer, archived products drop out, login-gated. Guest/session history is opt-in (a client beacon) and not shipped — the lib's `forSession` + `merge` support it. |
|
|
75
76
|
| **`lib/recommendations.js`** | Product-recommendation engine. Operator-curated override pins first (`setOverride` — "when viewing A, show B", kind-scoped + weight-ordered), then a signal-based fallback: co-purchase (products bought in the same orders), category-popular, and in-stock-random filler. `recommendForProduct` / `recommendForCart` / `recommendForCustomer` / `recommendForCategory` each return renderable picks (active + in-stock, source product excluded). The order confirmation page (`/orders/:id`) renders a "Customers also bought" rail from it — best-effort, anchored on the order's items, excluding what was just bought. |
|
|
76
77
|
| **`lib/collections.js`** | Curated + smart product groupings. `GET /collections` lists the shop's active collections; `GET /collections/:slug` renders the grid — manual collections list hand-picked members, smart collections evaluate stored rules against the active catalog and apply the collection's sort strategy. Each product resolves fresh, so archived products drop out. Public, no sign-in; a bad or unknown slug is a 404 (never a 500). Linked from the footer on every page. |
|
package/lib/asset-manifest.json
CHANGED
package/lib/storefront.js
CHANGED
|
@@ -165,6 +165,7 @@ var LAYOUT =
|
|
|
165
165
|
" <li><a href=\"/categories\">Categories</a></li>\n" +
|
|
166
166
|
" <li><a href=\"/?sort=new\">New arrivals</a></li>\n" +
|
|
167
167
|
" <li><a href=\"/?sort=sale\">On sale</a></li>\n" +
|
|
168
|
+
" <li><a href=\"/compare\">Compare</a></li>\n" +
|
|
168
169
|
" <li><a href=\"/cart\">Cart</a></li>\n" +
|
|
169
170
|
" </ul>\n" +
|
|
170
171
|
" </div>\n" +
|
|
@@ -745,6 +746,7 @@ var PRODUCT_PAGE =
|
|
|
745
746
|
" </div>\n" +
|
|
746
747
|
" </div>\n" +
|
|
747
748
|
" RAW_WISHLIST_PLACEHOLDER\n" +
|
|
749
|
+
" RAW_COMPARE_PLACEHOLDER\n" +
|
|
748
750
|
" </div>\n" +
|
|
749
751
|
" </div>\n" +
|
|
750
752
|
" RAW_REVIEWS_PLACEHOLDER\n" +
|
|
@@ -1162,6 +1164,177 @@ function renderWishlist(opts) {
|
|
|
1162
1164
|
});
|
|
1163
1165
|
}
|
|
1164
1166
|
|
|
1167
|
+
// Compare page notice banner — surfaced after a toggle / a full-basket
|
|
1168
|
+
// refusal. The route passes a short message key; unknown keys render
|
|
1169
|
+
// nothing so a forged `?notice=` query can't inject arbitrary copy.
|
|
1170
|
+
var COMPARE_NOTICES = {
|
|
1171
|
+
added: "Added to your comparison.",
|
|
1172
|
+
removed: "Removed from your comparison.",
|
|
1173
|
+
full: "Your comparison is full (4 products max). Remove one to add another.",
|
|
1174
|
+
cleared: "Comparison cleared.",
|
|
1175
|
+
};
|
|
1176
|
+
function _compareNotice(key) {
|
|
1177
|
+
var msg = COMPARE_NOTICES[key];
|
|
1178
|
+
if (!msg) return "";
|
|
1179
|
+
return "<p class=\"compare-page__notice\" role=\"status\">" + b.template.escapeHtml(msg) + "</p>";
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
// Side-by-side comparison table. `opts.columns` is the resolved per-
|
|
1183
|
+
// product column list in basket order:
|
|
1184
|
+
// { product_id, product, hero_media, price, available }
|
|
1185
|
+
// where `product` is null for a product archived / deleted between the
|
|
1186
|
+
// add and this render (orphan-tolerant — the column renders "no longer
|
|
1187
|
+
// available" and a remove control, never crashes the table). `opts.rows`
|
|
1188
|
+
// is the attribute matrix from product-compare's `compareTable`:
|
|
1189
|
+
// [ { attribute: { slug, label, format }, values_per_product: [...] } ]
|
|
1190
|
+
// each `values_per_product` entry positionally aligned with `columns`.
|
|
1191
|
+
// The currency/number/boolean formatting happens here (the display
|
|
1192
|
+
// layer), not in the primitive.
|
|
1193
|
+
function _compareCellValue(value, format) {
|
|
1194
|
+
var esc = b.template.escapeHtml;
|
|
1195
|
+
if (value == null) return "<span class=\"compare-cell--empty\" aria-hidden=\"true\">—</span><span class=\"sr-only\">Not specified</span>";
|
|
1196
|
+
if (format === "currency") {
|
|
1197
|
+
var minor = Number(value);
|
|
1198
|
+
if (!Number.isFinite(minor)) return esc(String(value));
|
|
1199
|
+
return esc(pricing.format(Math.round(minor), "USD"));
|
|
1200
|
+
}
|
|
1201
|
+
if (format === "boolean") {
|
|
1202
|
+
return value ? "Yes" : "No";
|
|
1203
|
+
}
|
|
1204
|
+
return esc(String(value));
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
function renderCompare(opts) {
|
|
1208
|
+
var esc = b.template.escapeHtml;
|
|
1209
|
+
var columns = opts.columns || [];
|
|
1210
|
+
var attrRows = opts.rows || [];
|
|
1211
|
+
var prefix = opts.asset_prefix || "/assets/";
|
|
1212
|
+
var notice = _compareNotice(opts.notice);
|
|
1213
|
+
|
|
1214
|
+
if (!columns.length) {
|
|
1215
|
+
var empty =
|
|
1216
|
+
"<section class=\"compare-page\">" +
|
|
1217
|
+
"<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
|
|
1218
|
+
"<li><a href=\"/\">Shop</a></li>" +
|
|
1219
|
+
"<li aria-current=\"page\">Compare</li>" +
|
|
1220
|
+
"</ol></nav>" +
|
|
1221
|
+
"<h1 class=\"compare-page__title\">Compare products</h1>" +
|
|
1222
|
+
notice +
|
|
1223
|
+
"<p class=\"compare-page__empty\">You haven't added anything to compare yet. Browse the shop and tap " +
|
|
1224
|
+
"<strong>Add to compare</strong> on up to four products to line them up side by side.</p>" +
|
|
1225
|
+
"<p><a class=\"btn-secondary\" href=\"/\">Browse the shop →</a></p>" +
|
|
1226
|
+
"</section>";
|
|
1227
|
+
return _wrap({
|
|
1228
|
+
title: "Compare products",
|
|
1229
|
+
shop_name: opts.shop_name || "blamejs.shop",
|
|
1230
|
+
cart_count: opts.cart_count == null ? 0 : opts.cart_count,
|
|
1231
|
+
theme_css: opts.theme_css,
|
|
1232
|
+
body: empty,
|
|
1233
|
+
});
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
// Per-column header cell — image + title + remove form. A column whose
|
|
1237
|
+
// product resolved out (archived / deleted) renders the gone-state
|
|
1238
|
+
// header and still offers the remove control so the shopper can clear
|
|
1239
|
+
// the dangling entry.
|
|
1240
|
+
function _headerCell(col) {
|
|
1241
|
+
var removeForm =
|
|
1242
|
+
"<form class=\"compare-col__remove\" method=\"post\" action=\"/compare/toggle\">" +
|
|
1243
|
+
"<input type=\"hidden\" name=\"product_id\" value=\"" + esc(col.product_id) + "\">" +
|
|
1244
|
+
"<input type=\"hidden\" name=\"return_to\" value=\"/compare\">" +
|
|
1245
|
+
"<button type=\"submit\" class=\"btn-ghost btn-ghost--sm\">Remove</button>" +
|
|
1246
|
+
"</form>";
|
|
1247
|
+
if (!col.product) {
|
|
1248
|
+
return "<th scope=\"col\" class=\"compare-col compare-col--gone\">" +
|
|
1249
|
+
"<span class=\"compare-col__title\">No longer available</span>" +
|
|
1250
|
+
removeForm +
|
|
1251
|
+
"</th>";
|
|
1252
|
+
}
|
|
1253
|
+
var slug = esc(col.product.slug);
|
|
1254
|
+
var thumb = col.hero_media
|
|
1255
|
+
? "<img src=\"" + esc(prefix + col.hero_media.r2_key) + "\" alt=\"" + esc(col.hero_media.alt_text || col.product.title) + "\" loading=\"lazy\">"
|
|
1256
|
+
: "<span class=\"compare-col__mark\" aria-hidden=\"true\">" + esc((col.product.title || "?").trim().charAt(0).toUpperCase() || "?") + "</span>";
|
|
1257
|
+
return "<th scope=\"col\" class=\"compare-col\">" +
|
|
1258
|
+
"<a class=\"compare-col__media\" href=\"/products/" + slug + "\">" + thumb + "</a>" +
|
|
1259
|
+
"<a class=\"compare-col__title\" href=\"/products/" + slug + "\">" + esc(col.product.title) + "</a>" +
|
|
1260
|
+
removeForm +
|
|
1261
|
+
"</th>";
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
var headerCells = "";
|
|
1265
|
+
for (var h = 0; h < columns.length; h += 1) headerCells += _headerCell(columns[h]);
|
|
1266
|
+
|
|
1267
|
+
// Fixed display rows resolved directly (richer than the attribute
|
|
1268
|
+
// matrix): price + availability. Price for a gone column renders "—".
|
|
1269
|
+
function _priceCell(col) {
|
|
1270
|
+
if (!col.product) return "<td class=\"compare-cell compare-cell--gone\">—</td>";
|
|
1271
|
+
return "<td class=\"compare-cell\">" + esc(col.price || "—") + "</td>";
|
|
1272
|
+
}
|
|
1273
|
+
function _availCell(col) {
|
|
1274
|
+
if (!col.product) return "<td class=\"compare-cell compare-cell--gone\">—</td>";
|
|
1275
|
+
return col.available
|
|
1276
|
+
? "<td class=\"compare-cell\"><span class=\"compare-cell__badge compare-cell__badge--ok\">In stock</span></td>"
|
|
1277
|
+
: "<td class=\"compare-cell\"><span class=\"compare-cell__badge\">Out of stock</span></td>";
|
|
1278
|
+
}
|
|
1279
|
+
var priceRow = "<tr><th scope=\"row\" class=\"compare-row-label\">Price</th>";
|
|
1280
|
+
var availRow = "<tr><th scope=\"row\" class=\"compare-row-label\">Availability</th>";
|
|
1281
|
+
for (var c = 0; c < columns.length; c += 1) {
|
|
1282
|
+
priceRow += _priceCell(columns[c]);
|
|
1283
|
+
availRow += _availCell(columns[c]);
|
|
1284
|
+
}
|
|
1285
|
+
priceRow += "</tr>";
|
|
1286
|
+
availRow += "</tr>";
|
|
1287
|
+
|
|
1288
|
+
// Attribute matrix rows from the primitive. The `price` attribute is
|
|
1289
|
+
// already surfaced as the fixed Price row above, so skip its duplicate
|
|
1290
|
+
// here; every other attribute renders one row, one cell per column.
|
|
1291
|
+
var attrRowsHtml = "";
|
|
1292
|
+
for (var r = 0; r < attrRows.length; r += 1) {
|
|
1293
|
+
var ar = attrRows[r];
|
|
1294
|
+
if (ar.attribute && ar.attribute.slug === "price") continue;
|
|
1295
|
+
var label = (ar.attribute && ar.attribute.label) || "";
|
|
1296
|
+
var fmt = ar.attribute && ar.attribute.format;
|
|
1297
|
+
var cells = "";
|
|
1298
|
+
var vals = ar.values_per_product || [];
|
|
1299
|
+
for (var v = 0; v < columns.length; v += 1) {
|
|
1300
|
+
cells += "<td class=\"compare-cell\">" + _compareCellValue(vals[v], fmt) + "</td>";
|
|
1301
|
+
}
|
|
1302
|
+
attrRowsHtml += "<tr><th scope=\"row\" class=\"compare-row-label\">" + esc(label) + "</th>" + cells + "</tr>";
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
var clearForm =
|
|
1306
|
+
"<form class=\"compare-page__clear\" method=\"post\" action=\"/compare/clear\">" +
|
|
1307
|
+
"<button type=\"submit\" class=\"btn-ghost\">Clear comparison</button>" +
|
|
1308
|
+
"</form>";
|
|
1309
|
+
|
|
1310
|
+
var body =
|
|
1311
|
+
"<section class=\"compare-page\">" +
|
|
1312
|
+
"<nav class=\"breadcrumb\" aria-label=\"Breadcrumb\"><ol>" +
|
|
1313
|
+
"<li><a href=\"/\">Shop</a></li>" +
|
|
1314
|
+
"<li aria-current=\"page\">Compare</li>" +
|
|
1315
|
+
"</ol></nav>" +
|
|
1316
|
+
"<header class=\"compare-page__head\">" +
|
|
1317
|
+
"<h1 class=\"compare-page__title\">Compare products (" + columns.length + ")</h1>" +
|
|
1318
|
+
clearForm +
|
|
1319
|
+
"</header>" +
|
|
1320
|
+
notice +
|
|
1321
|
+
"<div class=\"table-scroll\">" +
|
|
1322
|
+
"<table class=\"compare-table\">" +
|
|
1323
|
+
"<thead><tr><th scope=\"col\" class=\"compare-row-label\"><span class=\"sr-only\">Attribute</span></th>" + headerCells + "</tr></thead>" +
|
|
1324
|
+
"<tbody>" + priceRow + availRow + attrRowsHtml + "</tbody>" +
|
|
1325
|
+
"</table>" +
|
|
1326
|
+
"</div>" +
|
|
1327
|
+
"</section>";
|
|
1328
|
+
|
|
1329
|
+
return _wrap({
|
|
1330
|
+
title: "Compare products",
|
|
1331
|
+
shop_name: opts.shop_name || "blamejs.shop",
|
|
1332
|
+
cart_count: opts.cart_count == null ? 0 : opts.cart_count,
|
|
1333
|
+
theme_css: opts.theme_css,
|
|
1334
|
+
body: body,
|
|
1335
|
+
});
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1165
1338
|
// Account "Saved for later" page. `opts.items` is a resolved list:
|
|
1166
1339
|
// { save, product, hero_media } for live products, or { save,
|
|
1167
1340
|
// product: null } when the variant/product behind a saved row was
|
|
@@ -2093,6 +2266,27 @@ function _buildWishlist(productId, count) {
|
|
|
2093
2266
|
"</div>";
|
|
2094
2267
|
}
|
|
2095
2268
|
|
|
2269
|
+
// Product-level "Add to compare" control. Byte-compatible with the
|
|
2270
|
+
// edge renderer (`worker/render/product.js`) so both paths emit
|
|
2271
|
+
// identical markup. Action-only label — the toggle route resolves
|
|
2272
|
+
// add/remove server-side against the sealed session, and a guest who
|
|
2273
|
+
// has never compared anything still sees the same neutral control the
|
|
2274
|
+
// edge cache serves. The link to the compare table sits beside the
|
|
2275
|
+
// toggle so a shopper mid-basket can jump straight to the side-by-side
|
|
2276
|
+
// view.
|
|
2277
|
+
function _buildCompare(productId) {
|
|
2278
|
+
var esc = b.template.escapeHtml;
|
|
2279
|
+
return "<div class=\"compare\">" +
|
|
2280
|
+
"<form class=\"compare__form\" method=\"post\" action=\"/compare/toggle\">" +
|
|
2281
|
+
"<input type=\"hidden\" name=\"product_id\" value=\"" + esc(productId) + "\">" +
|
|
2282
|
+
"<button type=\"submit\" class=\"btn-secondary compare__btn\">" +
|
|
2283
|
+
"<span class=\"compare__icon\" aria-hidden=\"true\">⇄</span> Add to compare" +
|
|
2284
|
+
"</button>" +
|
|
2285
|
+
"</form>" +
|
|
2286
|
+
"<a class=\"compare__link card-link\" href=\"/compare\">View compare →</a>" +
|
|
2287
|
+
"</div>";
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2096
2290
|
// Schema.org JSON-LD block. JSON.stringify covers the standard escapes;
|
|
2097
2291
|
// the `</` → `<\/` rewrite neutralises any literal `</script>` in a
|
|
2098
2292
|
// value. Mirrors the edge renderer's `jsonLdScript`.
|
|
@@ -2128,6 +2322,7 @@ function renderProduct(opts) {
|
|
|
2128
2322
|
reviews_html: _buildReviews(opts.review_summary, opts.reviews, opts.review_cta),
|
|
2129
2323
|
qa_html: _buildProductQa(opts.qa_questions, opts.qa_cta),
|
|
2130
2324
|
wishlist_html: _buildWishlist(opts.product.id, opts.wishlist_count),
|
|
2325
|
+
compare_html: _buildCompare(opts.product.id),
|
|
2131
2326
|
asset_css_main: opts.theme.assetUrl("css/main.css"),
|
|
2132
2327
|
});
|
|
2133
2328
|
}
|
|
@@ -2139,6 +2334,7 @@ function renderProduct(opts) {
|
|
|
2139
2334
|
var reviewsHtml = _buildReviews(opts.review_summary, opts.reviews, opts.review_cta);
|
|
2140
2335
|
var qaHtml = _buildProductQa(opts.qa_questions, opts.qa_cta);
|
|
2141
2336
|
var wishlistHtml = _buildWishlist(opts.product.id, opts.wishlist_count);
|
|
2337
|
+
var compareHtml = _buildCompare(opts.product.id);
|
|
2142
2338
|
var body = _render(PRODUCT_PAGE, {
|
|
2143
2339
|
title: opts.product.title,
|
|
2144
2340
|
description: description,
|
|
@@ -2147,6 +2343,7 @@ function renderProduct(opts) {
|
|
|
2147
2343
|
.replace("RAW_GALLERY_PLACEHOLDER", galleryHtml)
|
|
2148
2344
|
.replace("RAW_ROWS_PLACEHOLDER", rows)
|
|
2149
2345
|
.replace("RAW_WISHLIST_PLACEHOLDER", wishlistHtml)
|
|
2346
|
+
.replace("RAW_COMPARE_PLACEHOLDER", compareHtml)
|
|
2150
2347
|
.replace("RAW_REVIEWS_PLACEHOLDER", reviewsHtml)
|
|
2151
2348
|
.replace("RAW_QA_PLACEHOLDER", qaHtml);
|
|
2152
2349
|
// Product-specific OpenGraph + Twitter Card values so shares
|
|
@@ -3589,6 +3786,224 @@ function mount(router, deps) {
|
|
|
3589
3786
|
_send(res, 200, html);
|
|
3590
3787
|
});
|
|
3591
3788
|
|
|
3789
|
+
// Product compare — the guest-or-customer side-by-side basket. Mounts
|
|
3790
|
+
// when the productCompare primitive is wired. The basket is keyed on
|
|
3791
|
+
// the same `shop_sid` session cookie the cart uses (a routing key that
|
|
3792
|
+
// grants zero authority — the primitive namespace-hashes it before it
|
|
3793
|
+
// touches the database), so a shopper compares without authenticating;
|
|
3794
|
+
// a logged-in shopper's customer_id rides alongside so the operator's
|
|
3795
|
+
// account widget could resume the basket on another device. All three
|
|
3796
|
+
// routes (toggle / clear / view) are writes-or-session-reads, so they
|
|
3797
|
+
// live in the container — the edge forwards them (and any request
|
|
3798
|
+
// carrying shop_sid skips the edge cache, so the view always reflects
|
|
3799
|
+
// the live basket).
|
|
3800
|
+
if (deps.productCompare) {
|
|
3801
|
+
// Resolve the compare basket's session id. Reads the cart session
|
|
3802
|
+
// cookie; when `mint` is true and none exists, allocates one + sets
|
|
3803
|
+
// the cookie so the basket has a stable key across requests. A
|
|
3804
|
+
// read-only path (the view) passes mint=false and returns null when
|
|
3805
|
+
// there's no session — that renders the empty state without minting
|
|
3806
|
+
// a cookie on a bare GET.
|
|
3807
|
+
function _compareSid(req, res, mint) {
|
|
3808
|
+
var sid = _readSidCookie(req);
|
|
3809
|
+
if (sid) return sid;
|
|
3810
|
+
if (!mint) return null;
|
|
3811
|
+
sid = b.uuid.v7();
|
|
3812
|
+
_setSidCookie(res, sid);
|
|
3813
|
+
return sid;
|
|
3814
|
+
}
|
|
3815
|
+
|
|
3816
|
+
function _compareRedirect(res, dest, notice) {
|
|
3817
|
+
var sep = dest.indexOf("?") === -1 ? "?" : "&";
|
|
3818
|
+
var to = notice ? (dest + sep + "notice=" + encodeURIComponent(notice)) : dest;
|
|
3819
|
+
res.status(303);
|
|
3820
|
+
res.setHeader && res.setHeader("location", to);
|
|
3821
|
+
return res.end ? res.end() : res.send("");
|
|
3822
|
+
}
|
|
3823
|
+
|
|
3824
|
+
// POST /compare/toggle — add the product if it isn't in the basket,
|
|
3825
|
+
// remove it if it is. Idempotent (the primitive collapses a repeat
|
|
3826
|
+
// add / a remove of an absent id). Redirects to `return_to` when it's
|
|
3827
|
+
// a safe same-origin path (the compare page's per-column Remove uses
|
|
3828
|
+
// it), otherwise back to the product PDP (the canonical slug is
|
|
3829
|
+
// resolved from product_id, so a forged slug can't drive an open
|
|
3830
|
+
// redirect). No auth required — guests compare too.
|
|
3831
|
+
router.post("/compare/toggle", async function (req, res) {
|
|
3832
|
+
var productId = (req.body || {}).product_id;
|
|
3833
|
+
var sid = _compareSid(req, res, true);
|
|
3834
|
+
var custEnv = _currentCustomerEnv(req);
|
|
3835
|
+
var customerId = custEnv ? custEnv.customer_id : null;
|
|
3836
|
+
|
|
3837
|
+
// Resolve the safe redirect destination up front so a validation
|
|
3838
|
+
// failure still lands the shopper somewhere sane.
|
|
3839
|
+
var rt = (req.body || {}).return_to;
|
|
3840
|
+
var dest = null;
|
|
3841
|
+
if (typeof rt === "string" && /^\/[^/]/.test(rt)) dest = rt;
|
|
3842
|
+
|
|
3843
|
+
var notice;
|
|
3844
|
+
try {
|
|
3845
|
+
var list = await deps.productCompare.getCompareList({ session_id: sid });
|
|
3846
|
+
var inBasket = list.product_ids.indexOf(productId) !== -1;
|
|
3847
|
+
if (inBasket) {
|
|
3848
|
+
await deps.productCompare.removeFromCompare({ session_id: sid, product_id: productId });
|
|
3849
|
+
notice = "removed";
|
|
3850
|
+
} else {
|
|
3851
|
+
await deps.productCompare.addToCompare({ session_id: sid, product_id: productId, customer_id: customerId });
|
|
3852
|
+
notice = "added";
|
|
3853
|
+
// Best-effort impression telemetry — drop-silent, never block
|
|
3854
|
+
// the toggle on the merchandising ledger write.
|
|
3855
|
+
try {
|
|
3856
|
+
await deps.productCompare.recordImpression({ session_id: sid, product_id: productId, source_kind: "product_page" });
|
|
3857
|
+
} catch (_e) { /* drop-silent — impression telemetry is supplementary */ }
|
|
3858
|
+
}
|
|
3859
|
+
} catch (e) {
|
|
3860
|
+
if (e && e.code === "COMPARE_FULL") {
|
|
3861
|
+
// Cap reached — refuse the add with a notice rather than a
|
|
3862
|
+
// crash. The basket is unchanged; the shopper removes one to
|
|
3863
|
+
// make room.
|
|
3864
|
+
if (!dest) {
|
|
3865
|
+
var capProduct = null;
|
|
3866
|
+
try { capProduct = await deps.catalog.products.get(productId); } catch (_e2) { capProduct = null; }
|
|
3867
|
+
dest = capProduct ? ("/products/" + encodeURIComponent(capProduct.slug)) : "/compare";
|
|
3868
|
+
}
|
|
3869
|
+
return _compareRedirect(res, dest, "full");
|
|
3870
|
+
}
|
|
3871
|
+
// A malformed product id (or session id) is a client error.
|
|
3872
|
+
res.status(e instanceof TypeError ? 400 : 500);
|
|
3873
|
+
return res.end ? res.end((e && e.message) || "Error") : res.send((e && e.message) || "Error");
|
|
3874
|
+
}
|
|
3875
|
+
|
|
3876
|
+
if (!dest) {
|
|
3877
|
+
var product = null;
|
|
3878
|
+
try { product = await deps.catalog.products.get(productId); } catch (_e) { product = null; }
|
|
3879
|
+
dest = product ? ("/products/" + encodeURIComponent(product.slug)) : "/compare";
|
|
3880
|
+
}
|
|
3881
|
+
return _compareRedirect(res, dest, notice);
|
|
3882
|
+
});
|
|
3883
|
+
|
|
3884
|
+
// POST /compare/clear — drop the whole basket. Idempotent (clearing
|
|
3885
|
+
// an empty basket is a no-op). Always redirects back to the compare
|
|
3886
|
+
// page so the shopper sees the emptied state.
|
|
3887
|
+
router.post("/compare/clear", async function (req, res) {
|
|
3888
|
+
var sid = _compareSid(req, res, false);
|
|
3889
|
+
if (sid) {
|
|
3890
|
+
try { await deps.productCompare.clearCompareList({ session_id: sid }); }
|
|
3891
|
+
catch (e) {
|
|
3892
|
+
if (!(e instanceof TypeError)) {
|
|
3893
|
+
res.status(500);
|
|
3894
|
+
return res.end ? res.end((e && e.message) || "Error") : res.send((e && e.message) || "Error");
|
|
3895
|
+
}
|
|
3896
|
+
// A malformed session cookie can't address a basket — treat
|
|
3897
|
+
// the clear as a no-op rather than a 400 (the cookie grants
|
|
3898
|
+
// zero authority; a stale/garbage value just means "no basket").
|
|
3899
|
+
}
|
|
3900
|
+
}
|
|
3901
|
+
return _compareRedirect(res, "/compare", "cleared");
|
|
3902
|
+
});
|
|
3903
|
+
|
|
3904
|
+
// GET /compare — the side-by-side comparison table for the session's
|
|
3905
|
+
// basket. Empty basket → the friendly empty state. Each product id is
|
|
3906
|
+
// resolved through the catalog; a product archived / deleted between
|
|
3907
|
+
// the add and this render resolves out gracefully (the column renders
|
|
3908
|
+
// "no longer available" with a remove control). The attribute matrix
|
|
3909
|
+
// comes from the primitive's compareTable, fed a getProduct adapter
|
|
3910
|
+
// enriched with the product's first variant + current price so the
|
|
3911
|
+
// baked-in price/sku/weight attributes resolve against this catalog's
|
|
3912
|
+
// schema.
|
|
3913
|
+
router.get("/compare", async function (req, res) {
|
|
3914
|
+
var sid = _compareSid(req, res, false);
|
|
3915
|
+
var cartCount = await _cartCountForReq(req);
|
|
3916
|
+
// Read the post-toggle notice key from the query string the same
|
|
3917
|
+
// way the search route reads `?q=` — via the parsed URL, not
|
|
3918
|
+
// `req.query` (which the router only populates when a route
|
|
3919
|
+
// declares a query validator). renderCompare gates the key
|
|
3920
|
+
// against a fixed allowlist, so a forged value renders nothing.
|
|
3921
|
+
var compareUrl = req.url ? new URL(req.url, "http://localhost") : null;
|
|
3922
|
+
var noticeKey = compareUrl ? compareUrl.searchParams.get("notice") : null;
|
|
3923
|
+
|
|
3924
|
+
if (!sid) {
|
|
3925
|
+
return _send(res, 200, renderCompare({
|
|
3926
|
+
columns: [], rows: [], shop_name: shopName, cart_count: cartCount,
|
|
3927
|
+
asset_prefix: _cardAssetPrefix, notice: noticeKey,
|
|
3928
|
+
}));
|
|
3929
|
+
}
|
|
3930
|
+
|
|
3931
|
+
// Per-request product cache so the header-column decoration and the
|
|
3932
|
+
// attribute-matrix adapter don't double-fetch the same product.
|
|
3933
|
+
var enrichedCache = Object.create(null);
|
|
3934
|
+
async function _enrich(pid) {
|
|
3935
|
+
if (Object.prototype.hasOwnProperty.call(enrichedCache, pid)) return enrichedCache[pid];
|
|
3936
|
+
var result = { product: null, variant: null, price: null, hero_media: null, available: false };
|
|
3937
|
+
var product = null;
|
|
3938
|
+
// get() throws a TypeError on a malformed id; a basket can only
|
|
3939
|
+
// hold strict-UUID ids (the primitive sanitises on add), but stay
|
|
3940
|
+
// defensive — a bad id resolves to the gone-state column.
|
|
3941
|
+
try { product = await deps.catalog.products.get(pid); } catch (_e) { product = null; }
|
|
3942
|
+
if (product && product.status === "active") {
|
|
3943
|
+
result.product = product;
|
|
3944
|
+
var variants = await deps.catalog.variants.listForProduct(pid);
|
|
3945
|
+
if (variants.length) {
|
|
3946
|
+
result.variant = variants[0];
|
|
3947
|
+
var pr = await deps.catalog.prices.current(variants[0].id, "USD");
|
|
3948
|
+
if (pr) result.price = pricing.format(pr.amount_minor, pr.currency);
|
|
3949
|
+
// Availability — best-effort inventory read on the first
|
|
3950
|
+
// variant. A product with no inventory row reads as available
|
|
3951
|
+
// (operators who don't track stock still sell), an explicit
|
|
3952
|
+
// zero-on-hand reads as out of stock.
|
|
3953
|
+
try {
|
|
3954
|
+
var inv = await deps.catalog.inventory.get(variants[0].sku);
|
|
3955
|
+
result.available = !inv || (Number(inv.stock_on_hand) - Number(inv.stock_held)) > 0;
|
|
3956
|
+
} catch (_e) { result.available = true; }
|
|
3957
|
+
} else {
|
|
3958
|
+
result.available = false;
|
|
3959
|
+
}
|
|
3960
|
+
var media = await deps.catalog.media.listForProduct(pid);
|
|
3961
|
+
result.hero_media = media.length ? media[0] : null;
|
|
3962
|
+
}
|
|
3963
|
+
enrichedCache[pid] = result;
|
|
3964
|
+
return result;
|
|
3965
|
+
}
|
|
3966
|
+
|
|
3967
|
+
var list = await deps.productCompare.getCompareList({ session_id: sid });
|
|
3968
|
+
var columns = [];
|
|
3969
|
+
for (var i = 0; i < list.product_ids.length; i += 1) {
|
|
3970
|
+
var pid = list.product_ids[i];
|
|
3971
|
+
var e = await _enrich(pid);
|
|
3972
|
+
columns.push({
|
|
3973
|
+
product_id: pid,
|
|
3974
|
+
product: e.product,
|
|
3975
|
+
hero_media: e.hero_media,
|
|
3976
|
+
price: e.price,
|
|
3977
|
+
available: e.available,
|
|
3978
|
+
});
|
|
3979
|
+
}
|
|
3980
|
+
|
|
3981
|
+
// Attribute matrix from the primitive. compareTable walks the
|
|
3982
|
+
// basket's product ids through the `catalog` adapter wired into the
|
|
3983
|
+
// primitive at create time (server.js) — that adapter enriches each
|
|
3984
|
+
// product with a `variants` array (price as `price_minor`, `weight`
|
|
3985
|
+
// from `weight_grams`) so the baked-in variant-sourced attributes
|
|
3986
|
+
// resolve against this catalog's column shape. compareTable refuses
|
|
3987
|
+
// (throws) when no catalog adapter was wired; a resolution failure
|
|
3988
|
+
// degrades to no attribute rows — the fixed Price/Availability rows
|
|
3989
|
+
// still render the side-by-side view.
|
|
3990
|
+
var attrRows = [];
|
|
3991
|
+
try {
|
|
3992
|
+
var table = await deps.productCompare.compareTable({ session_id: sid });
|
|
3993
|
+
attrRows = table.rows || [];
|
|
3994
|
+
} catch (_e) { attrRows = []; }
|
|
3995
|
+
|
|
3996
|
+
_send(res, 200, renderCompare({
|
|
3997
|
+
columns: columns,
|
|
3998
|
+
rows: attrRows,
|
|
3999
|
+
shop_name: shopName,
|
|
4000
|
+
cart_count: cartCount,
|
|
4001
|
+
asset_prefix: _cardAssetPrefix,
|
|
4002
|
+
notice: noticeKey,
|
|
4003
|
+
}));
|
|
4004
|
+
});
|
|
4005
|
+
}
|
|
4006
|
+
|
|
3592
4007
|
// Collections — operator-curated + smart product lists. Public browse
|
|
3593
4008
|
// pages; mounted when the collections primitive is wired.
|
|
3594
4009
|
if (deps.collections) {
|
package/lib/vendor/MANIFEST.json
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
"_about": "blamejs.shop vendors a single framework — blamejs — which itself bundles every server-side crypto/identity dependency. The transitive packages blamejs ships are surfaced in its own MANIFEST.json at lib/vendor/blamejs/lib/vendor/MANIFEST.json — Trivy / Grype rely on that nested data for CVE attribution.",
|
|
4
4
|
"packages": {
|
|
5
5
|
"blamejs": {
|
|
6
|
-
"version": "0.12.
|
|
7
|
-
"tag": "v0.12.
|
|
6
|
+
"version": "0.12.69",
|
|
7
|
+
"tag": "v0.12.69",
|
|
8
8
|
"license": "Apache-2.0",
|
|
9
9
|
"author": "blamejs contributors",
|
|
10
10
|
"source": "https://github.com/blamejs/blamejs",
|
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.12.x
|
|
10
10
|
|
|
11
|
+
- v0.12.69 (2026-05-26) — **`b.middleware.botGuard` no longer blocks browsers that omit Sec-Fetch-Mode.** b.middleware.botGuard treated a missing Sec-Fetch-Mode header as a bot signal and returned 403 Forbidden, which refused legitimate browsers on any origin where the browser does not emit Fetch Metadata: every plain-HTTP non-localhost origin (Umbrel apps, LAN and *.local reverse-proxy deployments) and Safari before 16.4 even over HTTPS. Browsers only send Sec-Fetch-* in a secure context, so its absence is normal there — not a bot. Sec-Fetch-Mode is now advisory only: it never blocks, and it sets req.suspectedBot in mode:"tag" only on a secure-context HTML GET where a modern browser would have sent it. Drive-by bots are still blocked by the missing-Accept-Language and User-Agent heuristics. No configuration change is needed; if you had widened skipPaths or disabled bot-guard to work around this, you can revert that. **Fixed:** *`b.middleware.botGuard` no longer 403s browsers over plain HTTP or older Safari* — A missing `Sec-Fetch-Mode` was a blocking heuristic, but browsers omit Fetch Metadata outside a secure context (every plain-HTTP non-localhost origin — Umbrel, LAN, `*.local` proxies) and Safari < 16.4 omits it even over HTTPS. Those legitimate browsers were refused with `403 Forbidden`. `Sec-Fetch-Mode` is now advisory: it never blocks, and only sets `req.suspectedBot` in `mode: "tag"` on a secure-context HTML GET. The `Accept-Language` and User-Agent heuristics (which catch the same bots) are unchanged. **Detectors:** *reserved-hostname trailing-dot detector recognizes regex strips* — The codebase-patterns gate that requires stripping the RFC 1034 trailing root-zone dot before a reserved-hostname comparison now also recognizes end-anchored regex strips (`.replace(/\.$/, …)`), not only the `charAt` / `while`-loop forms.
|
|
12
|
+
|
|
13
|
+
- v0.12.68 (2026-05-26) — **`b.jwk` — RFC 7638 JWK thumbprint.** Compute the RFC 7638 thumbprint of a JSON Web Key — the canonical base64url(SHA-256(canonical-JSON)) identifier used to name a key (DPoP jkt bindings, ACME account-key thumbprints, DBSC session pins, kid derivation). b.jwk.thumbprint(jwk) returns the digest; b.jwk.canonicalize(jwk) returns the exact JSON that is hashed — only the key-type's required members, member names in lexicographic order, no whitespace, so the same key always yields the same thumbprint regardless of how its JWK was serialized. The standard key types are supported (EC, RSA, oct, OKP per RFC 8037) plus AKP, the IANA key type Node uses for ML-DSA / SLH-DSA post-quantum public keys; SHA-256 is the default, with hash: "sha384" | "sha512" for RFC 9278 thumbprint-with-hash. Verified against the RFC 7638 §3.1 worked example. b.auth.dpop, b.acme, and b.dbsc now compute their thumbprints through this primitive. **Added:** *`b.jwk.thumbprint` / `b.jwk.canonicalize`* — RFC 7638 JWK thumbprint. `thumbprint(jwk, opts)` returns `base64url(hash(canonical-JSON))` — only the key-type's required members feed the hash, so optional fields (`kid`, `use`, `alg`, …) never change the result. `canonicalize(jwk)` returns the canonical JSON string itself. Supports EC / RSA / oct / OKP and the AKP post-quantum key type; SHA-256 default, `hash` selects SHA-384 / SHA-512. Throws `JwkError` on an invalid key or unknown hash. **Changed:** *DPoP, ACME, and DBSC compose `b.jwk`* — `b.auth.dpop` (the `jkt` proof-key thumbprint), `b.acme` (the RFC 8555 account-key authorization), and `b.dbsc` (the session-pin thumbprint) now compute RFC 7638 thumbprints through `b.jwk` instead of carrying their own implementations. Behavior is unchanged — DPoP still refuses symmetric key types, and each surface keeps its own error codes.
|
|
14
|
+
|
|
11
15
|
- v0.12.66 (2026-05-26) — **`b.uriTemplate` — RFC 6570 URI Template expansion.** Expand RFC 6570 URI Templates — the {var} syntax that OpenAPI links, HAL _links, and hypermedia API clients use to turn a template plus a set of variables into a concrete URI. The full Level 4 grammar is supported: every operator ({+var} reserved, {#var} fragment, {.var} label, {/var} path, {;var} path-style parameters, {?var} query, {&var} query continuation), the {var:3} prefix modifier, and the {var*} explode modifier for lists and associative arrays. b.uriTemplate.expand(template, vars) returns the expanded string; b.uriTemplate.compile(template) parses once for templates applied to many variable sets. A malformed template (unclosed expression, reserved operator, non-numeric prefix, unmatched brace) throws UriTemplateError. Verified against the official uritemplate-test conformance suite (all 135 spec, extended, and negative cases). **Added:** *`b.uriTemplate.expand` / `b.uriTemplate.compile`* — RFC 6570 URI Template expansion, full Level 4. `expand(template, vars)` substitutes variables into a template and returns the URI; `compile(template)` returns a reusable `{ expand }` for repeated use. Variable values may be strings, numbers, booleans, arrays (lists), or plain objects (associative arrays); undefined, null, and empty list/map variables are omitted. All eight operators, the `:N` prefix modifier, and the `*` explode modifier follow §3.2, including reserved-set encoding for `{+var}` / `{#var}`. Composes naturally with `b.hal`, `b.linkHeader`, and `b.openapi` link objects. A malformed template throws `UriTemplateError`.
|
|
12
16
|
|
|
13
17
|
- v0.12.65 (2026-05-26) — **`b.base32` — RFC 4648 Base32 encode / decode.** Encode and decode RFC 4648 Base32 — the case-insensitive alphabet behind TOTP / 2FA secrets, DNSSEC NSEC3 hashes, and human-transcribable identifiers. Both RFC 4648 variants are supported: the standard alphabet (the default) and the extended-hex alphabet. b.base32.encode pads to an 8-character boundary by default (pass padding: false for the bare form TOTP key URIs use); b.base32.decode is strict by default but accepts the real-world shapes humans produce — lower-case, embedded spaces and dashes, missing padding — under loose: true. Verified against the RFC 4648 §10 test vectors for both alphabets. The TOTP primitive now composes this codec instead of carrying its own Base32 implementation. **Added:** *`b.base32.encode` / `b.base32.decode`* — RFC 4648 Base32 codec. `encode(buf, opts)` takes a Buffer or Uint8Array and returns a Base32 string, padded to an 8-character boundary unless `padding: false`; `decode(str, opts)` returns a Buffer. The `variant` option selects the standard (`"rfc4648"`, default) or extended-hex (`"rfc4648-hex"`) alphabet. Decoding is strict by default — any character outside the alphabet throws `Base32Error` — and `loose: true` up-cases the input and ignores embedded spaces, dashes, and missing padding, which is how copied TOTP keys and hand-typed codes arrive. **Changed:** *TOTP composes `b.base32`* — `b.auth.totp` now encodes and decodes its secrets through `b.base32` rather than a private Base32 implementation. Behavior is unchanged — secrets are still emitted unpadded and parsed leniently (case-insensitive, ignoring spaces and dashes).
|
|
@@ -71,6 +71,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
|
|
|
71
71
|
|
|
72
72
|
- **Passwords** — Argon2id + policy primitive (`b.auth.password`); NIST 800-63B / PCI-DSS 4.0 / HIPAA-AAL2 profiles; HaveIBeenPwned k-anonymity breach check; length / context / dictionary / complexity rules; rotation + history
|
|
73
73
|
- **Multi-factor + WebAuthn** — passkeys (WebAuthn), TOTP, JWT (PQ-default)
|
|
74
|
+
- **JWK thumbprint** — RFC 7638 `base64url(SHA-256(canonical-JSON))` key identifier (`b.jwk.thumbprint` / `canonicalize`): EC / RSA / oct / OKP + the AKP post-quantum key type, SHA-256/384/512; the canonical key name behind DPoP `jkt`, ACME account keys, and DBSC session pins
|
|
74
75
|
- **OAuth / OIDC RP** — `b.auth.oauth`
|
|
75
76
|
- RP-Initiated / Front-Channel / Back-Channel Logout 1.0 (`parseFrontchannelLogoutRequest` + `verifyBackchannelLogoutToken` with jti-replay defense)
|
|
76
77
|
- RFC 9207 AS Issuer Identifier validation on callbacks (`parseCallback` — refuses iss mismatch + OP `error=` redirect)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 1,
|
|
3
|
-
"frameworkVersion": "0.12.
|
|
4
|
-
"createdAt": "2026-05-
|
|
3
|
+
"frameworkVersion": "0.12.69",
|
|
4
|
+
"createdAt": "2026-05-26T14:58:13.295Z",
|
|
5
5
|
"exports": {
|
|
6
6
|
"a2a": {
|
|
7
7
|
"type": "object",
|
|
@@ -37180,6 +37180,48 @@
|
|
|
37180
37180
|
}
|
|
37181
37181
|
}
|
|
37182
37182
|
},
|
|
37183
|
+
"jwk": {
|
|
37184
|
+
"type": "object",
|
|
37185
|
+
"members": {
|
|
37186
|
+
"JwkError": {
|
|
37187
|
+
"type": "function",
|
|
37188
|
+
"arity": 4
|
|
37189
|
+
},
|
|
37190
|
+
"REQUIRED": {
|
|
37191
|
+
"type": "object",
|
|
37192
|
+
"members": {
|
|
37193
|
+
"AKP": {
|
|
37194
|
+
"type": "instance",
|
|
37195
|
+
"ctorName": "Array"
|
|
37196
|
+
},
|
|
37197
|
+
"EC": {
|
|
37198
|
+
"type": "instance",
|
|
37199
|
+
"ctorName": "Array"
|
|
37200
|
+
},
|
|
37201
|
+
"OKP": {
|
|
37202
|
+
"type": "instance",
|
|
37203
|
+
"ctorName": "Array"
|
|
37204
|
+
},
|
|
37205
|
+
"RSA": {
|
|
37206
|
+
"type": "instance",
|
|
37207
|
+
"ctorName": "Array"
|
|
37208
|
+
},
|
|
37209
|
+
"oct": {
|
|
37210
|
+
"type": "instance",
|
|
37211
|
+
"ctorName": "Array"
|
|
37212
|
+
}
|
|
37213
|
+
}
|
|
37214
|
+
},
|
|
37215
|
+
"canonicalize": {
|
|
37216
|
+
"type": "function",
|
|
37217
|
+
"arity": 1
|
|
37218
|
+
},
|
|
37219
|
+
"thumbprint": {
|
|
37220
|
+
"type": "function",
|
|
37221
|
+
"arity": 2
|
|
37222
|
+
}
|
|
37223
|
+
}
|
|
37224
|
+
},
|
|
37183
37225
|
"keychain": {
|
|
37184
37226
|
"type": "object",
|
|
37185
37227
|
"members": {
|
|
@@ -406,6 +406,7 @@ var jtd = require("./lib/jtd");
|
|
|
406
406
|
var jsonSchema = require("./lib/json-schema");
|
|
407
407
|
var base32 = require("./lib/base32");
|
|
408
408
|
var uriTemplate = require("./lib/uri-template");
|
|
409
|
+
var jwk = require("./lib/jwk");
|
|
409
410
|
var standardWebhooks = require("./lib/standard-webhooks");
|
|
410
411
|
var lro = require("./lib/lro");
|
|
411
412
|
var jsonApi = require("./lib/jsonapi");
|
|
@@ -433,6 +434,7 @@ module.exports = {
|
|
|
433
434
|
jsonSchema: jsonSchema,
|
|
434
435
|
base32: base32,
|
|
435
436
|
uriTemplate: uriTemplate,
|
|
437
|
+
jwk: jwk,
|
|
436
438
|
standardWebhooks: standardWebhooks,
|
|
437
439
|
lro: lro,
|
|
438
440
|
jsonApi: jsonApi,
|
|
@@ -45,6 +45,7 @@ var nodeCrypto = require("node:crypto");
|
|
|
45
45
|
|
|
46
46
|
var C = require("./constants");
|
|
47
47
|
var asn1 = require("./asn1-der");
|
|
48
|
+
var jwk = require("./jwk");
|
|
48
49
|
var safeUrl = require("./safe-url");
|
|
49
50
|
var safeJson = require("./safe-json");
|
|
50
51
|
var validateOpts = require("./validate-opts");
|
|
@@ -114,8 +115,7 @@ function _publicJwkFromKeyObject(keyObject) {
|
|
|
114
115
|
|
|
115
116
|
function _jwkThumbprint(publicJwk) {
|
|
116
117
|
// RFC 7638 §3 — base64url(SHA-256(canonical JSON of required members)).
|
|
117
|
-
|
|
118
|
-
return _b64u(nodeCrypto.createHash("sha256").update(canon).digest());
|
|
118
|
+
return jwk.thumbprint(publicJwk);
|
|
119
119
|
}
|
|
120
120
|
|
|
121
121
|
function _signJws(privateKey, protectedHeader, payload) {
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
|
|
29
29
|
var nodeCrypto = require("node:crypto");
|
|
30
30
|
var bCrypto = require("../crypto");
|
|
31
|
+
var jwk = require("../jwk");
|
|
31
32
|
var safeJson = require("../safe-json");
|
|
32
33
|
var safeUrl = require("../safe-url");
|
|
33
34
|
var validateOpts = require("../validate-opts");
|
|
@@ -84,52 +85,21 @@ function _b64urlDecode(s) {
|
|
|
84
85
|
}
|
|
85
86
|
}
|
|
86
87
|
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
if (typeof
|
|
94
|
-
throw new AuthError("auth-dpop/bad-jwk", "jwk
|
|
95
|
-
}
|
|
96
|
-
if (jwk.kty === "EC") {
|
|
97
|
-
if (typeof jwk.crv !== "string" || typeof jwk.x !== "string" || typeof jwk.y !== "string") {
|
|
98
|
-
throw new AuthError("auth-dpop/bad-jwk", "EC jwk requires crv, x, y");
|
|
99
|
-
}
|
|
100
|
-
return JSON.stringify({ crv: jwk.crv, kty: "EC", x: jwk.x, y: jwk.y });
|
|
88
|
+
// Asymmetric key types DPoP accepts (its proof model relies on a
|
|
89
|
+
// signature, so symmetric "oct" keys are refused). AKP is the IANA key
|
|
90
|
+
// type for ML-DSA / SLH-DSA PQC public keys.
|
|
91
|
+
var DPOP_KTY = { EC: 1, OKP: 1, RSA: 1, AKP: 1 };
|
|
92
|
+
|
|
93
|
+
function thumbprint(key) {
|
|
94
|
+
if (!key || typeof key !== "object" || typeof key.kty !== "string" || key.kty.length === 0) {
|
|
95
|
+
throw new AuthError("auth-dpop/bad-jwk", "jwk must be an object with a kty");
|
|
101
96
|
}
|
|
102
|
-
if (
|
|
103
|
-
|
|
104
|
-
throw new AuthError("auth-dpop/bad-jwk", "OKP jwk requires crv, x");
|
|
105
|
-
}
|
|
106
|
-
return JSON.stringify({ crv: jwk.crv, kty: "OKP", x: jwk.x });
|
|
97
|
+
if (!DPOP_KTY[key.kty]) {
|
|
98
|
+
throw new AuthError("auth-dpop/refused-kty", "jwk.kty='" + key.kty + "' is not allowed (DPoP requires asymmetric kty)");
|
|
107
99
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
}
|
|
112
|
-
return JSON.stringify({ e: jwk.e, kty: "RSA", n: jwk.n });
|
|
113
|
-
}
|
|
114
|
-
if (jwk.kty === "AKP") {
|
|
115
|
-
// PQC asymmetric key package (draft-ietf-cose-cnsa-pqc / IANA AKP
|
|
116
|
-
// registry). Node:crypto exports ML-DSA / SLH-DSA public keys with
|
|
117
|
-
// kty=AKP, alg=<algId>, pub=<base64url public bytes>.
|
|
118
|
-
if (typeof jwk.alg !== "string" || typeof jwk.pub !== "string") {
|
|
119
|
-
throw new AuthError("auth-dpop/bad-jwk", "AKP jwk requires alg, pub");
|
|
120
|
-
}
|
|
121
|
-
return JSON.stringify({ alg: jwk.alg, kty: "AKP", pub: jwk.pub });
|
|
122
|
-
}
|
|
123
|
-
// Symmetric keys (oct) and any other kty are refused outright — DPoP's
|
|
124
|
-
// proof model requires asymmetric.
|
|
125
|
-
throw new AuthError("auth-dpop/refused-kty",
|
|
126
|
-
"jwk.kty='" + jwk.kty + "' is not allowed (DPoP requires asymmetric kty)");
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
function thumbprint(jwk) {
|
|
130
|
-
var canonical = _canonicalJwk(jwk);
|
|
131
|
-
var hash = nodeCrypto.createHash("sha256").update(canonical, "utf8").digest();
|
|
132
|
-
return _b64urlEncode(hash);
|
|
100
|
+
// The RFC 7638 thumbprint itself is computed by b.jwk.
|
|
101
|
+
try { return jwk.thumbprint(key); }
|
|
102
|
+
catch (e) { throw new AuthError("auth-dpop/bad-jwk", (e && e.message) || "invalid jwk"); }
|
|
133
103
|
}
|
|
134
104
|
|
|
135
105
|
function _sha256B64Url(input) {
|
|
@@ -36,7 +36,7 @@ var nodeCrypto = require("node:crypto");
|
|
|
36
36
|
var validateOpts = require("./validate-opts");
|
|
37
37
|
var safeJson = require("./safe-json");
|
|
38
38
|
var bCrypto = require("./crypto");
|
|
39
|
-
var
|
|
39
|
+
var jwk = require("./jwk");
|
|
40
40
|
var jwtExternal = require("./auth/jwt-external");
|
|
41
41
|
var C = require("./constants");
|
|
42
42
|
var { defineClass } = require("./framework-error");
|
|
@@ -272,23 +272,10 @@ function _trimLeadingZeros(buf) {
|
|
|
272
272
|
return buf.slice(i);
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
-
function _jwkThumbprint(
|
|
276
|
-
// RFC 7638
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
var members;
|
|
280
|
-
if (jwk.kty === "EC") {
|
|
281
|
-
members = { crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y };
|
|
282
|
-
} else if (jwk.kty === "RSA") {
|
|
283
|
-
members = { e: jwk.e, kty: jwk.kty, n: jwk.n };
|
|
284
|
-
} else {
|
|
285
|
-
throw new DbscError("dbsc/bad-jwk-kty",
|
|
286
|
-
"jwkThumbprint: unsupported kty " + jwk.kty);
|
|
287
|
-
}
|
|
288
|
-
var canonical = canonicalJson.stringify(members);
|
|
289
|
-
return bCrypto.toBase64Url(
|
|
290
|
-
nodeCrypto.createHash("sha256").update(Buffer.from(canonical, "utf8")).digest()
|
|
291
|
-
);
|
|
275
|
+
function _jwkThumbprint(key) {
|
|
276
|
+
// RFC 7638 thumbprint (base64url(SHA-256(canonical JWK))) via b.jwk.
|
|
277
|
+
try { return jwk.thumbprint(key); }
|
|
278
|
+
catch (e) { throw new DbscError("dbsc/bad-jwk-kty", "jwkThumbprint: " + ((e && e.message) || "invalid jwk")); }
|
|
292
279
|
}
|
|
293
280
|
|
|
294
281
|
module.exports = {
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.jwk
|
|
4
|
+
* @nav Identity
|
|
5
|
+
* @title JWK Thumbprint
|
|
6
|
+
*
|
|
7
|
+
* @intro
|
|
8
|
+
* Compute the <a href="https://www.rfc-editor.org/rfc/rfc7638">RFC 7638</a>
|
|
9
|
+
* thumbprint of a JSON Web Key — the canonical, hash-based identifier used
|
|
10
|
+
* to name a key (DPoP <code>jkt</code> bindings, ACME account-key
|
|
11
|
+
* thumbprints per RFC 8555, DBSC session pins, and <code>kid</code>
|
|
12
|
+
* derivation). The thumbprint is
|
|
13
|
+
* <code>base64url(SHA-256(canonical-JSON))</code>, where the canonical
|
|
14
|
+
* JSON contains only the key-type's required members, with member names
|
|
15
|
+
* in lexicographic order and no whitespace — so the same key always
|
|
16
|
+
* produces the same thumbprint regardless of how its JWK was serialized.
|
|
17
|
+
*
|
|
18
|
+
* <code>thumbprint(jwk)</code> returns the base64url digest;
|
|
19
|
+
* <code>canonicalize(jwk)</code> returns the exact JSON string that is
|
|
20
|
+
* hashed. The standard key types are supported — EC, RSA, oct, and OKP
|
|
21
|
+
* (RFC 8037 Ed25519 / X25519) — plus AKP, the IANA key type Node uses for
|
|
22
|
+
* ML-DSA / SLH-DSA post-quantum public keys. SHA-256 is the default;
|
|
23
|
+
* <code>hash: "sha384" | "sha512"</code> selects a longer digest
|
|
24
|
+
* (RFC 9278 thumbprint-with-hash).
|
|
25
|
+
*
|
|
26
|
+
* @card
|
|
27
|
+
* RFC 7638 JWK thumbprint — the canonical
|
|
28
|
+
* <code>base64url(SHA-256(canonical-JSON))</code> identifier for a JSON
|
|
29
|
+
* Web Key (EC / RSA / oct / OKP / AKP), behind DPoP <code>jkt</code>,
|
|
30
|
+
* ACME account keys, and DBSC session pins.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
var nodeCrypto = require("node:crypto");
|
|
34
|
+
var canonicalJson = require("./canonical-json");
|
|
35
|
+
var { defineClass } = require("./framework-error");
|
|
36
|
+
|
|
37
|
+
var JwkError = defineClass("JwkError", { alwaysPermanent: true });
|
|
38
|
+
|
|
39
|
+
var HASHES = { sha256: "sha256", sha384: "sha384", sha512: "sha512" };
|
|
40
|
+
|
|
41
|
+
// RFC 7638 §3.2 + JWA: the required members per key type, which (and only
|
|
42
|
+
// which) participate in the thumbprint. Listed for documentation; the
|
|
43
|
+
// canonical form is produced with lexicographic ordering regardless.
|
|
44
|
+
var REQUIRED = {
|
|
45
|
+
EC: ["crv", "kty", "x", "y"],
|
|
46
|
+
RSA: ["e", "kty", "n"],
|
|
47
|
+
oct: ["k", "kty"],
|
|
48
|
+
OKP: ["crv", "kty", "x"], // RFC 8037
|
|
49
|
+
AKP: ["alg", "kty", "pub"], // IANA AKP — ML-DSA / SLH-DSA public keys
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
function _requiredMembers(jwk) {
|
|
53
|
+
if (!jwk || typeof jwk !== "object" || Array.isArray(jwk)) {
|
|
54
|
+
throw new JwkError("jwk/bad-jwk", "jwk: must be a JWK object");
|
|
55
|
+
}
|
|
56
|
+
if (typeof jwk.kty !== "string" || jwk.kty.length === 0) {
|
|
57
|
+
throw new JwkError("jwk/bad-jwk", "jwk: 'kty' is required");
|
|
58
|
+
}
|
|
59
|
+
var names = REQUIRED[jwk.kty];
|
|
60
|
+
if (!names) throw new JwkError("jwk/unsupported-kty", "jwk: unsupported kty '" + jwk.kty + "'");
|
|
61
|
+
var out = {};
|
|
62
|
+
for (var i = 0; i < names.length; i++) {
|
|
63
|
+
var n = names[i];
|
|
64
|
+
if (typeof jwk[n] !== "string" || jwk[n].length === 0) {
|
|
65
|
+
throw new JwkError("jwk/bad-jwk", "jwk: " + jwk.kty + " key requires a string '" + n + "' member");
|
|
66
|
+
}
|
|
67
|
+
out[n] = jwk[n];
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @primitive b.jwk.canonicalize
|
|
74
|
+
* @signature b.jwk.canonicalize(jwk)
|
|
75
|
+
* @since 0.12.68
|
|
76
|
+
* @status stable
|
|
77
|
+
* @related b.jwk.thumbprint
|
|
78
|
+
*
|
|
79
|
+
* Return the RFC 7638 canonical JSON string for a JWK — only the key-type's
|
|
80
|
+
* required members, member names in lexicographic order, no whitespace.
|
|
81
|
+
* This is the exact input that <code>thumbprint</code> hashes. Throws
|
|
82
|
+
* <code>JwkError</code> for a missing <code>kty</code>, an unsupported key
|
|
83
|
+
* type, or a missing required member.
|
|
84
|
+
*
|
|
85
|
+
* @example
|
|
86
|
+
* b.jwk.canonicalize({ kty: "EC", crv: "P-256", x: "...", y: "...", use: "sig" });
|
|
87
|
+
* // → '{"crv":"P-256","kty":"EC","x":"...","y":"..."}' (use omitted)
|
|
88
|
+
*/
|
|
89
|
+
function canonicalize(jwk) {
|
|
90
|
+
return canonicalJson.stringify(_requiredMembers(jwk));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @primitive b.jwk.thumbprint
|
|
95
|
+
* @signature b.jwk.thumbprint(jwk, opts?)
|
|
96
|
+
* @since 0.12.68
|
|
97
|
+
* @status stable
|
|
98
|
+
* @related b.jwk.canonicalize
|
|
99
|
+
*
|
|
100
|
+
* Compute the RFC 7638 thumbprint of a JWK:
|
|
101
|
+
* <code>base64url(hash(canonicalJSON))</code>. Only the key-type's required
|
|
102
|
+
* members feed the hash, so optional fields (<code>kid</code>,
|
|
103
|
+
* <code>use</code>, <code>alg</code>, …) never change the result. SHA-256
|
|
104
|
+
* is the default digest; <code>hash</code> selects a longer one. Throws
|
|
105
|
+
* <code>JwkError</code> on an invalid JWK or unknown hash.
|
|
106
|
+
*
|
|
107
|
+
* @opts
|
|
108
|
+
* hash: "sha256" | "sha384" | "sha512", // default: "sha256"
|
|
109
|
+
*
|
|
110
|
+
* @example
|
|
111
|
+
* b.jwk.thumbprint({ kty: "RSA", e: "AQAB", n: "0vx7ago...DKgw" });
|
|
112
|
+
* // → "NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs"
|
|
113
|
+
*/
|
|
114
|
+
function thumbprint(jwk, opts) {
|
|
115
|
+
opts = opts || {};
|
|
116
|
+
var hash = HASHES[opts.hash || "sha256"];
|
|
117
|
+
if (!hash) throw new JwkError("jwk/bad-hash", "jwk.thumbprint: hash must be sha256, sha384, or sha512");
|
|
118
|
+
var canon = canonicalize(jwk);
|
|
119
|
+
return nodeCrypto.createHash(hash).update(canon, "utf8").digest("base64url");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
module.exports = {
|
|
123
|
+
thumbprint: thumbprint,
|
|
124
|
+
canonicalize: canonicalize,
|
|
125
|
+
REQUIRED: REQUIRED,
|
|
126
|
+
JwkError: JwkError,
|
|
127
|
+
};
|
|
@@ -6,8 +6,12 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Heuristics (all combined):
|
|
8
8
|
* - Missing Accept-Language header (real browsers always send one)
|
|
9
|
-
* - Missing Sec-Fetch-Mode header
|
|
10
|
-
*
|
|
9
|
+
* - Missing Sec-Fetch-Mode header — ADVISORY ONLY (never blocks). Tagged
|
|
10
|
+
* in mode:"tag" on secure-context HTML GETs where a modern browser
|
|
11
|
+
* would have sent it. It cannot block because the header is absent for
|
|
12
|
+
* entire browser families (Safari < 16.4) and for every plain-HTTP
|
|
13
|
+
* non-localhost origin (Umbrel, LAN / *.local proxies) — a 403 on it
|
|
14
|
+
* alone would refuse real users.
|
|
11
15
|
* - User-Agent matches known automation libraries (curl, wget, python-
|
|
12
16
|
* requests, axios, Go-http-client) — operators can add or remove
|
|
13
17
|
* entries via config
|
|
@@ -86,9 +90,13 @@ function _xffIpFor(trustProxy) {
|
|
|
86
90
|
* Cheap fingerprint-based detection of obviously-non-browser requests.
|
|
87
91
|
* Constructed via `b.middleware.botGuard(opts)`; the resulting
|
|
88
92
|
* middleware has the `(req, res, next)` shape shown above.
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
93
|
+
* Two blocking heuristics — missing `Accept-Language` and a User-Agent
|
|
94
|
+
* regex match against a default list (curl / wget / python-requests /
|
|
95
|
+
* axios / etc.) — plus one advisory signal: a missing `Sec-Fetch-Mode`
|
|
96
|
+
* on a secure-context HTML GET sets `req.suspectedBot` in `mode: "tag"`
|
|
97
|
+
* but NEVER blocks (the header is absent for Safari < 16.4 and every
|
|
98
|
+
* plain-HTTP non-localhost origin, so blocking on it refuses real
|
|
99
|
+
* users). Not
|
|
92
100
|
* a substitute for proper authentication — catches drive-by scrapers
|
|
93
101
|
* and low-effort bots. In `mode: "block"` (default) the request is
|
|
94
102
|
* refused; in `mode: "tag"` `req.suspectedBot = true` is set and the
|
|
@@ -152,6 +160,28 @@ function create(opts) {
|
|
|
152
160
|
return /^\/api\//.test(path);
|
|
153
161
|
}
|
|
154
162
|
|
|
163
|
+
// Browsers only emit Fetch Metadata (Sec-Fetch-*) in a *secure context*
|
|
164
|
+
// (W3C Secure Contexts): an HTTPS origin, or a localhost-family origin
|
|
165
|
+
// even over plain HTTP. On a plain-HTTP non-localhost origin — an Umbrel
|
|
166
|
+
// app, a LAN / *.local reverse-proxy deployment — the browser omits
|
|
167
|
+
// Sec-Fetch-* entirely, so a missing Sec-Fetch-Mode is NORMAL there and
|
|
168
|
+
// must not be read as a bot signal. The effective scheme honours
|
|
169
|
+
// X-Forwarded-Proto only under trustProxy (otherwise it is forgeable).
|
|
170
|
+
function _isSecureContext(req) {
|
|
171
|
+
if (requestHelpers.requestProtocol(req, { trustProxy: trustProxy }) === "https") return true;
|
|
172
|
+
var host = (req.headers && req.headers.host) || "";
|
|
173
|
+
host = String(host).toLowerCase().replace(/:\d+$/, ""); // strip :port
|
|
174
|
+
if (host.charAt(0) === "[") { // [::1] IPv6 literal
|
|
175
|
+
var end = host.indexOf("]");
|
|
176
|
+
host = end === -1 ? host.slice(1) : host.slice(1, end);
|
|
177
|
+
}
|
|
178
|
+
host = host.replace(/\.$/, ""); // strip trailing root-zone dot (RFC 1034 §3.1) so "localhost." matches
|
|
179
|
+
if (host === "localhost" || /\.localhost$/.test(host)) return true;
|
|
180
|
+
if (host === "::1") return true;
|
|
181
|
+
if (/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host)) return true; // allow:regex-no-length-cap — bounded dotted-quad loopback
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
|
|
155
185
|
function _checkHeuristics(req) {
|
|
156
186
|
var headers = req.headers || {};
|
|
157
187
|
var ua = headers["user-agent"] || "";
|
|
@@ -167,7 +197,14 @@ function create(opts) {
|
|
|
167
197
|
return null;
|
|
168
198
|
}
|
|
169
199
|
if (!headers["accept-language"]) return "missing-accept-language";
|
|
170
|
-
|
|
200
|
+
// Missing Sec-Fetch-Mode NEVER blocks: the header is absent for entire
|
|
201
|
+
// browser families (Safari < 16.4 omits Fetch Metadata even over HTTPS)
|
|
202
|
+
// and for every plain-HTTP non-localhost origin (Umbrel, LAN / *.local
|
|
203
|
+
// reverse proxies), so a 403 on it alone refuses real users. It survives
|
|
204
|
+
// only as an advisory TAG in mode:"tag", and even then only in a secure
|
|
205
|
+
// context where a modern browser would have sent it. Drive-by bots are
|
|
206
|
+
// still blocked by missing Accept-Language + the User-Agent deny-list.
|
|
207
|
+
if (mode === "tag" && req.method === "GET" && _isSecureContext(req) && !headers["sec-fetch-mode"]) return "missing-sec-fetch-mode";
|
|
171
208
|
return null;
|
|
172
209
|
}
|
|
173
210
|
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../scripts/release-notes-schema.json",
|
|
3
|
+
"version": "0.12.68",
|
|
4
|
+
"date": "2026-05-26",
|
|
5
|
+
"headline": "`b.jwk` — RFC 7638 JWK thumbprint",
|
|
6
|
+
"summary": "Compute the RFC 7638 thumbprint of a JSON Web Key — the canonical base64url(SHA-256(canonical-JSON)) identifier used to name a key (DPoP jkt bindings, ACME account-key thumbprints, DBSC session pins, kid derivation). b.jwk.thumbprint(jwk) returns the digest; b.jwk.canonicalize(jwk) returns the exact JSON that is hashed — only the key-type's required members, member names in lexicographic order, no whitespace, so the same key always yields the same thumbprint regardless of how its JWK was serialized. The standard key types are supported (EC, RSA, oct, OKP per RFC 8037) plus AKP, the IANA key type Node uses for ML-DSA / SLH-DSA post-quantum public keys; SHA-256 is the default, with hash: \"sha384\" | \"sha512\" for RFC 9278 thumbprint-with-hash. Verified against the RFC 7638 §3.1 worked example. b.auth.dpop, b.acme, and b.dbsc now compute their thumbprints through this primitive.",
|
|
7
|
+
"sections": [
|
|
8
|
+
{
|
|
9
|
+
"heading": "Added",
|
|
10
|
+
"items": [
|
|
11
|
+
{
|
|
12
|
+
"title": "`b.jwk.thumbprint` / `b.jwk.canonicalize`",
|
|
13
|
+
"body": "RFC 7638 JWK thumbprint. `thumbprint(jwk, opts)` returns `base64url(hash(canonical-JSON))` — only the key-type's required members feed the hash, so optional fields (`kid`, `use`, `alg`, …) never change the result. `canonicalize(jwk)` returns the canonical JSON string itself. Supports EC / RSA / oct / OKP and the AKP post-quantum key type; SHA-256 default, `hash` selects SHA-384 / SHA-512. Throws `JwkError` on an invalid key or unknown hash."
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"heading": "Changed",
|
|
19
|
+
"items": [
|
|
20
|
+
{
|
|
21
|
+
"title": "DPoP, ACME, and DBSC compose `b.jwk`",
|
|
22
|
+
"body": "`b.auth.dpop` (the `jkt` proof-key thumbprint), `b.acme` (the RFC 8555 account-key authorization), and `b.dbsc` (the session-pin thumbprint) now compute RFC 7638 thumbprints through `b.jwk` instead of carrying their own implementations. Behavior is unchanged — DPoP still refuses symmetric key types, and each surface keeps its own error codes."
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../scripts/release-notes-schema.json",
|
|
3
|
+
"version": "0.12.69",
|
|
4
|
+
"date": "2026-05-26",
|
|
5
|
+
"headline": "`b.middleware.botGuard` no longer blocks browsers that omit Sec-Fetch-Mode",
|
|
6
|
+
"summary": "b.middleware.botGuard treated a missing Sec-Fetch-Mode header as a bot signal and returned 403 Forbidden, which refused legitimate browsers on any origin where the browser does not emit Fetch Metadata: every plain-HTTP non-localhost origin (Umbrel apps, LAN and *.local reverse-proxy deployments) and Safari before 16.4 even over HTTPS. Browsers only send Sec-Fetch-* in a secure context, so its absence is normal there — not a bot. Sec-Fetch-Mode is now advisory only: it never blocks, and it sets req.suspectedBot in mode:\"tag\" only on a secure-context HTML GET where a modern browser would have sent it. Drive-by bots are still blocked by the missing-Accept-Language and User-Agent heuristics. No configuration change is needed; if you had widened skipPaths or disabled bot-guard to work around this, you can revert that.",
|
|
7
|
+
"sections": [
|
|
8
|
+
{
|
|
9
|
+
"heading": "Fixed",
|
|
10
|
+
"items": [
|
|
11
|
+
{
|
|
12
|
+
"title": "`b.middleware.botGuard` no longer 403s browsers over plain HTTP or older Safari",
|
|
13
|
+
"body": "A missing `Sec-Fetch-Mode` was a blocking heuristic, but browsers omit Fetch Metadata outside a secure context (every plain-HTTP non-localhost origin — Umbrel, LAN, `*.local` proxies) and Safari < 16.4 omits it even over HTTPS. Those legitimate browsers were refused with `403 Forbidden`. `Sec-Fetch-Mode` is now advisory: it never blocks, and only sets `req.suspectedBot` in `mode: \"tag\"` on a secure-context HTML GET. The `Accept-Language` and User-Agent heuristics (which catch the same bots) are unchanged."
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"heading": "Detectors",
|
|
19
|
+
"items": [
|
|
20
|
+
{
|
|
21
|
+
"title": "reserved-hostname trailing-dot detector recognizes regex strips",
|
|
22
|
+
"body": "The codebase-patterns gate that requires stripping the RFC 1034 trailing root-zone dot before a reserved-hostname comparison now also recognizes end-anchored regex strips (`.replace(/\\.$/, …)`), not only the `charAt` / `while`-loop forms."
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Layer 0 — b.middleware.botGuard.
|
|
4
|
+
* Focus: the Sec-Fetch-Mode heuristic must never refuse a real browser.
|
|
5
|
+
* Browsers omit Fetch Metadata (Sec-Fetch-*) on plain-HTTP non-localhost
|
|
6
|
+
* origins (Umbrel, LAN / *.local reverse proxies) AND in Safari < 16.4
|
|
7
|
+
* even over HTTPS — so a missing Sec-Fetch-Mode is advisory-only (tags in
|
|
8
|
+
* mode:"tag", never blocks). Drive-by bots are still blocked by the
|
|
9
|
+
* missing-Accept-Language and User-Agent heuristics.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
var b = require("../../index");
|
|
13
|
+
var helpers = require("../helpers");
|
|
14
|
+
var check = helpers.check;
|
|
15
|
+
|
|
16
|
+
var BROWSER_UA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36";
|
|
17
|
+
|
|
18
|
+
function _run(opts, reqInit) {
|
|
19
|
+
var mw = b.middleware.botGuard(opts || {});
|
|
20
|
+
var req = b.testing.mockReq(reqInit);
|
|
21
|
+
var res = b.testing.mockRes();
|
|
22
|
+
var nexted = false;
|
|
23
|
+
mw(req, res, function () { nexted = true; });
|
|
24
|
+
var cap = res._captured();
|
|
25
|
+
return { nexted: nexted, blocked: cap.status === 403, status: cap.status, body: cap.body, suspectedBot: req.suspectedBot };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function testSurface() {
|
|
29
|
+
check("b.middleware.botGuard is a function", typeof b.middleware.botGuard === "function");
|
|
30
|
+
check("returns a (req,res,next) middleware", b.middleware.botGuard({}).length === 3);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function testSecFetchNeverBlocks() {
|
|
34
|
+
// The reported defect: a real browser on a plain-HTTP non-localhost
|
|
35
|
+
// origin (Umbrel app / LAN proxy) sends Accept-Language but no Sec-Fetch-*.
|
|
36
|
+
var umbrel = _run({ mode: "block" }, { method: "GET", url: "/", headers: { "accept-language": "en-US", "user-agent": BROWSER_UA, host: "umbrel-dev.local:3080" } });
|
|
37
|
+
check("plain-HTTP browser (Umbrel) is NOT blocked", umbrel.nexted && !umbrel.blocked);
|
|
38
|
+
|
|
39
|
+
// Safari < 16.4 omits Sec-Fetch-* even over HTTPS — must not 403 either.
|
|
40
|
+
var safari = _run({ mode: "block" }, { method: "GET", url: "/", headers: { "accept-language": "en-US", "user-agent": "Mozilla/5.0 (Macintosh) Version/15.6 Safari/605", host: "app.example.com" }, socket: { encrypted: true } });
|
|
41
|
+
check("Safari-over-HTTPS (no Sec-Fetch) is NOT blocked", safari.nexted && !safari.blocked);
|
|
42
|
+
|
|
43
|
+
// localhost over plain HTTP, no Sec-Fetch — also fine.
|
|
44
|
+
var local = _run({ mode: "block" }, { method: "GET", url: "/", headers: { "accept-language": "en", "user-agent": BROWSER_UA, host: "localhost:3000" } });
|
|
45
|
+
check("localhost browser is NOT blocked", local.nexted && !local.blocked);
|
|
46
|
+
|
|
47
|
+
// A secure-context browser that DID send Sec-Fetch-Mode passes (sanity).
|
|
48
|
+
var modern = _run({ mode: "block" }, { method: "GET", url: "/", headers: { "accept-language": "en", "user-agent": BROWSER_UA, "sec-fetch-mode": "navigate", host: "app.example.com" }, socket: { encrypted: true } });
|
|
49
|
+
check("modern HTTPS browser passes", modern.nexted && !modern.blocked);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function testBotsStillBlocked() {
|
|
53
|
+
// Missing Accept-Language remains a hard block.
|
|
54
|
+
var noLang = _run({ mode: "block" }, { method: "GET", url: "/", headers: { "user-agent": BROWSER_UA, host: "app.example.com" }, socket: { encrypted: true } });
|
|
55
|
+
check("missing Accept-Language is blocked", noLang.blocked && noLang.status === 403 && noLang.body === "Forbidden");
|
|
56
|
+
|
|
57
|
+
// Known automation UA remains a hard block.
|
|
58
|
+
var curl = _run({ mode: "block" }, { method: "GET", url: "/", headers: { "accept-language": "en", "user-agent": "curl/8.4.0", host: "app.example.com" } });
|
|
59
|
+
check("curl UA is blocked", curl.blocked && curl.status === 403);
|
|
60
|
+
|
|
61
|
+
var py = _run({ mode: "block" }, { method: "GET", url: "/", headers: { "accept-language": "en", "user-agent": "python-requests/2.31.0", host: "x" } });
|
|
62
|
+
check("python-requests UA is blocked", py.blocked);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function testTagModeAdvisory() {
|
|
66
|
+
// mode:"tag" — secure context, no Sec-Fetch-Mode → advisory tag, never blocks.
|
|
67
|
+
var tagged = _run({ mode: "tag" }, { method: "GET", url: "/", headers: { "accept-language": "en", "user-agent": BROWSER_UA, host: "app.example.com" }, socket: { encrypted: true } });
|
|
68
|
+
check("tag mode: secure-context Sec-Fetch miss tags but continues", tagged.nexted && !tagged.blocked && tagged.suspectedBot === "missing-sec-fetch-mode");
|
|
69
|
+
|
|
70
|
+
// mode:"tag" — plain-HTTP non-localhost → NOT tagged for Sec-Fetch (insecure context).
|
|
71
|
+
var untagged = _run({ mode: "tag" }, { method: "GET", url: "/", headers: { "accept-language": "en", "user-agent": BROWSER_UA, host: "umbrel.local" } });
|
|
72
|
+
check("tag mode: plain-HTTP origin is NOT tagged for Sec-Fetch", untagged.nexted && !untagged.suspectedBot);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function testOverridesAndSkips() {
|
|
76
|
+
// allowedAgents override beats the deny-list.
|
|
77
|
+
var allowed = _run({ mode: "block", allowedAgents: [/^curl\//i] }, { method: "GET", url: "/", headers: { "accept-language": "en", "user-agent": "curl/8.4.0", host: "x" } });
|
|
78
|
+
check("allowedAgents override lets curl through", allowed.nexted && !allowed.blocked);
|
|
79
|
+
|
|
80
|
+
// API routes skip the browser-fingerprint checks (onlyForHtml default).
|
|
81
|
+
var api = _run({ mode: "block" }, { method: "GET", url: "/api/data", pathname: "/api/data", headers: { "user-agent": BROWSER_UA, host: "x" } });
|
|
82
|
+
check("API route skips fingerprint checks", api.nexted && !api.blocked);
|
|
83
|
+
|
|
84
|
+
// skipPaths bypass.
|
|
85
|
+
var skipped = _run({ mode: "block", skipPaths: ["/healthz"] }, { method: "GET", url: "/healthz", pathname: "/healthz", headers: { "user-agent": "curl/8" } });
|
|
86
|
+
check("skipPaths bypasses bot-guard", skipped.nexted && !skipped.blocked);
|
|
87
|
+
|
|
88
|
+
// RegExp patterns are required (string patterns refused at create()).
|
|
89
|
+
var threw = null;
|
|
90
|
+
try { b.middleware.botGuard({ blockedAgents: ["badbot"] }); } catch (e) { threw = e.code; }
|
|
91
|
+
check("string blockedAgents pattern is refused", threw === "bot-guard/bad-pattern");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function run() {
|
|
95
|
+
testSurface();
|
|
96
|
+
testSecFetchNeverBlocks();
|
|
97
|
+
testBotsStillBlocked();
|
|
98
|
+
testTagModeAdvisory();
|
|
99
|
+
testOverridesAndSkips();
|
|
100
|
+
}
|
|
101
|
+
module.exports = { run: run };
|
|
102
|
+
if (require.main === module) { run().then(function () { console.log("[bot-guard] OK — " + helpers.getChecks() + " checks passed"); }, function (e) { console.error("FAIL:", e && e.stack || e); process.exit(1); }); }
|
|
@@ -7963,7 +7963,10 @@ function testHostnameCompareTrailingDotNormalize() {
|
|
|
7963
7963
|
catch (_e) { continue; }
|
|
7964
7964
|
if (!reservedHostLiteralRe.test(content)) continue;
|
|
7965
7965
|
var hasStrip = /\.charAt\([^)]*length\s*-\s*1\)\s*===\s*"\."/.test(content) ||
|
|
7966
|
-
/while[\s\S]{0,80}length\s*>\s*0[\s\S]{0,80}charAt[\s\S]{0,80}===\s*"\."/.test(content)
|
|
7966
|
+
/while[\s\S]{0,80}length\s*>\s*0[\s\S]{0,80}charAt[\s\S]{0,80}===\s*"\."/.test(content) ||
|
|
7967
|
+
// end-anchored regex strip of one-or-more trailing dots:
|
|
7968
|
+
// .replace(/\.$/, ...) / .replace(/\.+$/, ...) / .replace(/\.*$/, ...)
|
|
7969
|
+
/\.replace\(\s*\/\\\.[+*]?\$\//.test(content);
|
|
7967
7970
|
if (hasStrip) continue;
|
|
7968
7971
|
var m = content.match(reservedHostLiteralRe);
|
|
7969
7972
|
var lineNum = content.slice(0, m.index).split("\n").length;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Layer 0 — b.jwk (RFC 7638 thumbprint).
|
|
4
|
+
* Oracle: the RFC 7638 §3.1 worked example (an RSA key whose SHA-256
|
|
5
|
+
* thumbprint is "NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs"), plus
|
|
6
|
+
* canonicalization + per-kty + composition (DPoP / DBSC delegate here).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
var b = require("../../index");
|
|
10
|
+
var helpers = require("../helpers");
|
|
11
|
+
var check = helpers.check;
|
|
12
|
+
function code(fn) { try { fn(); return "NO-THROW"; } catch (e) { return e.code; } }
|
|
13
|
+
|
|
14
|
+
// RFC 7638 §3.1 example key (with extra members that must be ignored).
|
|
15
|
+
var RFC_RSA = {
|
|
16
|
+
kty: "RSA",
|
|
17
|
+
n: "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw",
|
|
18
|
+
e: "AQAB", alg: "RS256", kid: "2011-04-29", use: "sig",
|
|
19
|
+
};
|
|
20
|
+
var RFC_THUMB = "NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs";
|
|
21
|
+
|
|
22
|
+
function testSurface() {
|
|
23
|
+
check("b.jwk.thumbprint is a function", typeof b.jwk.thumbprint === "function");
|
|
24
|
+
check("b.jwk.canonicalize is a function", typeof b.jwk.canonicalize === "function");
|
|
25
|
+
check("b.jwk.JwkError is a class", typeof b.jwk.JwkError === "function");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function testRfc7638() {
|
|
29
|
+
check("RFC 7638 §3.1 thumbprint matches", b.jwk.thumbprint(RFC_RSA) === RFC_THUMB);
|
|
30
|
+
check("optional members are ignored", b.jwk.thumbprint({ kty: "RSA", n: RFC_RSA.n, e: "AQAB" }) === RFC_THUMB);
|
|
31
|
+
check("canonical JSON is lexicographic + minimal", b.jwk.canonicalize(RFC_RSA) === '{"e":"AQAB","kty":"RSA","n":"' + RFC_RSA.n + '"}');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function testKtys() {
|
|
35
|
+
// EC required members are crv, kty, x, y (lexicographic).
|
|
36
|
+
check("EC canonical", b.jwk.canonicalize({ kty: "EC", crv: "P-256", x: "X", y: "Y", d: "secret" }) === '{"crv":"P-256","kty":"EC","x":"X","y":"Y"}');
|
|
37
|
+
check("OKP canonical (RFC 8037)", b.jwk.canonicalize({ kty: "OKP", crv: "Ed25519", x: "X" }) === '{"crv":"Ed25519","kty":"OKP","x":"X"}');
|
|
38
|
+
check("oct canonical", b.jwk.canonicalize({ kty: "oct", k: "GawgguFyGrWKav7AX4VKUg" }) === '{"k":"GawgguFyGrWKav7AX4VKUg","kty":"oct"}');
|
|
39
|
+
check("AKP canonical (PQC)", b.jwk.canonicalize({ kty: "AKP", alg: "ML-DSA-87", pub: "UFVC" }) === '{"alg":"ML-DSA-87","kty":"AKP","pub":"UFVC"}');
|
|
40
|
+
check("different keys → different thumbprints", b.jwk.thumbprint({ kty: "oct", k: "AAAA" }) !== b.jwk.thumbprint({ kty: "oct", k: "BBBB" }));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function testHashOption() {
|
|
44
|
+
check("sha384 differs from sha256", b.jwk.thumbprint(RFC_RSA, { hash: "sha384" }) !== RFC_THUMB);
|
|
45
|
+
check("sha512 is a string", typeof b.jwk.thumbprint(RFC_RSA, { hash: "sha512" }) === "string");
|
|
46
|
+
check("bad hash throws", code(function () { b.jwk.thumbprint(RFC_RSA, { hash: "md5" }); }) === "jwk/bad-hash");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function testErrors() {
|
|
50
|
+
check("missing kty throws", code(function () { b.jwk.thumbprint({ n: "x", e: "y" }); }) === "jwk/bad-jwk");
|
|
51
|
+
check("unsupported kty throws", code(function () { b.jwk.thumbprint({ kty: "XYZ" }); }) === "jwk/unsupported-kty");
|
|
52
|
+
check("missing required member throws", code(function () { b.jwk.thumbprint({ kty: "EC", crv: "P-256", x: "X" }); }) === "jwk/bad-jwk");
|
|
53
|
+
check("non-object throws", code(function () { b.jwk.thumbprint("nope"); }) === "jwk/bad-jwk");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function testComposition() {
|
|
57
|
+
// DPoP and DBSC compute their thumbprints through b.jwk.
|
|
58
|
+
var ec = { kty: "EC", crv: "P-256", x: "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU", y: "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0" };
|
|
59
|
+
check("dpop.thumbprint composes b.jwk", b.auth.dpop.thumbprint(ec) === b.jwk.thumbprint(ec));
|
|
60
|
+
check("dpop refuses symmetric kty", code(function () { b.auth.dpop.thumbprint({ kty: "oct", k: "x" }); }) === "auth-dpop/refused-kty");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function run() {
|
|
64
|
+
testSurface();
|
|
65
|
+
testRfc7638();
|
|
66
|
+
testKtys();
|
|
67
|
+
testHashOption();
|
|
68
|
+
testErrors();
|
|
69
|
+
testComposition();
|
|
70
|
+
}
|
|
71
|
+
module.exports = { run: run };
|
|
72
|
+
if (require.main === module) { run().then(function () { console.log("[jwk] OK — " + helpers.getChecks() + " checks passed"); }, function (e) { console.error("FAIL:", e && e.stack || e); process.exit(1); }); }
|
package/package.json
CHANGED