@blamejs/blamejs-shop 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.1.x
10
10
 
11
+ - v0.1.11 (2026-05-25) — **Sign in with Apple.** Customers can now sign in with Apple, alongside passkeys and Google. A “Continue with Apple” button appears on the account login page once the operator wires the Apple credentials; the callback turns the verified Apple identity into a shop session, adopts the guest cart, and claims prior guest orders placed under the same verified email — the same way Google sign-in does. Apple's OAuth client secret is itself an ES256 JWT, which the shop mints from the team's Sign-in-with-Apple key. Sign in with Apple is off until the credentials are set, like every other integration. **Added:** *Sign in with Apple (OIDC)* — `GET /account/login/apple` starts the flow (sealed in-flight state cookie, PKCE, nonce); Apple posts the result back to `POST /account/auth/apple/callback` (response_mode=form_post). The callback verifies the state, exchanges the code, signs the customer in on `(provider=apple, subject)`, adopts the guest cart, and — when Apple reports the email as verified — claims prior guest orders under that email. The display name is captured from Apple's first-authorization `user` field (Apple sends it only once and never in the ID token). The button appears on `/account/login` only when the credentials are configured, and is listed on `/admin/integrations`. · *customers.mintAppleClientSecret* — Mints Apple's required ES256 client-secret JWT from a Services-ID `.p8` key (team id, key id, client id). This is the one signature the protocol forces to be classical ECDSA P-256 rather than the framework's post-quantum default — an external identity provider's wire format, not an application default. The secret is minted at boot with a 150-day life (inside Apple's six-month ceiling) and re-minted on each deploy. **Changed:** *Account login offers Apple when configured* — The login page shows Continue-with-Google and Continue-with-Apple buttons independently, each gated on its own credentials. Set `APPLE_TEAM_ID`, `APPLE_KEY_ID`, `APPLE_CLIENT_ID` (your Services ID), `APPLE_PRIVATE_KEY` (the `.p8` contents), and `SHOP_ORIGIN`, and add `<SHOP_ORIGIN>/account/auth/apple/callback` as a Return URL on the Services ID. Requires an Apple Developer Program membership. See the README “Optional integrations” section.
12
+
13
+ - v0.1.10 (2026-05-25) — **Admin console — a review moderation screen.** Reviews join the admin console, completing the set of management screens. `/admin/reviews` is the moderation queue: filter by status (pending, published, rejected) and act on each submission inline — publish it, reject it with a reason, or take a published one back down. Reviews are short, so the queue shows each one in full (rating, title, body, verified-purchase flag) with its actions, no separate detail page. As with the other screens, every path content-negotiates: a bearer-token client gets the JSON API unchanged, a signed-in browser gets the HTML console. **Added:** *Review moderation screen* — `/admin/reviews` renders the review queue as inline cards (rating stars, title, body, verified-purchase flag, product, date) with status-filter chips, when opened in a signed-in browser; the same path serves the existing JSON list to a bearer-token client. Each card offers the actions that fit its status — a pending review can be published or rejected, a published one taken down, a rejected one published — with Reject taking a reason. Publish and reject post to their endpoints and redirect (PRG); a missing id is a no-op notice, never a 500. **Changed:** *Console nav gains Reviews; the reviews API content-negotiates* — The signed-in admin nav now includes Reviews — shown, like Returns, only when the reviews primitive is wired. The `/admin/reviews` list and the publish / reject endpoints serve the HTML console to a signed-in browser while continuing to serve the JSON API to a bearer-token client unchanged. A request without the bearer token is no longer answered with a 401 on the list path — a browser GET receives the sign-in form — matching the other console screens.
14
+
11
15
  - v0.1.9 (2026-05-25) — **Admin console — a returns moderation screen.** Returns join the admin console. `/admin/returns` is the RMA moderation queue: filter by status (pending, approved, received, refunded, rejected), and open a request to see its items, reason, customer notes, and linked order. From the detail page an operator works the return through its lifecycle — approve with a refund amount, mark the goods received, record the refund, or reject with a reason shown to the customer — with only the moves legal from the current status offered. An illegal move or a bad id is refused with a notice rather than an error. As with Products and Orders, every path content-negotiates: a bearer-token client gets the JSON API unchanged, a signed-in browser gets the HTML console. **Added:** *Returns moderation screen* — `/admin/returns` renders the RMA queue as a table (RMA code, order, reason, status, item count, refund amount, requested date) with status-filter chips, when opened in a signed-in browser; the same path serves the existing JSON list to a bearer-token client. Each return links to a detail page showing its line items, reason and customer notes, linked order, and refund details, with the legal next actions as forms — Approve takes a refund amount and currency, Reject takes a reason, Mark received and Refund are single-click. Each posts to its own endpoint and redirects (PRG); an unknown id renders a 404 page and an illegal or refused action redirects back with a notice, never a 500. · *returns.transitionsFrom* — `returns.transitionsFrom(status)` returns the moderation events legal from a given status as `{ on, to }`, derived from the same transition table the setters use — so the console's action buttons stay in lockstep with the return state machine. **Changed:** *Console nav gains Returns; the returns API content-negotiates* — The signed-in admin nav now includes Returns alongside Home, Dashboard, Products, Orders, Integrations, and Setup — shown only when the returns primitive is wired, so the link never points at an unmounted route. The `/admin/returns` list, detail, and approve / received / refund / reject endpoints now serve the HTML console to a signed-in browser while continuing to serve the JSON API to a bearer-token client unchanged. A request without the bearer token is no longer answered with a 401 on these paths — a browser GET receives the sign-in form and a write redirects to the console, matching the other console screens.
12
16
 
13
17
  - v0.1.8 (2026-05-25) — **Admin console — an orders screen with full lifecycle control.** Orders join the admin console. `/admin/orders` lists recent orders newest-first with one-click status filters, and each order opens to its line items, totals, shipping address, and payment reference. From the detail page an operator drives the order through its lifecycle — mark paid, start fulfilment, mark shipped, mark delivered, cancel, refund — with only the moves that are legal from the current status offered as buttons; an illegal move is refused with a notice rather than an error. Refund moves money, so the console Refund button issues a real payment-provider refund (and only appears when a provider is wired and the order has a captured intent) before the order advances to refunded. As with Products, every path content-negotiates: a bearer-token client gets the JSON API unchanged, a signed-in browser gets the HTML console. **Added:** *Orders management screen* — `/admin/orders` renders recent orders as a table (order, status, item count, total, placed date) with status-filter chips, when opened in a signed-in browser; the same path serves a new JSON list to a bearer-token client. Each order links to a detail page showing its line items, subtotal / tax / shipping / total, shipping address, and linked payment intent, with the order's legal next transitions as action buttons that post back and redirect (PRG). The Refund button posts to the payment-refund flow — it issues the provider refund and then advances the order, so a console refund never marks an order refunded without moving the money; it appears only when a payment provider is wired and the order has a captured intent (partial refunds remain on the JSON API). A bad or unknown order id renders a 404 page, never a 500; an illegal or failed action redirects back with a notice and leaves the order unchanged. · *order.listRecent + order.transitionsFrom* — `order.listRecent({ limit, status })` returns recent orders across all customers (guest orders included), newest-first, optionally filtered to one status, with line items and shipping hydrated. `order.transitionsFrom(status)` returns the lifecycle moves legal from a given status as `{ on, to, label }` — both derived from a single order-FSM edge list, so the console actions stay in lockstep with the state machine. **Changed:** *Console nav gains Orders; dashboard orders link through* — The signed-in admin nav now includes Orders alongside Home, Dashboard, Products, Integrations, and Setup. The dashboard's recent-orders list links each row to its order detail.
package/README.md CHANGED
@@ -62,7 +62,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
62
62
  | **`lib/checkout.js`** | Orchestrator. `quote()` returns priced quote; `confirm()` creates PaymentIntent + persists order in pending; `handleStripeEvent()` verifies webhook + fires the FSM transition (idempotent on re-delivery). |
63
63
  | **`lib/email.js`** | Transactional templates — order receipt, ship notification, refund confirmation. Strict `{{var}}` renderer with HTML escape + refusal of unknown / unused placeholders. Composed on `b.mail` (DKIM/SPF/DMARC/BIMI upstream). |
64
64
  | **`lib/storefront.js`** | Server-rendered HTML — utility bar + sticky header + dark hero with code-preview card + primitives marquee + featured-product callout + collections grid + framework feature band + designed catalog grid + newsletter band + four-column footer. Designed surfaces also for PDP, cart, checkout, pay, order, account login / register / dashboard, search results, `/admin` API landing, 404. Image-bearing cards on the home + search grids pull from `catalog.media`. The default theme stylesheet is external (R2-served `themes/default/assets/css/main.css`) and CSP-compliant — operators override by uploading a replacement at the same key, by passing `opts.theme_css` to renderers, or by registering a named theme through the `theme` primitive. |
65
- | **`lib/customers.js`** | Customer accounts — passkey (WebAuthn) + **Sign in with Google** (OIDC). Email is stored hash-only (`b.crypto.namespaceHash` namespace `customer-email`); the raw address never lands in D1. Passkey credentials carry CBOR-encoded public keys, transport hints, and SHA3-512-fingerprinted attestation. `signInWithOIDC` keys federated accounts on the provider `(provider, subject)` and links an existing account only on a provider-verified email (never on an unverified one). Account routes (`/account/login`, `/account/register`, `/account`, `/account/login/google`) ship as designed cards on the storefront. |
65
+ | **`lib/customers.js`** | Customer accounts — passkey (WebAuthn) + **Sign in with Google / Apple** (OIDC). Email is stored hash-only (`b.crypto.namespaceHash` namespace `customer-email`); the raw address never lands in D1. Passkey credentials carry CBOR-encoded public keys, transport hints, and SHA3-512-fingerprinted attestation. `signInWithOIDC` keys federated accounts on the provider `(provider, subject)` and links an existing account only on a provider-verified email (never on an unverified one). `mintAppleClientSecret` produces Apple's required ES256 client-secret JWT from a Services-ID `.p8` key (the one classical signature the protocol mandates; the PQC default doesn't apply to an external IdP's wire format). Account routes (`/account/login`, `/account/register`, `/account`, `/account/login/google`, `/account/login/apple`) ship as designed cards on the storefront. |
66
66
  | **`lib/reviews.js`** | Operator-moderated product ratings. Submission requires a signed-in customer **and** a verified purchase — `/products/:slug/review` confirms a completed order for the product (via `order.hasPurchasedProduct`) before accepting, re-checked on POST; reviews land `pending`. Author identity is hash-only (`b.crypto.namespaceHash`); the raw email is never stored. The PDP renders the average, per-star distribution, and published reviews with `AggregateRating` JSON-LD. `/admin/reviews` is the moderation queue (`listByStatus` → publish / reject). |
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`. |
@@ -72,7 +72,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
72
72
  | **`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. |
73
73
  | **`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. |
74
74
  | **`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. |
75
- | **`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. Also serves a **browser admin console**: sign in at `/admin` by pasting the API key (sealed `shop_admin` session cookie, SameSite=Strict, /admin-scoped), with a persistent nav across every signed-in page. A guided **setup wizard** at `/admin/setup` writes shop identity to config; **Products** (`/admin/products`) browses the catalog and creates / archives / restores; **Orders** (`/admin/orders`) lists recent orders with status filters, opens an order's items, totals, and shipping address, and drives the lifecycle (mark paid → fulfil → ship → deliver, cancel — Refund goes through the payment provider) through the order FSM; **Returns** (`/admin/returns`) is the RMA moderation queue — filter by status, open a request's items and reason, and approve (with refund amount) → mark received → refund, or reject with a reason, over the return FSM. Each console path content-negotiates: a bearer-token client still gets the JSON API unchanged, a signed-in browser gets HTML. Reachable by the cookie or the bearer token. |
75
+ | **`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. Also serves a **browser admin console**: sign in at `/admin` by pasting the API key (sealed `shop_admin` session cookie, SameSite=Strict, /admin-scoped), with a persistent nav across every signed-in page. A guided **setup wizard** at `/admin/setup` writes shop identity to config; **Products** (`/admin/products`) browses the catalog and creates / archives / restores; **Orders** (`/admin/orders`) lists recent orders with status filters, opens an order's items, totals, and shipping address, and drives the lifecycle (mark paid → fulfil → ship → deliver, cancel — Refund goes through the payment provider) through the order FSM; **Returns** (`/admin/returns`) is the RMA moderation queue — filter by status, open a request's items and reason, and approve (with refund amount) → mark received → refund, or reject with a reason, over the return FSM; **Reviews** (`/admin/reviews`) is the review moderation queue — filter by status and publish, reject (with a reason), or take down each submission inline. The Returns and Reviews links appear only when those primitives are wired. Each console path content-negotiates: a bearer-token client still gets the JSON API unchanged, a signed-in browser gets HTML. Reachable by the cookie or the bearer token. |
76
76
  | **`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. |
77
77
  | **`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. |
78
78
 
@@ -162,12 +162,10 @@ variables. A signed-in operator can see the live on/off status of each at
162
162
  | **Card checkout (Stripe)** | Checkout + the Payment Element on the pay page; refunds; subscription billing. | `STRIPE_API_KEY` (`sk_…`), `STRIPE_WEBHOOK_SECRET` (`whsec_…`), `STRIPE_PUBLISHABLE_KEY` (`pk_…`) | Point your Stripe webhook at `/api/webhooks/stripe`. Without these the shop stays browsable but checkout doesn't mount. |
163
163
  | **Apple Pay & Google Pay** | One-tap wallet buttons (Express Checkout Element) on the pay page. | Stripe (above) **+** register each web domain: `POST /admin/payment-method-domains {"domain_name":"shop.example.com"}` | Stripe performs Apple merchant validation and hosts the association file — **no Apple Developer account needed**. Apex, `www`, and each subdomain register separately; a live-mode registration also covers sandbox. |
164
164
  | **Sign in with Google** | A *Continue with Google* button on `/account/login` (OIDC). | `GOOGLE_OAUTH_CLIENT_ID`, `GOOGLE_OAUTH_CLIENT_SECRET`, `SHOP_ORIGIN` (e.g. `https://shop.example.com`) | Create a Google Cloud **OAuth 2.0 Web** client; add `<SHOP_ORIGIN>/account/auth/google/callback` as an Authorized redirect URI; consent-screen scopes `openid email profile`. The button appears only when all three are set. |
165
+ | **Sign in with Apple** | A *Continue with Apple* button on `/account/login` (OIDC). | `APPLE_TEAM_ID`, `APPLE_KEY_ID`, `APPLE_CLIENT_ID` (your **Services ID**), `APPLE_PRIVATE_KEY` (the `.p8` key contents), `SHOP_ORIGIN` | Needs an **Apple Developer Program** membership. Create a Services ID, enable Sign in with Apple, add `<SHOP_ORIGIN>/account/auth/apple/callback` as a Return URL, and create a Sign-in-with-Apple key (`.p8`). The shop mints Apple's ES256 client secret from the key at boot (re-minted each deploy, inside Apple's 6-month window). The button appears only when all five are set. |
165
166
 
166
167
  **Planned / not available:**
167
168
 
168
- - **Sign in with Apple** — the flow is wired in the framework, but it needs an
169
- Apple Developer Program membership ($99/yr) and an ES256 client-secret minted
170
- from your `.p8` key. Config-optional; shipping behind those credentials.
171
169
  - **PayPal** — a separate adapter (Orders v2 + its own webhook); planned.
172
170
  - **Shop Pay / "Sign in with Shop"** — **not available** to a self-hosted,
173
171
  non-Shopify store: the credentials only issue from a Shopify Admin and payment
package/lib/admin.js CHANGED
@@ -761,15 +761,41 @@ function mount(router, deps) {
761
761
  // `pending`. Endpoints are omitted entirely when no reviews primitive
762
762
  // is wired.
763
763
  if (reviews) {
764
- router.get("/admin/reviews", R(async function (req, res) {
765
- var url = req.url ? new URL(req.url, "http://localhost") : null;
766
- var status = (url && url.searchParams.get("status")) || "pending";
767
- var cursor = url && url.searchParams.get("cursor");
768
- var limitS = url && url.searchParams.get("limit");
769
- var limit = limitS == null ? undefined : parseInt(limitS, 10);
770
- var page = await reviews.listByStatus(status, { cursor: cursor || undefined, limit: limit });
771
- _json(res, 200, page);
772
- }));
764
+ router.get("/admin/reviews", _pageOrApi(true,
765
+ R(async function (req, res) {
766
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
767
+ var status = (url && url.searchParams.get("status")) || "pending";
768
+ var cursor = url && url.searchParams.get("cursor");
769
+ var limitS = url && url.searchParams.get("limit");
770
+ var limit = limitS == null ? undefined : parseInt(limitS, 10);
771
+ var page = await reviews.listByStatus(status, { cursor: cursor || undefined, limit: limit });
772
+ _json(res, 200, page);
773
+ }),
774
+ async function (req, res) {
775
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
776
+ var status = (url && url.searchParams.get("status")) || "pending";
777
+ var notice = null, rows = [];
778
+ // A bad ?status= raises a TypeError — fall back to pending.
779
+ try {
780
+ rows = (await reviews.listByStatus(status, { limit: 100 })).rows || [];
781
+ } catch (e) {
782
+ if (!(e instanceof TypeError)) throw e;
783
+ status = "pending"; notice = "Unknown status filter — showing pending reviews.";
784
+ rows = (await reviews.listByStatus("pending", { limit: 100 })).rows || [];
785
+ }
786
+ // A failed publish/reject redirects back with ?err=1 — surface it
787
+ // so a no-op (e.g. unknown id / missing reason) isn't mistaken for
788
+ // success, the way orders/returns do.
789
+ if (!notice && url && url.searchParams.get("err")) {
790
+ notice = "That action couldn't be completed for the review.";
791
+ }
792
+ _sendHtml(res, 200, renderAdminReviews({
793
+ shop_name: deps.shop_name, nav_available: navAvailable,
794
+ reviews: rows, status: status, notice: notice,
795
+ moved: url && url.searchParams.get("moved"),
796
+ }));
797
+ },
798
+ ));
773
799
 
774
800
  router.get("/admin/reviews/:id", R(async function (req, res) {
775
801
  var rev = await reviews.get(req.params.id);
@@ -777,30 +803,56 @@ function mount(router, deps) {
777
803
  _json(res, 200, rev);
778
804
  }));
779
805
 
780
- router.post("/admin/reviews/:id/publish", W("review.publish", async function (req, res) {
781
- var rev;
782
- try {
783
- rev = await reviews.publish(req.params.id);
784
- } catch (e) {
785
- if (e && e.code === "REVIEW_NOT_FOUND") return _problem(res, 404, "review-not-found");
786
- throw e;
787
- }
788
- _json(res, 200, rev);
789
- return rev;
790
- }));
806
+ // Publish / reject content-negotiate: bearer → JSON (unchanged);
807
+ // browser form → moderate, then PRG back to the queue (a not-found id
808
+ // is a no-op notice, never a 500).
809
+ function _reviewModerate(jsonHandler, auditEvent, opFn) {
810
+ return _pageOrApi(false, jsonHandler, async function (req, res) {
811
+ var id = req.params.id;
812
+ try { await opFn(id, req.body || {}); }
813
+ catch (e) {
814
+ if (e instanceof TypeError || (e && e.code === "REVIEW_NOT_FOUND")) {
815
+ return _redirect(res, "/admin/reviews?err=1");
816
+ }
817
+ throw e;
818
+ }
819
+ _b().audit.safeEmit({ action: AUDIT_NAMESPACE + "." + auditEvent, outcome: "success", metadata: { id: id } });
820
+ _redirect(res, "/admin/reviews?moved=1");
821
+ });
822
+ }
791
823
 
792
- router.post("/admin/reviews/:id/reject", W("review.reject", async function (req, res) {
793
- var body = req.body || {};
794
- var rev;
795
- try {
796
- rev = await reviews.reject(req.params.id, body.reason);
797
- } catch (e) {
798
- if (e && e.code === "REVIEW_NOT_FOUND") return _problem(res, 404, "review-not-found");
799
- throw e;
800
- }
801
- _json(res, 200, rev);
802
- return rev;
803
- }));
824
+ router.post("/admin/reviews/:id/publish", _reviewModerate(
825
+ W("review.publish", async function (req, res) {
826
+ var rev;
827
+ try {
828
+ rev = await reviews.publish(req.params.id);
829
+ } catch (e) {
830
+ if (e && e.code === "REVIEW_NOT_FOUND") return _problem(res, 404, "review-not-found");
831
+ throw e;
832
+ }
833
+ _json(res, 200, rev);
834
+ return rev;
835
+ }),
836
+ "review.publish",
837
+ function (id) { return reviews.publish(id); },
838
+ ));
839
+
840
+ router.post("/admin/reviews/:id/reject", _reviewModerate(
841
+ W("review.reject", async function (req, res) {
842
+ var body = req.body || {};
843
+ var rev;
844
+ try {
845
+ rev = await reviews.reject(req.params.id, body.reason);
846
+ } catch (e) {
847
+ if (e && e.code === "REVIEW_NOT_FOUND") return _problem(res, 404, "review-not-found");
848
+ throw e;
849
+ }
850
+ _json(res, 200, rev);
851
+ return rev;
852
+ }),
853
+ "review.reject",
854
+ function (id, body) { return reviews.reject(id, body.reason || undefined); },
855
+ ));
804
856
  }
805
857
 
806
858
  // ---- returns (moderation) -------------------------------------------
@@ -1443,6 +1495,12 @@ var DASHBOARD_LAYOUT =
1443
1495
  " .return-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(16rem,1fr)); gap:1.25rem; }\n" +
1444
1496
  " .return-action { border:1px solid var(--hair); border-radius:8px; padding:1rem; }\n" +
1445
1497
  " .return-action h4 { margin:0 0 .6rem; font-size:.9rem; }\n" +
1498
+ " .review-card { margin-bottom:1rem; }\n" +
1499
+ " .review-card__head { display:flex; flex-wrap:wrap; align-items:center; gap:.5rem; margin-bottom:.5rem; }\n" +
1500
+ " .review-card__body { margin:.25rem 0 .75rem; white-space:pre-wrap; }\n" +
1501
+ " .review-stars { color:#c9821f; letter-spacing:.1em; }\n" +
1502
+ " .review-reject { display:inline-flex; gap:.4rem; align-items:center; }\n" +
1503
+ " .review-reject input { padding:.45rem .6rem; border:1px solid var(--hair); border-radius:6px; font-size:.82rem; }\n" +
1446
1504
  " .nav-cards { display:grid; grid-template-columns:repeat(auto-fit,minmax(14rem,1fr)); gap:1rem; }\n" +
1447
1505
  " .nav-card { display:block; background:var(--paper); border:1px solid var(--hair); border-radius:8px; padding:1.4rem; text-decoration:none; color:var(--ink); }\n" +
1448
1506
  " .nav-card:hover { border-color:var(--accent); box-shadow:0 8px 20px -12px rgba(0,0,0,.25); }\n" +
@@ -1625,6 +1683,7 @@ var ADMIN_NAV_ITEMS = [
1625
1683
  { key: "products", href: "/admin/products", label: "Products" },
1626
1684
  { key: "orders", href: "/admin/orders", label: "Orders" },
1627
1685
  { key: "returns", href: "/admin/returns", label: "Returns", requires: "returns" },
1686
+ { key: "reviews", href: "/admin/reviews", label: "Reviews", requires: "reviews" },
1628
1687
  { key: "integrations", href: "/admin/integrations", label: "Integrations" },
1629
1688
  { key: "setup", href: "/admin/setup", label: "Setup" },
1630
1689
  ];
@@ -1728,6 +1787,8 @@ var INTEGRATIONS_CATALOG = [
1728
1787
  set: "Configure Stripe (above), then register each domain: POST /admin/payment-method-domains {\"domain_name\":\"shop.example.com\"}. No Apple Developer account needed." },
1729
1788
  { key: "google_signin", name: "Sign in with Google", enables: "A “Continue with Google” button on the account login page.",
1730
1789
  set: "GOOGLE_OAUTH_CLIENT_ID, GOOGLE_OAUTH_CLIENT_SECRET, SHOP_ORIGIN. Add <SHOP_ORIGIN>/account/auth/google/callback as a Google OAuth redirect URI." },
1790
+ { key: "apple_signin", name: "Sign in with Apple", enables: "A “Continue with Apple” button on the account login page.",
1791
+ set: "APPLE_TEAM_ID, APPLE_KEY_ID, APPLE_CLIENT_ID (your Services ID), APPLE_PRIVATE_KEY (the .p8 key contents), SHOP_ORIGIN. Add <SHOP_ORIGIN>/account/auth/apple/callback as a Return URL on the Services ID. Requires an Apple Developer Program membership." },
1731
1792
  ];
1732
1793
 
1733
1794
  function renderAdminIntegrations(opts) {
@@ -2066,6 +2127,59 @@ function renderAdminReturn(opts) {
2066
2127
  return _renderAdminShell(opts.shop_name, "Return " + (r.rma_code || r.id.slice(0, 8)), body, "returns", opts.nav_available);
2067
2128
  }
2068
2129
 
2130
+ // The review states an operator can filter the moderation queue by.
2131
+ var REVIEW_STATUS_FILTERS = ["pending", "published", "rejected"];
2132
+
2133
+ function _stars(n) {
2134
+ var r = Math.max(0, Math.min(5, parseInt(n, 10) || 0));
2135
+ return "★★★★★".slice(0, r) + "☆☆☆☆☆".slice(0, 5 - r);
2136
+ }
2137
+
2138
+ function renderAdminReviews(opts) {
2139
+ opts = opts || {};
2140
+ var list = opts.reviews || [];
2141
+ var active = opts.status || "pending";
2142
+ var moved = opts.moved ? "<div class=\"banner banner--ok\">Review updated.</div>" : "";
2143
+ var notice = opts.notice ? "<div class=\"banner banner--warn\">" + _htmlEscape(opts.notice) + "</div>" : "";
2144
+
2145
+ var chips = "<div class=\"order-filters\">" +
2146
+ REVIEW_STATUS_FILTERS.map(function (s) {
2147
+ return "<a class=\"chip" + (active === s ? " chip--on" : "") + "\" href=\"/admin/reviews?status=" + encodeURIComponent(s) + "\">" + _htmlEscape(s) + "</a>";
2148
+ }).join("") +
2149
+ "</div>";
2150
+
2151
+ // Reviews are short, so the queue moderates inline — each card shows the
2152
+ // rating, title, body, verified-purchase flag, and the actions that make
2153
+ // sense from its current status (a rejected review can be published, a
2154
+ // published one taken down, a pending one either way).
2155
+ var cards = list.map(function (rv) {
2156
+ var pub = "<form method=\"post\" action=\"/admin/reviews/" + _htmlEscape(rv.id) + "/publish\" style=\"display:inline;\">" +
2157
+ "<button class=\"btn\" type=\"submit\">Publish</button></form>";
2158
+ var rej = "<form method=\"post\" action=\"/admin/reviews/" + _htmlEscape(rv.id) + "/reject\" class=\"review-reject\">" +
2159
+ "<input type=\"text\" name=\"reason\" placeholder=\"Reason (shown in the log)\" maxlength=\"300\" required>" +
2160
+ "<button class=\"btn btn--danger\" type=\"submit\">Reject</button></form>";
2161
+ var actions = rv.status === "published" ? rej
2162
+ : rv.status === "rejected" ? pub
2163
+ : pub + " " + rej; // pending → either
2164
+ return "<div class=\"panel review-card\">" +
2165
+ "<div class=\"review-card__head\">" +
2166
+ "<span class=\"review-stars\" title=\"" + _htmlEscape(String(rv.rating)) + " of 5\">" + _stars(rv.rating) + "</span> " +
2167
+ "<strong>" + _htmlEscape(rv.title || "(no title)") + "</strong> " +
2168
+ (rv.verified_purchase ? "<span class=\"status-pill paid\">Verified</span> " : "") +
2169
+ "<span class=\"status-pill " + (rv.status === "published" ? "paid" : rv.status === "rejected" ? "cancelled" : "pending") + "\">" + _htmlEscape(rv.status) + "</span>" +
2170
+ "</div>" +
2171
+ "<p class=\"review-card__body\">" + _htmlEscape(rv.body || "") + "</p>" +
2172
+ "<p class=\"meta\">Product <code class=\"order-id\">" + _htmlEscape(String(rv.product_id).slice(0, 8)) + "</code> · " + _htmlEscape(_fmtDate(rv.created_at)) +
2173
+ (rv.rejected_reason ? " · rejected: " + _htmlEscape(rv.rejected_reason) : "") + "</p>" +
2174
+ "<div class=\"order-actions\">" + actions + "</div>" +
2175
+ "</div>";
2176
+ }).join("");
2177
+
2178
+ var queue = list.length ? cards : "<p class=\"empty\">No “" + _htmlEscape(active) + "” reviews.</p>";
2179
+ var body = "<section><h2>Reviews</h2>" + moved + notice + chips + queue + "</section>";
2180
+ return _renderAdminShell(opts.shop_name, "Reviews", body, "reviews", opts.nav_available);
2181
+ }
2182
+
2069
2183
  module.exports = {
2070
2184
  mount: mount,
2071
2185
  AUDIT_NAMESPACE: AUDIT_NAMESPACE,
@@ -2079,4 +2193,5 @@ module.exports = {
2079
2193
  renderAdminOrder: renderAdminOrder,
2080
2194
  renderAdminReturns: renderAdminReturns,
2081
2195
  renderAdminReturn: renderAdminReturn,
2196
+ renderAdminReviews: renderAdminReviews,
2082
2197
  };
package/lib/customers.js CHANGED
@@ -29,6 +29,12 @@
29
29
  * <= 4 KiB) but never tries to parse them.
30
30
  */
31
31
 
32
+ // Apple's OAuth client secret must be an ES256 (ECDSA P-256, IEEE-P1363)
33
+ // JWT; the PQC-first framework ships no classical ES256 signer and
34
+ // b.crypto.sign can't select P1363 encoding. node:crypto is the only way to
35
+ // mint it (used solely by mintAppleClientSecret below).
36
+ var nodeCrypto = require("node:crypto"); // allow:non-shop-require — Apple mandates ES256/P1363; framework has no classical ES256 signer
37
+
32
38
  var bShop;
33
39
  function _b() {
34
40
  if (!bShop) bShop = require("./index");
@@ -413,8 +419,56 @@ function create(opts) {
413
419
  return api;
414
420
  }
415
421
 
422
+ // "Sign in with Apple" requires the OAuth client secret to be an
423
+ // ES256-signed JWT — Apple's protocol mandates ECDSA P-256, the one
424
+ // signature in this codebase that is NOT the PQC default (the framework
425
+ // ships no ES256 signer by design). It's an external-protocol constraint,
426
+ // not an application default, exactly like a Stripe/PayPal webhook
427
+ // signature: the remote party dictates the algorithm. Minted from the
428
+ // team's `.p8` EC private key:
429
+ // header { alg:"ES256", kid:<key_id> }
430
+ // payload { iss:<team_id>, iat, exp, aud:"https://appleid.apple.com", sub:<client_id> }
431
+ // Returns the compact JWS to hand to `b.auth.oauth.create({ clientSecret })`.
432
+ // `ttl_seconds` defaults to 150 days (inside Apple's 180-day ceiling); the
433
+ // secret is re-minted on the next process start, so a normal deploy
434
+ // cadence refreshes it well within the window. A config-time / entry-point
435
+ // helper — THROWS on bad input so a misconfigured `.p8` fails at boot.
436
+ function mintAppleClientSecret(opts) {
437
+ opts = opts || {};
438
+ var teamId = opts.team_id;
439
+ var keyId = opts.key_id;
440
+ var clientId = opts.client_id;
441
+ var privateKeyPem = opts.private_key; // .p8 contents (PKCS#8 PEM)
442
+ if (!teamId || !keyId || !clientId || !privateKeyPem) {
443
+ throw new TypeError("mintAppleClientSecret: team_id, key_id, client_id, private_key are all required");
444
+ }
445
+ // Apple's exp/iat are unix SECONDS, so derive the default from C.TIME
446
+ // (the single duration source of truth, in ms) rather than a hand-rolled
447
+ // literal: 150 days, inside Apple's 180-day ceiling.
448
+ var ttl = opts.ttl_seconds == null ? _b().constants.TIME.days(150) / 1000 : opts.ttl_seconds;
449
+ if (typeof ttl !== "number" || !isFinite(ttl) || ttl <= 0 || ttl > 15777000) {
450
+ throw new TypeError("mintAppleClientSecret: ttl_seconds must be 1..15777000 (Apple's 6-month maximum)");
451
+ }
452
+ var key;
453
+ try { key = nodeCrypto.createPrivateKey(privateKeyPem); }
454
+ catch (e) { throw new TypeError("mintAppleClientSecret: private_key is not a valid PEM key — " + (e && e.message || e)); }
455
+ if (key.asymmetricKeyType !== "ec") {
456
+ throw new TypeError("mintAppleClientSecret: private_key must be an EC P-256 (.p8) key, got " + key.asymmetricKeyType);
457
+ }
458
+ var now = Math.floor(Date.now() / 1000);
459
+ var header = { alg: "ES256", kid: keyId };
460
+ var payload = { iss: teamId, iat: now, exp: now + ttl, aud: "https://appleid.apple.com", sub: clientId };
461
+ var b64u = _b().crypto.toBase64Url;
462
+ var signingInput = b64u(JSON.stringify(header)) + "." + b64u(JSON.stringify(payload));
463
+ // ES256 = ECDSA P-256 over SHA-256, JOSE-encoded as raw r||s (IEEE
464
+ // P1363), not the DER node emits by default — `dsaEncoding` selects it.
465
+ var sig = nodeCrypto.sign("sha256", Buffer.from(signingInput), { key: key, dsaEncoding: "ieee-p1363" });
466
+ return signingInput + "." + b64u(sig);
467
+ }
468
+
416
469
  module.exports = {
417
470
  create: create,
471
+ mintAppleClientSecret: mintAppleClientSecret,
418
472
  EMAIL_NAMESPACE: EMAIL_NAMESPACE,
419
473
  MAX_DISPLAY_NAME_LEN: MAX_DISPLAY_NAME_LEN,
420
474
  MAX_CRED_FIELD_BYTES: MAX_CRED_FIELD_BYTES,
package/lib/storefront.js CHANGED
@@ -2245,10 +2245,17 @@ var LOGIN_ERROR_MESSAGES = {
2245
2245
 
2246
2246
  function renderAccountLogin(opts) {
2247
2247
  opts = opts || {};
2248
- var oauthHtml = opts.google_enabled
2248
+ var oauthButtons = "";
2249
+ if (opts.google_enabled) {
2250
+ oauthButtons += "<a class=\"btn-secondary auth-oauth__btn\" href=\"/account/login/google\">Continue with Google</a>";
2251
+ }
2252
+ if (opts.apple_enabled) {
2253
+ oauthButtons += "<a class=\"btn-secondary auth-oauth__btn\" href=\"/account/login/apple\">Continue with Apple</a>";
2254
+ }
2255
+ var oauthHtml = oauthButtons
2249
2256
  ? "<div class=\"auth-oauth\">" +
2250
2257
  "<div class=\"auth-oauth__divider\"><span>or</span></div>" +
2251
- "<a class=\"btn-secondary auth-oauth__btn\" href=\"/account/login/google\">Continue with Google</a>" +
2258
+ oauthButtons +
2252
2259
  "</div>"
2253
2260
  : "";
2254
2261
  var errHtml = (opts.error && LOGIN_ERROR_MESSAGES[opts.error])
@@ -2937,6 +2944,7 @@ function mount(router, deps) {
2937
2944
  shop_name: shopName,
2938
2945
  cart_count: cartCount,
2939
2946
  google_enabled: !!deps.oauthGoogle,
2947
+ apple_enabled: !!deps.oauthApple,
2940
2948
  error: url && url.searchParams.get("error"),
2941
2949
  }));
2942
2950
  });
@@ -3309,6 +3317,112 @@ function mount(router, deps) {
3309
3317
  });
3310
3318
  }
3311
3319
 
3320
+ // Sign in with Apple (OIDC). Mounts when the operator wires an
3321
+ // `oauthApple` adapter (b.auth.oauth, apple preset). Two differences
3322
+ // from Google: (1) Apple uses response_mode=form_post, so the callback
3323
+ // is a POST whose `code`/`state` arrive in the form body, not the query
3324
+ // string; (2) Apple returns the user's name ONLY on the first
3325
+ // authorization, in a `user` form field (JSON) — never in the ID token
3326
+ // — so we read it from there and fall back to the (usually absent)
3327
+ // token name. Everything after the verified identity (sealed-state
3328
+ // check, sign-in, cart merge, guest-order reconciliation, auth cookie)
3329
+ // mirrors the Google path.
3330
+ if (deps.oauthApple) {
3331
+ router.get("/account/login/apple", async function (req, res) {
3332
+ try {
3333
+ var a = await deps.oauthApple.authorizationUrl({});
3334
+ // Apple returns via response_mode=form_post — a CROSS-SITE POST
3335
+ // from appleid.apple.com back to our callback. A SameSite=Lax
3336
+ // cookie is NOT sent on a cross-site POST navigation (Lax only
3337
+ // covers top-level GETs), so the sealed state would be lost and
3338
+ // every sign-in would fail. It must be SameSite=None; Secure.
3339
+ // (Google's callback is a GET, so Lax is fine there.)
3340
+ _cookieJar().writeSealed(res, OAUTH_COOKIE_NAME, JSON.stringify({
3341
+ provider: "apple", state: a.state, nonce: a.nonce, verifier: a.verifier,
3342
+ }), { expires: new Date(Date.now() + _b().constants.TIME.minutes(10)), path: "/account", sameSite: "None", secure: true });
3343
+ res.status(302);
3344
+ res.setHeader && res.setHeader("location", a.url);
3345
+ return res.end ? res.end() : res.send("");
3346
+ } catch (e) {
3347
+ if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return; }
3348
+ res.status(303); res.setHeader && res.setHeader("location", "/account/login?error=oauth");
3349
+ return res.end ? res.end() : res.send("");
3350
+ }
3351
+ });
3352
+
3353
+ router.post("/account/auth/apple/callback", async function (req, res) {
3354
+ function _toLogin(err) {
3355
+ res.status(303);
3356
+ res.setHeader && res.setHeader("location", "/account/login" + (err ? "?error=" + err : ""));
3357
+ return res.end ? res.end() : res.send("");
3358
+ }
3359
+ // form_post: code + state (+ the first-auth `user` blob) are in the
3360
+ // request body, not the query string.
3361
+ var body = req.body || {};
3362
+ var code = body.code;
3363
+ var state = body.state;
3364
+ if (!code || !state) return _toLogin("oauth");
3365
+
3366
+ var saved;
3367
+ try { var raw = _cookieJar().readSealed(req, OAUTH_COOKIE_NAME); saved = raw ? JSON.parse(raw) : null; }
3368
+ catch (_e) { saved = null; }
3369
+ _cookieJar().clear(res, OAUTH_COOKIE_NAME, { path: "/account" });
3370
+ if (!saved || saved.provider !== "apple" || saved.state !== state) return _toLogin("oauth");
3371
+
3372
+ var claims;
3373
+ try {
3374
+ var tokens = await deps.oauthApple.exchangeCode({ code: code, verifier: saved.verifier, nonce: saved.nonce });
3375
+ claims = tokens && tokens.claims;
3376
+ } catch (_e) { return _toLogin("oauth"); }
3377
+ if (!claims || !claims.sub) return _toLogin("oauth");
3378
+
3379
+ // Apple sends the display name only on first consent, as a JSON
3380
+ // `user` form field: { name: { firstName, lastName }, email }.
3381
+ var displayName = claims.name || null;
3382
+ if (typeof body.user === "string" && body.user.length) {
3383
+ try {
3384
+ var u = JSON.parse(body.user);
3385
+ if (u && u.name) {
3386
+ displayName = [u.name.firstName, u.name.lastName].filter(Boolean).join(" ") || displayName;
3387
+ }
3388
+ } catch (_e) { /* malformed user blob — fall back to the token name */ }
3389
+ }
3390
+ // Apple's email_verified arrives as a boolean OR the string "true".
3391
+ var emailVerified = claims.email_verified === true || claims.email_verified === "true";
3392
+
3393
+ var rv;
3394
+ try {
3395
+ rv = await deps.customers.signInWithOIDC({
3396
+ provider: "apple",
3397
+ subject: String(claims.sub),
3398
+ email: claims.email,
3399
+ email_verified: emailVerified,
3400
+ display_name: displayName,
3401
+ });
3402
+ } catch (e) {
3403
+ if (e && e.code === "OAUTH_EMAIL_UNVERIFIED_CONFLICT") return _toLogin("email-conflict");
3404
+ if (e instanceof TypeError) return _toLogin("oauth");
3405
+ throw e;
3406
+ }
3407
+ var sid = _readSidCookie(req);
3408
+ if (sid) {
3409
+ try {
3410
+ var anonCart = await deps.cart.bySession(sid);
3411
+ if (anonCart) await deps.cart.setCustomer(anonCart.id, rv.customer.id);
3412
+ } catch (_e) { /* best-effort merge; sign-in itself succeeds */ }
3413
+ }
3414
+ if (emailVerified && claims.email && deps.order &&
3415
+ typeof deps.order.linkGuestOrdersByEmailHash === "function") {
3416
+ try {
3417
+ await deps.order.linkGuestOrdersByEmailHash(rv.customer.id, deps.customers.hashEmail(claims.email));
3418
+ } catch (_e) { /* best-effort reconciliation; sign-in succeeds regardless */ }
3419
+ }
3420
+ _setAuthCookie(res, { customer_id: rv.customer.id, exp: Date.now() + _b().constants.TIME.days(14) });
3421
+ res.status(303); res.setHeader && res.setHeader("location", "/account");
3422
+ return res.end ? res.end() : res.send("");
3423
+ });
3424
+ }
3425
+
3312
3426
  // Wishlist — saved products scoped to the logged-in customer.
3313
3427
  // Mounts when the wishlist primitive is wired.
3314
3428
  if (deps.wishlist) {
@@ -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.50",
7
- "tag": "v0.12.50",
6
+ "version": "0.12.51",
7
+ "tag": "v0.12.51",
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.51 (2026-05-25) — **`b.network.dns.dane.matchCertificate` — DANE / TLSA certificate matching (RFC 6698 / 7671).** Pin a service's certificate through DNS instead of a public CA. matchCertificate checks a server certificate against a set of TLSA records: the selected data — the full certificate (selector 0) or its subjectPublicKeyInfo (selector 1) — is hashed per the matching type (exact / SHA-256 / SHA-512) and compared in constant time to the record's association data. For a DANE-EE (usage 3) record a match is self-authenticating — the TLSA pins the key, so no public-CA path is needed (the common SMTP-DANE case, RFC 7672); for the PKIX usages a match is reported as necessary-but-not-sufficient so the caller still runs PKIX. This is the payoff of the DNSSEC verifier: verify the TLSA RRset with b.network.dns.dnssec, then match the certificate. Verified against a live DNSSEC-signed TLSA record and the matching server certificate. **Added:** *`b.network.dns.dane.matchCertificate(opts)`* — Matches a leaf certificate (and optional `chain`) against a TLSA RRset (`{ usage, selector, matchingType, data }`). Selector 0 hashes the full certificate DER, selector 1 the subjectPublicKeyInfo; matching type 0 is an exact comparison, 1 SHA-256, 2 SHA-512 (SHA-1 and any other type are refused, not guessed). End-entity usages (PKIX-EE 1, DANE-EE 3) match the leaf; trust-anchor usages (PKIX-TA 0, DANE-TA 2) match the leaf or any supplied chain certificate. Returns `{ ok, matched, daneAuthenticated, trustAnchorMatch, pkixRequired, matchedCertIndex }` — `daneAuthenticated` is true only for a DANE-EE match (the key is pinned, no CA needed); `pkixRequired` flags the PKIX usages. Throws `dane/no-match` when nothing matches, and refuses unknown usage / selector / matching values and unparseable certificates. Verify the TLSA RRset with `b.network.dns.dnssec` first — an unauthenticated TLSA record proves nothing.
12
+
11
13
  - v0.12.50 (2026-05-25) — **`b.network.dns.dnssec.verifyChain` — validate a DNSSEC delegation chain to a pinned root anchor.** Completes local DNSSEC verification: validate a full delegation chain from the root down to a zone against a pinned trust anchor (RFC 4035 §5), instead of trusting any single resolver. For each link, the zone's DNSKEY RRset must be self-signed by one of its keys, and that key must be vouched for either by a pinned anchor (at the root) or by a DS record served + signed by the already-trusted parent — so trust flows root → TLD → zone with no gap. The IANA root KSKs (KSK-2017 tag 20326, KSK-2024 tag 38696) ship as the default anchors; override with opts.trustAnchors for a private root. verifyChain returns the leaf zone's trusted DNSKEY set, which you then hand to verifyRrset / verifyDenial for the actual answer. Composes verifyRrset + verifyDs + the key tag; verified end-to-end against a live root→org chain. **Added:** *`b.network.dns.dnssec.verifyChain(opts)`* — Walks an ordered, root-first list of `links` ({ zone, dnskeys, dnskeyRrsig, dsRdatas?, dsRrsig? }). At each link it verifies the DNSKEY RRset's self-signature (composing `verifyRrset`), then establishes trust in the signing key: at the root by matching a pinned anchor's DS digest (`verifyDs`), at every delegation by verifying the parent-served DS RRset's signature with the already-trusted parent key and confirming the signing KSK matches one of those DS records. Returns `{ ok, zone, keys, path }` with the leaf zone's trusted DNSKEY set. Refuses a root key that matches no anchor (`dnssec/chain-anchor-mismatch`), a KSK that matches no parent DS (`dnssec/chain-ds-mismatch`), and a missing parent key (`dnssec/chain-no-parent-key`). The default `DEFAULT_ROOT_ANCHORS` are the published IANA root KSK DS records; `opts.trustAnchors` overrides them for a private or test root.
12
14
 
13
15
  - v0.12.49 (2026-05-25) — **`b.network.dns.dnssec.verifyDenial` — NSEC / NSEC3 denial-of-existence.** Prove a DNS name does not exist, or has no records of a given type, from the signed NSEC (RFC 4034 §4) or NSEC3 (RFC 5155) records a server returns. This is the other half of local DNSSEC verification: verifyRrset proves a positive answer, verifyDenial proves a negative — so a resolver client can confirm an NXDOMAIN / NODATA itself instead of trusting the upstream resolver. NSEC3 proofs run the closest-encloser / next-closer / covering-range logic over iterated-SHA-1 hashes, with the iteration count capped (default 500) to bound the work an attacker can force, and an Opt-Out NXDOMAIN refused unless explicitly accepted (opt-out only proves 'no signed records', not non-existence). The companion b.network.dns.dnssec.nsec3Hash computes the RFC 5155 §5 hash directly. NSEC verifyRrset support is also enabled: per RFC 6840 §5.1 the NSEC Next Domain Name is not downcased, so its RDATA is verbatim-canonical. **Added:** *`b.network.dns.dnssec.verifyDenial(opts)`* — Proves NXDOMAIN or NODATA from already-verified NSEC / NSEC3 records (supply one of `opts.nsec3` or `opts.nsec`). Like `verifyDs`, it checks the denial RELATION — closest-encloser matching, covering ranges, and type-bitmap absence — not the record signatures, which the caller verifies with `verifyRrset` first. NSEC3 supports name-error proofs (matching closest encloser + covered next-closer + covered wildcard), NODATA (matching record with the type and CNAME absent from the bitmap), Opt-Out DS NODATA, and wildcard NODATA. The iterated-SHA-1 count is capped by `opts.maxIterations` (default 500); an NXDOMAIN proof that depends on an Opt-Out NSEC3 is refused unless `opts.allowOptOut` is set. NSEC supports covering-name NXDOMAIN (with the source-of-synthesis wildcard) and matching-name NODATA. Verified end-to-end against a live iana.org NXDOMAIN proof. · *`b.network.dns.dnssec.nsec3Hash(name, opts)`* — Computes the RFC 5155 §5 NSEC3 hash of a name — iterated SHA-1 over the canonical (lowercased, root-terminated) wire form with the zone salt. The base32hex encoding of the result is the NSEC3 owner label. SHA-1 is the only hash IANA registers for NSEC3, so this is a wire-protocol constant rather than a cryptographic default. Useful for checking an owner label or analyzing a zone's hashing parameters. **Changed:** *`verifyRrset` now accepts NSEC and NSEC3 RRsets* — NSEC (type 47) and NSEC3 (type 50) are no longer refused as uncanonicalizable: NSEC3's next-owner is a hash, and per RFC 6840 §5.1 the NSEC Next Domain Name field is not downcased for DNSSEC canonical form, so both RDATAs are verbatim-canonical. This lets a caller verify the signatures on the records that `verifyDenial` then reasons over.
@@ -120,7 +120,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
120
120
  - In-process CIDR fence (`b.middleware.networkAllowlist`)
121
121
  - `Cache-Control: no-store` on every 401 from `requireAuth` / `requireAal` / `requireStepUp` per RFC 9111 §5.2.2.5
122
122
  - **Outbound HTTP client** — HTTP/1.1 + HTTP/2 with SSRF gate (cloud-metadata IPs hard-denied; private / loopback / link-local overridable per call); scheme + userinfo + per-host destination allowlist; redirects, multipart, interceptors, progress, encrypted cookie jar (`b.httpClient`, `b.ssrfGuard`, `b.safeUrl`)
123
- - **Network configurability (`b.network`)** — env-driven NTP / NTS (RFC 8915), IPv4/IPv6 NTP, DNS with IPv6 / DoH / DoT (private-CA pinning) / cache / lookup timeout; local DNSSEC signature verification (RFC 4035 — `b.network.dns.dnssec.verifyRrset` over a canonicalised RRset against RSA / ECDSA P-256·P-384 / Ed25519 DNSKEYs, plus DS-digest + key-tag, plus `verifyDenial` for NSEC / NSEC3 (RFC 5155) NXDOMAIN / NODATA proofs with iteration caps + Opt-Out handling, plus `verifyChain` to validate a full root→TLD→zone delegation chain against the pinned IANA root anchors) so a resolver client can verify both positive and negative answers instead of trusting the upstream AD bit; outbound HTTP proxy (`HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`); runtime DPI trust-store CA additions; application-level heartbeats; TCP socket defaults
123
+ - **Network configurability (`b.network`)** — env-driven NTP / NTS (RFC 8915), IPv4/IPv6 NTP, DNS with IPv6 / DoH / DoT (private-CA pinning) / cache / lookup timeout; local DNSSEC signature verification (RFC 4035 — `b.network.dns.dnssec.verifyRrset` over a canonicalised RRset against RSA / ECDSA P-256·P-384 / Ed25519 DNSKEYs, plus DS-digest + key-tag, plus `verifyDenial` for NSEC / NSEC3 (RFC 5155) NXDOMAIN / NODATA proofs with iteration caps + Opt-Out handling, plus `verifyChain` to validate a full root→TLD→zone delegation chain against the pinned IANA root anchors) so a resolver client can verify both positive and negative answers instead of trusting the upstream AD bit; DANE / TLSA certificate matching (RFC 6698/7671 — `b.network.dns.dane.matchCertificate`) to pin a service's key through DNSSEC instead of a public CA; outbound HTTP proxy (`HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`); runtime DPI trust-store CA additions; application-level heartbeats; TCP socket defaults
124
124
  - **Error pages** — operator-rendered, no app-frame leakage (`b.errorPage`)
125
125
  ### Defensive parsers
126
126
 
@@ -351,7 +351,7 @@ This is the minimum-viable security posture for a production deployment. The fra
351
351
  - [ ] At boot, before any outbound socket opens: call `b.network.bootFromEnv({ env: process.env, audit: b.audit })` so operator-supplied NTP / DNS / proxy / DPI-trust / TCP socket settings (`BLAMEJS_NTP_*`, `BLAMEJS_DNS_*`, `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`, `BLAMEJS_EXTRA_CA_CERTS`, `BLAMEJS_SOCKET_*`) apply uniformly
352
352
  - [ ] If the deployment sits behind a deep-packet-inspection proxy with its own re-signing CA: install the CA via `b.network.tls.addCa("/path/to/corp-ca.pem", { label: "corp-mitm" })` and pass `allowDpiTrust: true` to `b.security.assertProduction` — every CA addition audits with subject + fingerprint so a forensic review can reconstruct the trust path
353
353
  - [ ] For authenticated time (HIPAA / PCI / FIPS shops): use `b.network.ntp.nts.query({ host: ntsKeServer })` (RFC 8915) instead of plain SNTP; set `BLAMEJS_NTS_REQUIRE=1` to fail closed on negotiation failure
354
- - [ ] When a DNS answer drives a trust decision (DANE / TLSA pinning, SSHFP, CAA enforcement, OPENPGPKEY lookup) and the upstream resolver isn't itself trusted: verify the answer's DNSSEC signature with `b.network.dns.dnssec.verifyRrset(...)` rather than trusting the resolver's AD bit — an on-path or compromised resolver can set AD on a forged answer, but cannot forge the RRSIG. Validate the whole delegation chain root→TLD→zone with `b.network.dns.dnssec.verifyChain(...)` (default-pinned to the IANA root KSKs, or `trustAnchors` for a private root) so trust is anchored, not borrowed from the resolver. For a negative answer that drives a fail-closed decision (an allowlist lookup, a revocation check), verify the NSEC / NSEC3 proof with `b.network.dns.dnssec.verifyDenial(...)` so a forged NXDOMAIN cannot suppress a record; keep the default Opt-Out refusal unless the zone's opt-out spans are acceptable for that decision
354
+ - [ ] When a DNS answer drives a trust decision (DANE / TLSA pinning, SSHFP, CAA enforcement, OPENPGPKEY lookup) and the upstream resolver isn't itself trusted: verify the answer's DNSSEC signature with `b.network.dns.dnssec.verifyRrset(...)` rather than trusting the resolver's AD bit — an on-path or compromised resolver can set AD on a forged answer, but cannot forge the RRSIG. Validate the whole delegation chain root→TLD→zone with `b.network.dns.dnssec.verifyChain(...)` (default-pinned to the IANA root KSKs, or `trustAnchors` for a private root) so trust is anchored, not borrowed from the resolver. For a negative answer that drives a fail-closed decision (an allowlist lookup, a revocation check), verify the NSEC / NSEC3 proof with `b.network.dns.dnssec.verifyDenial(...)` so a forged NXDOMAIN cannot suppress a record; keep the default Opt-Out refusal unless the zone's opt-out spans are acceptable for that decision. For DANE / TLSA, once the TLSA RRset is DNSSEC-verified, pin the peer certificate with `b.network.dns.dane.matchCertificate(...)` — a DANE-EE(3) match authenticates the key with no public CA, while PKIX usages are flagged as still needing PKIX
355
355
  - [ ] At boot in production: call `await b.security.assertProduction({ vault: "wrapped", dbAtRest: "encrypted", auditSigning: "wrapped", ntpStrict: true, requireEnv: ["BLAMEJS_VAULT_PASSPHRASE"], dataDir: "./data" })` to refuse to start on weak posture instead of warning
356
356
  - [ ] At boot: call `await b.configDrift.create({ dataDir, audit }).checkpoint({ allowedOrigins, csp, vaultMode, ... })` so the next boot detects + audits any silent runtime config change
357
357
  - [ ] At boot, before any listener opens: call `b.configDrift.verifyVendorIntegrity({ manifestPath: "./lib/vendor/MANIFEST.json", audit: b.audit })` so a tampered `lib/vendor/*.cjs` artifact aborts start instead of running with a swapped crypto bundle
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 1,
3
- "frameworkVersion": "0.12.50",
4
- "createdAt": "2026-05-25T13:31:06.521Z",
3
+ "frameworkVersion": "0.12.51",
4
+ "createdAt": "2026-05-25T14:53:56.538Z",
5
5
  "exports": {
6
6
  "a2a": {
7
7
  "type": "object",
@@ -41574,6 +41574,53 @@
41574
41574
  "type": "function",
41575
41575
  "arity": 0
41576
41576
  },
41577
+ "dane": {
41578
+ "type": "object",
41579
+ "members": {
41580
+ "DaneError": {
41581
+ "type": "function",
41582
+ "arity": 4
41583
+ },
41584
+ "SELECTORS": {
41585
+ "type": "object",
41586
+ "members": {
41587
+ "0": {
41588
+ "type": "primitive",
41589
+ "valueType": "string"
41590
+ },
41591
+ "1": {
41592
+ "type": "primitive",
41593
+ "valueType": "string"
41594
+ }
41595
+ }
41596
+ },
41597
+ "USAGES": {
41598
+ "type": "object",
41599
+ "members": {
41600
+ "0": {
41601
+ "type": "primitive",
41602
+ "valueType": "string"
41603
+ },
41604
+ "1": {
41605
+ "type": "primitive",
41606
+ "valueType": "string"
41607
+ },
41608
+ "2": {
41609
+ "type": "primitive",
41610
+ "valueType": "string"
41611
+ },
41612
+ "3": {
41613
+ "type": "primitive",
41614
+ "valueType": "string"
41615
+ }
41616
+ }
41617
+ },
41618
+ "matchCertificate": {
41619
+ "type": "function",
41620
+ "arity": 1
41621
+ }
41622
+ }
41623
+ },
41577
41624
  "discoverEncrypted": {
41578
41625
  "type": "function",
41579
41626
  "arity": 1
@@ -0,0 +1,159 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.network.dns.dane
4
+ * @nav Network
5
+ * @title DANE / TLSA
6
+ *
7
+ * @intro
8
+ * DNS-Based Authentication of Named Entities (RFC 6698, updated by
9
+ * RFC 7671) — match a server certificate against a TLSA record so the
10
+ * DNS, not a public CA, vouches for which key a service uses. This is
11
+ * the payoff of DNSSEC: verify the TLSA RRset with
12
+ * <code>b.network.dns.dnssec</code> first, then
13
+ * <code>matchCertificate</code> checks the certificate against it.
14
+ *
15
+ * A TLSA record carries a certificate usage (PKIX-TA 0, PKIX-EE 1,
16
+ * DANE-TA 2, DANE-EE 3 — RFC 7218 mnemonics), a selector (full
17
+ * certificate 0, or subjectPublicKeyInfo 1), and a matching type
18
+ * (exact 0, SHA-256 1, SHA-512 2). The selected certificate data is
19
+ * hashed per the matching type and compared, in constant time, to the
20
+ * record's association data. For DANE-EE(3) a match means the
21
+ * certificate IS the pinned end-entity key — no public-CA path is
22
+ * needed (the common SMTP-DANE case, RFC 7672). For the PKIX usages a
23
+ * match is necessary but the caller still performs PKIX validation.
24
+ *
25
+ * @card
26
+ * DANE / TLSA certificate matching (RFC 6698 / 7671). Pin a service's
27
+ * key through DNSSEC instead of a public CA — verify the TLSA RRset,
28
+ * then match the certificate (DANE-EE / DANE-TA / PKIX usages,
29
+ * full-cert or SPKI selector, SHA-256 / SHA-512).
30
+ */
31
+
32
+ var nodeCrypto = require("node:crypto");
33
+ var bCrypto = require("./crypto");
34
+ var validateOpts = require("./validate-opts");
35
+ var { defineClass } = require("./framework-error");
36
+
37
+ var DaneError = defineClass("DaneError", { alwaysPermanent: true });
38
+
39
+ // RFC 6698 §2.1 + RFC 7218 mnemonics.
40
+ var USAGES = { 0: "PKIX-TA", 1: "PKIX-EE", 2: "DANE-TA", 3: "DANE-EE" };
41
+ var SELECTORS = { 0: "Cert", 1: "SPKI" };
42
+ // Matching types: 0 = exact match on the selected data, 1 = SHA-256,
43
+ // 2 = SHA-512. SHA-1 is not registered for TLSA, so anything else is
44
+ // refused rather than guessed.
45
+ var MATCHING = { 0: null, 1: "sha256", 2: "sha512" };
46
+
47
+ function _bytes(x, what) {
48
+ if (Buffer.isBuffer(x)) return x;
49
+ if (x instanceof Uint8Array) return Buffer.from(x);
50
+ if (typeof x === "string") return Buffer.from(x, "hex");
51
+ throw new DaneError("dane/bad-bytes", "dane: " + what + " must be a Buffer / Uint8Array / hex string");
52
+ }
53
+
54
+ function _selectedData(x509, selector) {
55
+ if (selector === 0) return Buffer.from(x509.raw); // full certificate DER
56
+ if (selector === 1) return x509.publicKey.export({ format: "der", type: "spki" }); // subjectPublicKeyInfo DER
57
+ throw new DaneError("dane/unsupported-selector", "dane: unsupported TLSA selector " + selector + " (0 = full cert, 1 = SPKI)");
58
+ }
59
+
60
+ function _associationOf(selected, matchingType) {
61
+ if (matchingType === 0) return selected;
62
+ var hashName = MATCHING[matchingType];
63
+ if (!hashName) throw new DaneError("dane/unsupported-matching", "dane: unsupported TLSA matching type " + matchingType + " (0 = exact, 1 = SHA-256, 2 = SHA-512)");
64
+ return nodeCrypto.createHash(hashName).update(selected).digest();
65
+ }
66
+
67
+ function _parseCert(der, what) {
68
+ try { return new nodeCrypto.X509Certificate(_bytes(der, what)); }
69
+ catch (e) { throw new DaneError("dane/bad-certificate", "dane: could not parse " + what + ": " + ((e && e.message) || e)); }
70
+ }
71
+
72
+ // Validate a TLSA enum field: it must be an actual integer that is an
73
+ // OWN key of the lookup table. Rejecting non-numbers stops a string like
74
+ // "1" (which coerces on key lookup but then fails the strict-=== usage
75
+ // checks below), and the own-property test stops prototype keys such as
76
+ // "__proto__" that `in` / `[x] !== undefined` would wrongly accept.
77
+ function _enumField(v, table, code, label, i) {
78
+ if (typeof v !== "number" || !Number.isInteger(v) || !Object.prototype.hasOwnProperty.call(table, v)) {
79
+ throw new DaneError(code, "dane: tlsa[" + i + "] " + label + " must be a numeric " + Object.keys(table).join(" / ") + " (got " + JSON.stringify(v) + ")");
80
+ }
81
+ }
82
+ function _normaliseTlsa(rec, i) {
83
+ if (!rec || typeof rec !== "object") throw new DaneError("dane/bad-tlsa", "dane: tlsa[" + i + "] must be an object");
84
+ _enumField(rec.usage, USAGES, "dane/unsupported-usage", "certificate usage", i);
85
+ _enumField(rec.selector, SELECTORS, "dane/unsupported-selector", "selector", i);
86
+ _enumField(rec.matchingType, MATCHING, "dane/unsupported-matching", "matching type", i);
87
+ return { usage: rec.usage, selector: rec.selector, matchingType: rec.matchingType, data: _bytes(rec.data, "tlsa[" + i + "].data") };
88
+ }
89
+
90
+ /**
91
+ * @primitive b.network.dns.dane.matchCertificate
92
+ * @signature b.network.dns.dane.matchCertificate(opts)
93
+ * @since 0.12.51
94
+ * @status stable
95
+ * @compliance soc2
96
+ * @related b.network.dns.dnssec.verifyChain, b.network.dns.dnssec.verifyRrset
97
+ *
98
+ * Match a server certificate against a set of (DNSSEC-verified) TLSA
99
+ * records (RFC 6698 / 7671). For each record the selected data — the
100
+ * full certificate DER (selector 0) or its subjectPublicKeyInfo
101
+ * (selector 1) — is hashed per the matching type (exact / SHA-256 /
102
+ * SHA-512) and compared, constant-time, to the record's association
103
+ * data. End-entity usages (PKIX-EE 1, DANE-EE 3) are matched against the
104
+ * leaf certificate; trust-anchor usages (PKIX-TA 0, DANE-TA 2) are
105
+ * matched against the leaf and any supplied <code>chain</code>.
106
+ *
107
+ * Returns the matching record plus what the caller must still do: a
108
+ * DANE-EE match is self-sufficient (the TLSA pins the key); a DANE-TA
109
+ * match still needs chain-to-anchor verification; PKIX usages still need
110
+ * full PKIX validation. Throws <code>dane/no-match</code> if nothing
111
+ * matches. Verify the TLSA RRset with <code>b.network.dns.dnssec</code>
112
+ * before trusting the records — an unauthenticated TLSA proves nothing.
113
+ *
114
+ * @opts
115
+ * {
116
+ * tlsa: [ { usage, selector, matchingType, data: Buffer|hex } ], // the TLSA RRset
117
+ * certificate: Buffer, // leaf certificate (DER)
118
+ * chain?: Buffer[], // intermediate / CA certs (DER), for TA usages
119
+ * }
120
+ *
121
+ * @example
122
+ * var r = b.network.dns.dane.matchCertificate({ tlsa: records, certificate: leafDer });
123
+ * // → { ok: true, matched: { usage: 3, selector: 1, matchingType: 1 }, daneAuthenticated: true, pkixRequired: false }
124
+ */
125
+ function matchCertificate(opts) {
126
+ validateOpts.requireObject(opts, "dane.matchCertificate", DaneError);
127
+ validateOpts(opts, ["tlsa", "certificate", "chain"], "dane.matchCertificate");
128
+ if (!Array.isArray(opts.tlsa) || opts.tlsa.length === 0) throw new DaneError("dane/bad-arg", "dane.matchCertificate: opts.tlsa must be a non-empty array");
129
+ var records = opts.tlsa.map(_normaliseTlsa);
130
+ var leaf = _parseCert(opts.certificate, "certificate");
131
+ var chain = Array.isArray(opts.chain) ? opts.chain.map(function (c, i) { return _parseCert(c, "chain[" + i + "]"); }) : [];
132
+
133
+ for (var i = 0; i < records.length; i++) {
134
+ var rec = records[i];
135
+ var eeUsage = rec.usage === 1 || rec.usage === 3; // PKIX-EE / DANE-EE → leaf only
136
+ var certs = eeUsage ? [leaf] : [leaf].concat(chain); // TA usages may match a chain cert
137
+ for (var c = 0; c < certs.length; c++) {
138
+ var assoc = _associationOf(_selectedData(certs[c], rec.selector), rec.matchingType);
139
+ if (bCrypto.timingSafeEqual(assoc, rec.data)) {
140
+ return {
141
+ ok: true,
142
+ matched: { usage: rec.usage, usageName: USAGES[rec.usage], selector: rec.selector, matchingType: rec.matchingType },
143
+ matchedCertIndex: c, // 0 = leaf, >0 = chain[c-1]
144
+ daneAuthenticated: rec.usage === 3, // DANE-EE: TLSA pins the key, no CA path needed
145
+ trustAnchorMatch: rec.usage === 0 || rec.usage === 2,
146
+ pkixRequired: rec.usage === 0 || rec.usage === 1,
147
+ };
148
+ }
149
+ }
150
+ }
151
+ throw new DaneError("dane/no-match", "dane.matchCertificate: no TLSA record matched the certificate" + (chain.length ? " or chain" : ""));
152
+ }
153
+
154
+ module.exports = {
155
+ matchCertificate: matchCertificate,
156
+ USAGES: USAGES,
157
+ SELECTORS: SELECTORS,
158
+ DaneError: DaneError,
159
+ };
@@ -36,6 +36,7 @@ var nts = require("./network-nts");
36
36
  var networkDns = require("./network-dns");
37
37
  networkDns.resolver = require("./network-dns-resolver");
38
38
  networkDns.dnssec = require("./network-dnssec");
39
+ networkDns.dane = require("./network-dane");
39
40
  var networkProxy = require("./network-proxy");
40
41
  var networkTls = require("./network-tls");
41
42
  var heartbeat = require("./network-heartbeat");
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.12.50",
3
+ "version": "0.12.51",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
@@ -0,0 +1,18 @@
1
+ {
2
+ "$schema": "../scripts/release-notes-schema.json",
3
+ "version": "0.12.51",
4
+ "date": "2026-05-25",
5
+ "headline": "`b.network.dns.dane.matchCertificate` — DANE / TLSA certificate matching (RFC 6698 / 7671)",
6
+ "summary": "Pin a service's certificate through DNS instead of a public CA. matchCertificate checks a server certificate against a set of TLSA records: the selected data — the full certificate (selector 0) or its subjectPublicKeyInfo (selector 1) — is hashed per the matching type (exact / SHA-256 / SHA-512) and compared in constant time to the record's association data. For a DANE-EE (usage 3) record a match is self-authenticating — the TLSA pins the key, so no public-CA path is needed (the common SMTP-DANE case, RFC 7672); for the PKIX usages a match is reported as necessary-but-not-sufficient so the caller still runs PKIX. This is the payoff of the DNSSEC verifier: verify the TLSA RRset with b.network.dns.dnssec, then match the certificate. Verified against a live DNSSEC-signed TLSA record and the matching server certificate.",
7
+ "sections": [
8
+ {
9
+ "heading": "Added",
10
+ "items": [
11
+ {
12
+ "title": "`b.network.dns.dane.matchCertificate(opts)`",
13
+ "body": "Matches a leaf certificate (and optional `chain`) against a TLSA RRset (`{ usage, selector, matchingType, data }`). Selector 0 hashes the full certificate DER, selector 1 the subjectPublicKeyInfo; matching type 0 is an exact comparison, 1 SHA-256, 2 SHA-512 (SHA-1 and any other type are refused, not guessed). End-entity usages (PKIX-EE 1, DANE-EE 3) match the leaf; trust-anchor usages (PKIX-TA 0, DANE-TA 2) match the leaf or any supplied chain certificate. Returns `{ ok, matched, daneAuthenticated, trustAnchorMatch, pkixRequired, matchedCertIndex }` — `daneAuthenticated` is true only for a DANE-EE match (the key is pinned, no CA needed); `pkixRequired` flags the PKIX usages. Throws `dane/no-match` when nothing matches, and refuses unknown usage / selector / matching values and unparseable certificates. Verify the TLSA RRset with `b.network.dns.dnssec` first — an unauthenticated TLSA record proves nothing."
14
+ }
15
+ ]
16
+ }
17
+ ]
18
+ }
@@ -2267,10 +2267,13 @@ async function testNoDuplicateCodeBlocks() {
2267
2267
  mode: "family-subset",
2268
2268
  files: [
2269
2269
  "lib/cose.js:_coseKeyBytes",
2270
+ "lib/cose.js:_bstr",
2270
2271
  "lib/mdoc.js:_bytes",
2271
2272
  "lib/network-dnssec.js:_bytes",
2273
+ "lib/network-dane.js:_bytes",
2274
+ "lib/tsa.js:_bytes",
2272
2275
  ],
2273
- reason: "v0.12.48 — Buffer-coercion guard (`if (Buffer.isBuffer(x)) return x; if (x instanceof Uint8Array) return Buffer.from(x); throw <Error>`) repeats across three byte-string-consuming primitives. Each throws a MODULE-LOCAL typed error code (cose/bad-cose-key, mdoc/bad-input, dnssec/bad-bytes) naming the local argument; the duplicated three-line shape is the symptom, the cause is that JS can't throw a caller-namespaced ErrorClass without the local closure. Same documented exception as the v0.12.7 require-non-empty-string cluster — the typed-error CODE is the divergence the dup detector can't see.",
2276
+ reason: "v0.12.48 / v0.12.51 — Buffer-coercion guard (`if (Buffer.isBuffer(x)) return x; if (x instanceof Uint8Array) return Buffer.from(x); throw <Error>`) repeats across byte-string-consuming primitives. Each throws a MODULE-LOCAL typed error code (cose/bad-cose-key, mdoc/bad-input, dnssec/bad-bytes, dane/bad-bytes, tsa/bad-input) naming the local argument; network-dane additionally coerces a hex string. The duplicated three-line shape is the symptom, the cause is that JS can't throw a caller-namespaced ErrorClass without the local closure. Same documented exception as the v0.12.7 require-non-empty-string cluster — the typed-error CODE is the divergence the dup detector can't see.",
2274
2277
  },
2275
2278
  {
2276
2279
  mode: "family-subset",
@@ -6290,6 +6293,20 @@ var KNOWN_ANTIPATTERNS = [
6290
6293
  allowlist: [],
6291
6294
  reason: "DNSSEC key-tag collision false-negative — a 16-bit DNSKEY tag is not unique within an RRset (RFC 4034 Appendix B explicitly permits collisions). Picking the first key with a matching tag and verifying only against it rejects an otherwise-valid chain when a colliding non-signing key sorts earlier. RFC 4035 §5.3.1 requires trying every key whose tag and algorithm match until one validates; the framework does this via `_keysByTag` + `_verifyRrsetWithAnyKey`. The single-result `_findKeyByTag` helper must not be (re)introduced for signature key selection.",
6292
6295
  },
6296
+ {
6297
+ // Wire-enum validation against a lookup table must use an integer +
6298
+ // own-property check, never `key in TABLE` or `TABLE[key] !==
6299
+ // undefined`: `in` / member access walk the prototype chain, so an
6300
+ // attacker-supplied `"__proto__"` (or a string `"1"` that coerces on
6301
+ // lookup but then fails strict-=== comparisons) slips past. The
6302
+ // dane TLSA usage / selector / matching-type enums are validated via
6303
+ // `_enumField` (typeof number + Number.isInteger + hasOwnProperty).
6304
+ id: "dane-enum-unsafe-membership",
6305
+ primitive: "_enumField(v, TABLE, ...) — integer + Object.prototype.hasOwnProperty, never `v in TABLE` / `TABLE[v] === undefined`",
6306
+ regex: /\b(?:in\s+(?:USAGES|SELECTORS|MATCHING)\b|(?:USAGES|SELECTORS|MATCHING)\s*\[[^\]]+\]\s*===?\s*undefined)/,
6307
+ allowlist: [],
6308
+ reason: "Prototype-key / string-coercion bypass — validating an untrusted wire enum with `key in TABLE` or `TABLE[key] !== undefined` accepts inherited keys such as `__proto__` (and string keys like `\"1\"` that coerce on lookup but break later strict-=== branches). The DANE TLSA enums (certificate usage / selector / matching type) must be validated with a numeric + integer + own-property test (`_enumField`). The unsafe membership forms must not appear for these tables.",
6309
+ },
6293
6310
  {
6294
6311
  // CVE-2026-23552 — cross-realm JWT acceptance via non-CT iss
6295
6312
  // compare. `payload.iss !== expectedIssuer` (or claims.iss / token.iss)
@@ -0,0 +1,82 @@
1
+ "use strict";
2
+ /**
3
+ * Layer 0 — b.network.dns.dane (DANE / TLSA certificate matching).
4
+ * The oracle is a REAL DNSSEC-signed TLSA record for dane.sys4.de plus
5
+ * that server's actual leaf certificate (captured via `openssl s_client
6
+ * -connect dane.sys4.de:443`): the SHA-256 of the certificate's
7
+ * subjectPublicKeyInfo equals the record's association data, so a wrong
8
+ * SPKI extraction or hash would fail this match.
9
+ */
10
+
11
+ var b = require("../../index");
12
+ var helpers = require("../helpers");
13
+ var check = helpers.check;
14
+
15
+ // Real `_443._tcp.dane.sys4.de` TLSA: usage 3 (DANE-EE), selector 1
16
+ // (SPKI), matching 1 (SHA-256).
17
+ var TLSA_DANE_EE = { usage: 3, selector: 1, matchingType: 1, data: "eb74fe41c51d2876a50f0fe95ba6441119a38597a177e1ba54d68acb9a91efa3" };
18
+ // dane.sys4.de's real leaf certificate (DER).
19
+ var CERT_HEX = "308205f1308204d9a0030201020212061d670ec5c40f0709b0eb3279dfbf4a0780300d06092a864886f70d01010b05003033310b300906035504061302555331163014060355040a130d4c6574277320456e6372797074310c300a06035504031303523133301e170d3236303430373232303134395a170d3236303730363232303134385a3017311530130603550403130c64616e652e737973342e646530820222300d06092a864886f70d01010105000382020f003082020a0282020100a1e920d22e53fcd7ea5d47fa4c8ad99701e47cbeca1a1d290c5c5bfaecec805d8248174a7561c5f7ee23b68f5e4561df143eeccc1e849d6ad3b49a60a294798131bf0704364fd230803c7327d7a82b7c4e87daabc5f5474781a56003c1f0c3ad03960475b2bcb93b2a998f53531c0bdac8a9ddd0fe064b9a19a18c1b7220456c530a27c1cb39253e14157d805b17e0866a80e866d00cbf2e0a2481b72f1a9db141e557594c5e93a26fbe6f3548cff8a7d0ca82a0e0771cafd539da8e9dc13c8ceb618891994010a9a1b91e6a0e41ada62b1ad5018e654e0dad0274a6c2c4567afd7b71575ff597223071b5e4c15474ed6c3ee39763f996bbc5d99b908e89ead08058a3a65f5fa6ed962e7a891eba1fe68464db135a076db758ebd7fc3e665aed18ffadddf2f9deb76c164b56f9a91ba852608b1d562512f56e8ed30cad51d4cd0208a6e563eaf7ec94dd6ab7cca8616ca1a9c9c116bf4dcaad8cc7691a69e6e054a8f227c35c80b2dbcd1c2ec042af00bf7b98a1b531736f0d44c94fab6906be1fbdb3b7eff9d1c212d3e139a8d0aef994504ea3f4fffb8a11cf17fcdf012545779621ee4302b123fcb5ee02cc1cfb8389cb99d764fe305bc7f87bd9611bc0ab02012bd1cee8e8eeef9f77d9fa91c911283468d79a03c3ba9a8b1377ef2fdf8fc4ffb4d27b0af8d24686aa260593600e962ec7dee76c259aad77b0ed06f59acd0203010001a382021930820215300e0603551d0f0101ff0404030205a030130603551d25040c300a06082b06010505070301300c0603551d130101ff04023000301d0603551d0e0416041490d876e1363cedc1e8f840acea8a001831415abb301f0603551d23041830168014e7ab9f0f2c33a053d35e4f78c8b2840e3bd69233303306082b0601050507010104273025302306082b060105050730028617687474703a2f2f7231332e692e6c656e63722e6f72672f30170603551d110410300e820c64616e652e737973342e646530130603551d20040c300a3008060667810c010201302d0603551d1f042630243022a020a01e861c687474703a2f2f7231332e632e6c656e63722e6f72672f312e63726c3082010c060a2b06010401d6790204020481fd0481fa00f8007600d76d7d10d1a7f577c2c7e95fd700bff982c9335a65e1d0b3017317c0c8c569770000019d6a2ce2560000040300473045022100997e2a727dd924aa0ee78fad453a1822e3c4739c7bef6d8c4fead20f6ac99dd502200a744a3f55fa27f1f6d23b25d857676a34468a9c0df525c6313e6308907d7860007e00a826cbe30ac6351246533fe065f14f19d96e190813c41dd96d7900b3123c55270000019d6a2ce5920008000005000604e6ca040300473045022100b180e6e36983a8c109a115b948a5efa5f0b1f1a135cf961caafefbd69e38d64a022010e833f73d2c8f9069170c3803bb5bbab0f5ffd4e00b4ef0bc599bba59f7a9e2300d06092a864886f70d01010b050003820101006f7523afa8550ff4ee625ce6fd892b3a76584af142f53829b9976b4ffe4f0c8d3dab67a8298b17d0e3bad9f93c831832bfd60a9b7609eb1cd414e91ee094e633b972c2858b07dee4efdfccf5909d51fc2234229b783ab7fa598e9579aa3fd1089df4f3a33840ae6ed75ac27ad4645f2b87a32adc9a62dade43b97955d1395ff2b9c3ac30967da3211d04dbb6b3a470021a9218600c6c1158854f4fc4673a50f0be9e137705ab44e9a4fa0bccc247d01e7fda67475b8471075b8bb7b72229817af1d3688f55607edd7a3cd8259470c8f4fe5faa7e184aeb15582dc8a3b82666b3502d8d39a9a1130bf06ef82a072c84a432f016fb1d555bebaa4533d8b432ddba";
20
+
21
+ function cert() { return Buffer.from(CERT_HEX, "hex"); }
22
+ function code(fn) { try { fn(); return "NO-THROW"; } catch (e) { return e.code; } }
23
+
24
+ function testSurface() {
25
+ check("b.network.dns.dane.matchCertificate is a function", typeof b.network.dns.dane.matchCertificate === "function");
26
+ check("b.network.dns.dane.USAGES maps 3 to DANE-EE", b.network.dns.dane.USAGES[3] === "DANE-EE");
27
+ }
28
+
29
+ function testRealVector() {
30
+ var out = b.network.dns.dane.matchCertificate({ tlsa: [TLSA_DANE_EE], certificate: cert() });
31
+ check("matchCertificate: real dane.sys4.de DANE-EE TLSA matches the real cert (SPKI/SHA-256)", out.ok && out.matched.usage === 3);
32
+ check("matchCertificate: DANE-EE is self-authenticating (no PKIX required)", out.daneAuthenticated === true && out.pkixRequired === false);
33
+
34
+ // The same cert matched by a full-cert (selector 0) exact (matching 0)
35
+ // record — derive the association from the cert DER itself.
36
+ var full = { usage: 3, selector: 0, matchingType: 0, data: cert() };
37
+ var out2 = b.network.dns.dane.matchCertificate({ tlsa: [full], certificate: cert() });
38
+ check("matchCertificate: full-cert exact selector matches", out2.ok && out2.matched.selector === 0 && out2.matched.matchingType === 0);
39
+ }
40
+
41
+ function testRefusals() {
42
+ // A flipped association byte no longer matches.
43
+ var bad = { usage: 3, selector: 1, matchingType: 1, data: TLSA_DANE_EE.data.replace(/^eb/, "ec") };
44
+ check("matchCertificate: wrong association refused", code(function () { b.network.dns.dane.matchCertificate({ tlsa: [bad], certificate: cert() }); }) === "dane/no-match");
45
+ // Unknown usage / selector / matching are refused, not guessed.
46
+ check("matchCertificate: unsupported usage refused", code(function () { b.network.dns.dane.matchCertificate({ tlsa: [{ usage: 9, selector: 1, matchingType: 1, data: TLSA_DANE_EE.data }], certificate: cert() }); }) === "dane/unsupported-usage");
47
+ check("matchCertificate: unsupported selector refused", code(function () { b.network.dns.dane.matchCertificate({ tlsa: [{ usage: 3, selector: 9, matchingType: 1, data: TLSA_DANE_EE.data }], certificate: cert() }); }) === "dane/unsupported-selector");
48
+ check("matchCertificate: unsupported matching type (e.g. SHA-1) refused", code(function () { b.network.dns.dane.matchCertificate({ tlsa: [{ usage: 3, selector: 1, matchingType: 3, data: TLSA_DANE_EE.data }], certificate: cert() }); }) === "dane/unsupported-matching");
49
+ // Garbage certificate refused.
50
+ check("matchCertificate: bad certificate refused", code(function () { b.network.dns.dane.matchCertificate({ tlsa: [TLSA_DANE_EE], certificate: Buffer.from("not a cert") }); }) === "dane/bad-certificate");
51
+ // Empty TLSA set refused.
52
+ check("matchCertificate: empty TLSA set refused", code(function () { b.network.dns.dane.matchCertificate({ tlsa: [], certificate: cert() }); }) === "dane/bad-arg");
53
+ // String enum values are refused (they coerce on key lookup but break
54
+ // the strict-=== usage logic), not silently accepted.
55
+ check("matchCertificate: string usage refused", code(function () { b.network.dns.dane.matchCertificate({ tlsa: [{ usage: "3", selector: 1, matchingType: 1, data: TLSA_DANE_EE.data }], certificate: cert() }); }) === "dane/unsupported-usage");
56
+ // Prototype-chain keys must not slip past the enum check.
57
+ check("matchCertificate: __proto__ matchingType refused", code(function () { b.network.dns.dane.matchCertificate({ tlsa: [{ usage: 3, selector: 1, matchingType: "__proto__", data: TLSA_DANE_EE.data }], certificate: cert() }); }) === "dane/unsupported-matching");
58
+ check("matchCertificate: __proto__ selector refused", code(function () { b.network.dns.dane.matchCertificate({ tlsa: [{ usage: 3, selector: "__proto__", matchingType: 1, data: TLSA_DANE_EE.data }], certificate: cert() }); }) === "dane/unsupported-selector");
59
+ }
60
+
61
+ function testPkixUsage() {
62
+ // A PKIX-EE(1) match flags that PKIX validation is still required.
63
+ var pkixEe = { usage: 1, selector: 1, matchingType: 1, data: TLSA_DANE_EE.data };
64
+ var out = b.network.dns.dane.matchCertificate({ tlsa: [pkixEe], certificate: cert() });
65
+ check("matchCertificate: PKIX-EE match flags pkixRequired", out.ok && out.matched.usage === 1 && out.pkixRequired === true && out.daneAuthenticated === false);
66
+ }
67
+
68
+ async function run() {
69
+ testSurface();
70
+ testRealVector();
71
+ testRefusals();
72
+ testPkixUsage();
73
+ }
74
+
75
+ module.exports = { run: run };
76
+
77
+ if (require.main === module) {
78
+ run().then(
79
+ function () { console.log("[dane] OK — " + helpers.getChecks() + " checks passed"); },
80
+ function (e) { console.error("FAIL:", e && e.stack || e); process.exit(1); }
81
+ );
82
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
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": {