@blamejs/blamejs-shop 0.1.10 → 0.1.12

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.12 (2026-05-25) — **Card payments now finalize the order — the Stripe webhook is handled end to end.** A confirmed Stripe payment now advances the order from pending to paid. The container now serves the `POST /api/webhooks/stripe` route the edge worker forwards to: it re-verifies the event signature over the exact raw bytes, maps the event to the order's FSM transition, and is idempotent across Stripe's re-deliveries. Previously the edge verified the webhook but nothing consumed it on the container, so a paid PaymentIntent (card, Apple Pay, or Google Pay) left the order stuck in pending — no fulfillment, no paid status. Operators running checkout should upgrade and confirm their Stripe webhook points at `/api/webhooks/stripe`. **Added:** *Raw-body capture for payment webhooks* — A small middleware preserves the exact request bytes for the webhook path before the JSON body-parser runs, so signature verification (which is computed over the raw body) is reliable. It is scoped to the webhook routes and leaves every other request untouched. **Fixed:** *Stripe webhook completes the order* — `POST /api/webhooks/stripe` is now handled on the container: the event signature is re-verified against `STRIPE_WEBHOOK_SECRET` over the raw request body (a tampered or unsigned event is rejected with 400), then `payment_intent.succeeded` / `.canceled` / `charge.refunded` drive the order FSM (`mark_paid` / `cancel` / `refund`). Re-deliveries are idempotent — an event for an order already in the target state is acknowledged with 200 and skipped. A delivery for an unknown PaymentIntent is acknowledged without effect. This closes the gap where a confirmed payment never moved the order out of `pending`.
12
+
13
+ - 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.
14
+
11
15
  - 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.
12
16
 
13
17
  - 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.
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`. |
@@ -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
@@ -1787,6 +1787,8 @@ var INTEGRATIONS_CATALOG = [
1787
1787
  set: "Configure Stripe (above), then register each domain: POST /admin/payment-method-domains {\"domain_name\":\"shop.example.com\"}. No Apple Developer account needed." },
1788
1788
  { key: "google_signin", name: "Sign in with Google", enables: "A “Continue with Google” button on the account login page.",
1789
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." },
1790
1792
  ];
1791
1793
 
1792
1794
  function renderAdminIntegrations(opts) {
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
@@ -34,6 +34,34 @@ function _b() {
34
34
  return bShop.framework;
35
35
  }
36
36
 
37
+ // Payment-webhook signatures (Stripe's HMAC, PayPal's, …) are computed over
38
+ // the EXACT raw request bytes, but the global JSON body-parser reparses and
39
+ // discards them. This middleware buffers the raw body for the given POST
40
+ // paths into `req.rawBody` BEFORE the body-parser runs, and pre-sets
41
+ // `req.body` so the parser short-circuits (its `req.body !== undefined`
42
+ // guard) instead of re-reading an already-drained stream. Mount it ahead of
43
+ // `b.middleware.bodyParser()`; the webhook handlers read `req.rawBody`.
44
+ function webhookRawBodyCapture(paths) {
45
+ var pathSet = {};
46
+ (paths || []).forEach(function (p) { pathSet[p] = true; });
47
+ // Compose the framework's raw body-parser (req.body ← a Buffer of the
48
+ // exact request bytes) instead of hand-reading the stream — it already
49
+ // honours the router's await/next contract and the byte-limit cap.
50
+ // Scoped to the webhook paths and mounted ahead of the global JSON parser:
51
+ // payment webhooks verify the signature over the raw body, which the JSON
52
+ // parser would reparse + discard. The JSON parser then skips these paths
53
+ // via its own `req.body !== undefined` guard.
54
+ var rawParser = _b().middleware.bodyParser.raw({
55
+ limit: _b().constants.BYTES.mib(1),
56
+ contentTypes: ["application/json", "application/*"],
57
+ });
58
+ return function (req, res, next) {
59
+ var path = String(req.url || "").split("?")[0];
60
+ if ((req.method || "").toUpperCase() !== "POST" || !pathSet[path]) return next();
61
+ return rawParser(req, res, next);
62
+ };
63
+ }
64
+
37
65
  // Re-use the strict renderer from the email primitive (same shape,
38
66
  // same XSS guard, same unknown / unused refusal).
39
67
  var _render = emailModule._render;
@@ -2245,10 +2273,17 @@ var LOGIN_ERROR_MESSAGES = {
2245
2273
 
2246
2274
  function renderAccountLogin(opts) {
2247
2275
  opts = opts || {};
2248
- var oauthHtml = opts.google_enabled
2276
+ var oauthButtons = "";
2277
+ if (opts.google_enabled) {
2278
+ oauthButtons += "<a class=\"btn-secondary auth-oauth__btn\" href=\"/account/login/google\">Continue with Google</a>";
2279
+ }
2280
+ if (opts.apple_enabled) {
2281
+ oauthButtons += "<a class=\"btn-secondary auth-oauth__btn\" href=\"/account/login/apple\">Continue with Apple</a>";
2282
+ }
2283
+ var oauthHtml = oauthButtons
2249
2284
  ? "<div class=\"auth-oauth\">" +
2250
2285
  "<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>" +
2286
+ oauthButtons +
2252
2287
  "</div>"
2253
2288
  : "";
2254
2289
  var errHtml = (opts.error && LOGIN_ERROR_MESSAGES[opts.error])
@@ -2891,6 +2926,36 @@ function mount(router, deps) {
2891
2926
  theme: theme,
2892
2927
  }));
2893
2928
  });
2929
+
2930
+ // Stripe webhook — the order-completion path. A PaymentIntent succeeds
2931
+ // asynchronously (the customer may close the tab before Stripe fires),
2932
+ // so the order's pending→paid transition lands here, not on the return
2933
+ // page. The body is verified over the RAW bytes (captured upstream by
2934
+ // webhookRawBodyCapture into req.rawBody); handleStripeEvent re-verifies
2935
+ // the signature, maps the event to an FSM transition, and dedupes
2936
+ // Stripe's re-deliveries. A bad signature is 400; a handler error is
2937
+ // 500 so Stripe retries; otherwise 200.
2938
+ router.post("/api/webhooks/stripe", async function (req, res) {
2939
+ // webhookRawBodyCapture set req.body to the exact bytes as a Buffer.
2940
+ var raw = Buffer.isBuffer(req.body) ? req.body.toString("utf8")
2941
+ : (typeof req.body === "string" ? req.body : "");
2942
+ try {
2943
+ var result = await deps.checkout.handleStripeEvent({ headers: req.headers || {}, rawBody: raw });
2944
+ res.status(200);
2945
+ res.setHeader && res.setHeader("content-type", "application/json; charset=utf-8");
2946
+ var payload = JSON.stringify({ ok: true, handled: !!(result && result.handled) });
2947
+ return res.end ? res.end(payload) : res.send(payload);
2948
+ } catch (e) {
2949
+ if (e && e.code === "WEBHOOK_INVALID") {
2950
+ res.status(400);
2951
+ return res.end ? res.end("invalid signature") : res.send("");
2952
+ }
2953
+ // A real failure (e.g. an illegal FSM transition) — 500 so Stripe
2954
+ // retries the delivery rather than marking it permanently failed.
2955
+ res.status(500);
2956
+ return res.end ? res.end("handler error") : res.send("");
2957
+ }
2958
+ });
2894
2959
  }
2895
2960
 
2896
2961
  // ---- customer accounts (passkey-only) ------------------------------
@@ -2937,6 +3002,7 @@ function mount(router, deps) {
2937
3002
  shop_name: shopName,
2938
3003
  cart_count: cartCount,
2939
3004
  google_enabled: !!deps.oauthGoogle,
3005
+ apple_enabled: !!deps.oauthApple,
2940
3006
  error: url && url.searchParams.get("error"),
2941
3007
  }));
2942
3008
  });
@@ -3309,6 +3375,112 @@ function mount(router, deps) {
3309
3375
  });
3310
3376
  }
3311
3377
 
3378
+ // Sign in with Apple (OIDC). Mounts when the operator wires an
3379
+ // `oauthApple` adapter (b.auth.oauth, apple preset). Two differences
3380
+ // from Google: (1) Apple uses response_mode=form_post, so the callback
3381
+ // is a POST whose `code`/`state` arrive in the form body, not the query
3382
+ // string; (2) Apple returns the user's name ONLY on the first
3383
+ // authorization, in a `user` form field (JSON) — never in the ID token
3384
+ // — so we read it from there and fall back to the (usually absent)
3385
+ // token name. Everything after the verified identity (sealed-state
3386
+ // check, sign-in, cart merge, guest-order reconciliation, auth cookie)
3387
+ // mirrors the Google path.
3388
+ if (deps.oauthApple) {
3389
+ router.get("/account/login/apple", async function (req, res) {
3390
+ try {
3391
+ var a = await deps.oauthApple.authorizationUrl({});
3392
+ // Apple returns via response_mode=form_post — a CROSS-SITE POST
3393
+ // from appleid.apple.com back to our callback. A SameSite=Lax
3394
+ // cookie is NOT sent on a cross-site POST navigation (Lax only
3395
+ // covers top-level GETs), so the sealed state would be lost and
3396
+ // every sign-in would fail. It must be SameSite=None; Secure.
3397
+ // (Google's callback is a GET, so Lax is fine there.)
3398
+ _cookieJar().writeSealed(res, OAUTH_COOKIE_NAME, JSON.stringify({
3399
+ provider: "apple", state: a.state, nonce: a.nonce, verifier: a.verifier,
3400
+ }), { expires: new Date(Date.now() + _b().constants.TIME.minutes(10)), path: "/account", sameSite: "None", secure: true });
3401
+ res.status(302);
3402
+ res.setHeader && res.setHeader("location", a.url);
3403
+ return res.end ? res.end() : res.send("");
3404
+ } catch (e) {
3405
+ if (e && e.code === "vault/not-initialized") { _serviceUnavailable(res, "auth not configured"); return; }
3406
+ res.status(303); res.setHeader && res.setHeader("location", "/account/login?error=oauth");
3407
+ return res.end ? res.end() : res.send("");
3408
+ }
3409
+ });
3410
+
3411
+ router.post("/account/auth/apple/callback", async function (req, res) {
3412
+ function _toLogin(err) {
3413
+ res.status(303);
3414
+ res.setHeader && res.setHeader("location", "/account/login" + (err ? "?error=" + err : ""));
3415
+ return res.end ? res.end() : res.send("");
3416
+ }
3417
+ // form_post: code + state (+ the first-auth `user` blob) are in the
3418
+ // request body, not the query string.
3419
+ var body = req.body || {};
3420
+ var code = body.code;
3421
+ var state = body.state;
3422
+ if (!code || !state) return _toLogin("oauth");
3423
+
3424
+ var saved;
3425
+ try { var raw = _cookieJar().readSealed(req, OAUTH_COOKIE_NAME); saved = raw ? JSON.parse(raw) : null; }
3426
+ catch (_e) { saved = null; }
3427
+ _cookieJar().clear(res, OAUTH_COOKIE_NAME, { path: "/account" });
3428
+ if (!saved || saved.provider !== "apple" || saved.state !== state) return _toLogin("oauth");
3429
+
3430
+ var claims;
3431
+ try {
3432
+ var tokens = await deps.oauthApple.exchangeCode({ code: code, verifier: saved.verifier, nonce: saved.nonce });
3433
+ claims = tokens && tokens.claims;
3434
+ } catch (_e) { return _toLogin("oauth"); }
3435
+ if (!claims || !claims.sub) return _toLogin("oauth");
3436
+
3437
+ // Apple sends the display name only on first consent, as a JSON
3438
+ // `user` form field: { name: { firstName, lastName }, email }.
3439
+ var displayName = claims.name || null;
3440
+ if (typeof body.user === "string" && body.user.length) {
3441
+ try {
3442
+ var u = JSON.parse(body.user);
3443
+ if (u && u.name) {
3444
+ displayName = [u.name.firstName, u.name.lastName].filter(Boolean).join(" ") || displayName;
3445
+ }
3446
+ } catch (_e) { /* malformed user blob — fall back to the token name */ }
3447
+ }
3448
+ // Apple's email_verified arrives as a boolean OR the string "true".
3449
+ var emailVerified = claims.email_verified === true || claims.email_verified === "true";
3450
+
3451
+ var rv;
3452
+ try {
3453
+ rv = await deps.customers.signInWithOIDC({
3454
+ provider: "apple",
3455
+ subject: String(claims.sub),
3456
+ email: claims.email,
3457
+ email_verified: emailVerified,
3458
+ display_name: displayName,
3459
+ });
3460
+ } catch (e) {
3461
+ if (e && e.code === "OAUTH_EMAIL_UNVERIFIED_CONFLICT") return _toLogin("email-conflict");
3462
+ if (e instanceof TypeError) return _toLogin("oauth");
3463
+ throw e;
3464
+ }
3465
+ var sid = _readSidCookie(req);
3466
+ if (sid) {
3467
+ try {
3468
+ var anonCart = await deps.cart.bySession(sid);
3469
+ if (anonCart) await deps.cart.setCustomer(anonCart.id, rv.customer.id);
3470
+ } catch (_e) { /* best-effort merge; sign-in itself succeeds */ }
3471
+ }
3472
+ if (emailVerified && claims.email && deps.order &&
3473
+ typeof deps.order.linkGuestOrdersByEmailHash === "function") {
3474
+ try {
3475
+ await deps.order.linkGuestOrdersByEmailHash(rv.customer.id, deps.customers.hashEmail(claims.email));
3476
+ } catch (_e) { /* best-effort reconciliation; sign-in succeeds regardless */ }
3477
+ }
3478
+ _setAuthCookie(res, { customer_id: rv.customer.id, exp: Date.now() + _b().constants.TIME.days(14) });
3479
+ res.status(303); res.setHeader && res.setHeader("location", "/account");
3480
+ return res.end ? res.end() : res.send("");
3481
+ });
3482
+ }
3483
+
3312
3484
  // Wishlist — saved products scoped to the logged-in customer.
3313
3485
  // Mounts when the wishlist primitive is wired.
3314
3486
  if (deps.wishlist) {
@@ -4066,6 +4238,7 @@ function mount(router, deps) {
4066
4238
 
4067
4239
  module.exports = {
4068
4240
  mount: mount,
4241
+ webhookRawBodyCapture: webhookRawBodyCapture,
4069
4242
  renderHome: renderHome,
4070
4243
  renderSearch: renderSearch,
4071
4244
  renderProduct: renderProduct,
@@ -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.51",
7
- "tag": "v0.12.51",
6
+ "version": "0.12.52",
7
+ "tag": "v0.12.52",
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.52 (2026-05-25) — **`b.privacyPass` — Privacy Pass origin-side token verification (RFC 9577 / 9578).** Anonymous, publicly verifiable authorization: an origin issues a WWW-Authenticate: PrivateToken challenge and verifies a presented token cryptographically, without learning who the client is and without a callback to the issuer. b.privacyPass implements the publicly verifiable token type 0x0002 (Blind RSA, 2048-bit): the token's authenticator is an RSA Blind Signature (RFC 9474) checked as RSASSA-PSS (SHA-384, 48-byte salt) over token_input = token_type ‖ nonce ‖ challenge_digest ‖ token_key_id, using only the issuer's public key. The token is bound to that key (token_key_id) and, optionally, to the challenge it answers, so a token minted for another origin is refused. Blind RSA is the algorithm Privacy Pass defines on the wire — like the DNSSEC / DANE verifiers it validates an external protocol's signatures rather than introducing classical crypto as a framework default. Verified against the RFC 9578 §8.2 test vector. **Added:** *`b.privacyPass.verifyToken(opts)` / `parseToken` / `buildChallenge`* — `buildChallenge` builds a TokenChallenge (RFC 9577 §2.1) and the matching `WWW-Authenticate: PrivateToken challenge=…, token-key=…` header an origin returns to request a token, scoped to an issuer (and optionally an origin and a 32-byte redemption context). `parseToken` splits a token into its fields (type / nonce / challenge_digest / token_key_id / authenticator). `verifyToken` verifies a type 0x0002 (Blind RSA) token: it confirms the token's `token_key_id` is the SHA-256 of the supplied issuer public key, optionally that its `challenge_digest` matches `opts.challenge`, and that the authenticator is a valid RSASSA-PSS signature over the token input. Refuses unknown / privately verifiable token types (the VOPRF type 0x0001 needs the issuer secret and is an issuer-side operation), key-id and challenge mismatches, and tampered authenticators. Marked experimental while the issuance protocols see deployment.
12
+
11
13
  - 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
14
 
13
15
  - 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.
@@ -89,6 +89,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
89
89
  - **Financial / Open Banking** — FAPI 2.0 Final composite posture (PAR + PKCE-S256 + DPoP-or-mTLS + RFC 9207); runtime enforcement helpers `b.fapi2.assertCallback` (refuses missing iss + bare-param under message-signing) and `b.fapi2.assertAuthzRequest` (refuses non-JAR); CFPB §1033 / FDX 6.0 consumer-financial-data-sharing wrapper (`b.fdx`)
90
90
  - **Data-subject coordination** — cross-table export / rectify / erase / restrict / objection (`b.subject`, `b.subject.eraseHard`); subject-level legal-hold registry consulted by erase + retention paths (FRCP Rule 26/37(e), GDPR Art 17(3)(e), SEC Rule 17a-4, HIPAA §164.530(j)(2)) (`b.legalHold`)
91
91
  - **Account safety** — adaptive bot-challenge staircase (`b.authBotChallenge`); session-to-device-posture binding with fail-closed verify (`b.sessionDeviceBinding`)
92
+ - **Anonymous authorization** — Privacy Pass origin side (RFC 9577/9578 — `b.privacyPass`): issue a `WWW-Authenticate: PrivateToken` challenge and verify a presented Blind-RSA (type 0x0002) token against the issuer public key, with no issuer callback and no client identity
92
93
  ### Crypto
93
94
 
94
95
  - **At-rest envelope** — envelope-versioned PQC (ML-KEM-1024 + P-384 hybrid, XChaCha20-Poly1305, SHAKE256); vault sealing (`b.crypto`, `b.vault`)
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 1,
3
- "frameworkVersion": "0.12.51",
4
- "createdAt": "2026-05-25T14:53:56.538Z",
3
+ "frameworkVersion": "0.12.52",
4
+ "createdAt": "2026-05-25T16:02:59.536Z",
5
5
  "exports": {
6
6
  "a2a": {
7
7
  "type": "object",
@@ -44979,6 +44979,31 @@
44979
44979
  }
44980
44980
  }
44981
44981
  },
44982
+ "privacyPass": {
44983
+ "type": "object",
44984
+ "members": {
44985
+ "PrivacyPassError": {
44986
+ "type": "function",
44987
+ "arity": 4
44988
+ },
44989
+ "TOKEN_TYPE_BLIND_RSA": {
44990
+ "type": "primitive",
44991
+ "valueType": "number"
44992
+ },
44993
+ "buildChallenge": {
44994
+ "type": "function",
44995
+ "arity": 1
44996
+ },
44997
+ "parseToken": {
44998
+ "type": "function",
44999
+ "arity": 1
45000
+ },
45001
+ "verifyToken": {
45002
+ "type": "function",
45003
+ "arity": 1
45004
+ }
45005
+ }
45006
+ },
44982
45007
  "problemDetails": {
44983
45008
  "type": "object",
44984
45009
  "members": {
@@ -394,6 +394,7 @@ var webPush = require("./lib/web-push-vapid");
394
394
  var fedcm = require("./lib/fedcm");
395
395
  var dbsc = require("./lib/dbsc");
396
396
  var importmapIntegrity = require("./lib/importmap-integrity");
397
+ var privacyPass = require("./lib/privacy-pass");
397
398
  var standardWebhooks = require("./lib/standard-webhooks");
398
399
  var lro = require("./lib/lro");
399
400
  var jsonApi = require("./lib/jsonapi");
@@ -409,6 +410,7 @@ module.exports = {
409
410
  fedcm: fedcm,
410
411
  dbsc: dbsc,
411
412
  importmapIntegrity: importmapIntegrity,
413
+ privacyPass: privacyPass,
412
414
  standardWebhooks: standardWebhooks,
413
415
  lro: lro,
414
416
  jsonApi: jsonApi,
@@ -0,0 +1,267 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.privacyPass
4
+ * @nav Identity
5
+ * @title Privacy Pass
6
+ *
7
+ * @intro
8
+ * Origin / relying-party side of Privacy Pass (RFC 9577 HTTP
9
+ * authentication scheme, RFC 9578 issuance protocols) — issue a token
10
+ * challenge and verify a presented token without learning who the
11
+ * client is. An origin asks for a token with a
12
+ * <code>WWW-Authenticate: PrivateToken</code> challenge; the client
13
+ * obtains a token from an issuer and presents it; the origin verifies
14
+ * it cryptographically.
15
+ *
16
+ * This implements the publicly verifiable token type
17
+ * <strong>0x0002 (Blind RSA, 2048-bit)</strong>: the token's
18
+ * authenticator is an RSA Blind Signature (RFC 9474) that any party
19
+ * holding the issuer's public key can verify with RSASSA-PSS — so the
20
+ * origin verifies tokens itself, with no issuer secret and no callback.
21
+ * The privately verifiable VOPRF type (0x0001) requires the issuer's
22
+ * secret key and is an issuer-side operation, not implemented here.
23
+ *
24
+ * Blind RSA is the algorithm Privacy Pass defines on the wire; like
25
+ * the framework's DNSSEC / DANE verifiers it validates an external
26
+ * protocol's signatures (RSASSA-PSS, SHA-384) rather than introducing
27
+ * classical crypto as a framework default.
28
+ *
29
+ * @card
30
+ * Privacy Pass origin side (RFC 9577 / 9578). Issue a
31
+ * <code>WWW-Authenticate: PrivateToken</code> challenge and verify a
32
+ * presented Blind-RSA (type 0x0002) token against the issuer public
33
+ * key — anonymous, publicly verifiable authorization with no issuer
34
+ * callback.
35
+ */
36
+
37
+ var nodeCrypto = require("node:crypto");
38
+ var bCrypto = require("./crypto");
39
+ var validateOpts = require("./validate-opts");
40
+ var { defineClass } = require("./framework-error");
41
+
42
+ var PrivacyPassError = defineClass("PrivacyPassError", { alwaysPermanent: true });
43
+
44
+ var TOKEN_TYPE_BLIND_RSA = 0x0002;
45
+ // RFC 9578 §5.3 token type 0x0002: RSABSSA-SHA384-PSS, salt length 48.
46
+ var PSS_HASH = "sha384";
47
+ var PSS_SALT_LEN = 48; // allow:raw-byte-literal — RFC 9578 §5.3 PSS salt length (= SHA-384 digest size)
48
+ // Fixed-size token fields (RFC 9577 §2.2): type(2) nonce(32)
49
+ // challenge_digest(32) token_key_id(32), then the authenticator.
50
+ var TOKEN_PREFIX_LEN = 98; // allow:raw-byte-literal — 2 + 32 + 32 + 32 (token_input length)
51
+
52
+ // RFC 9577 §2.1 sends the challenge / token-key auth-params as base64url
53
+ // WITH padding; Node's "base64url" output is unpadded, so pad to a
54
+ // multiple of 4 so strict clients / proxies accept the header.
55
+ function _b64urlPadded(buf) {
56
+ var s = Buffer.from(buf).toString("base64url");
57
+ while (s.length % 4 !== 0) s += "="; // allow:raw-byte-literal — base64 quantum is 4 chars
58
+ return s;
59
+ }
60
+
61
+ function _bytes(x, what) {
62
+ if (Buffer.isBuffer(x)) return x;
63
+ if (x instanceof Uint8Array) return Buffer.from(x);
64
+ if (typeof x === "string") return Buffer.from(x, "base64");
65
+ throw new PrivacyPassError("privacy-pass/bad-bytes", "privacyPass: " + what + " must be a Buffer / Uint8Array / base64 string");
66
+ }
67
+
68
+ // Import the issuer public key and capture the SubjectPublicKeyInfo
69
+ // bytes used to derive token_key_id. When the caller supplies the
70
+ // published SPKI DER directly, hash THOSE bytes — re-exporting an
71
+ // rsa-pss KeyObject can re-encode the AlgorithmIdentifier and change the
72
+ // digest. token_key_id is SHA-256 of the issuer's distributed key
73
+ // (RFC 9577 §2.2), which is the SPKI as published.
74
+ function _importIssuerKey(k) {
75
+ if (k && typeof k === "object" && typeof k.export === "function" && k.type === "public") {
76
+ return { key: k, spki: k.export({ format: "der", type: "spki" }) };
77
+ }
78
+ try {
79
+ if (Buffer.isBuffer(k) || k instanceof Uint8Array) {
80
+ var der = Buffer.from(k);
81
+ return { key: nodeCrypto.createPublicKey({ key: der, format: "der", type: "spki" }), spki: der };
82
+ }
83
+ // A "PUBLIC KEY" PEM body IS the SubjectPublicKeyInfo DER — decode it
84
+ // directly so token_key_id is SHA-256 of the issuer's exact bytes,
85
+ // not a re-encoding (Node can re-emit rsa-pss AlgorithmIdentifier
86
+ // parameters differently on export).
87
+ if (typeof k === "string" && /-----BEGIN PUBLIC KEY-----/.test(k)) {
88
+ var body = k.replace(/-----BEGIN PUBLIC KEY-----/, "").replace(/-----END PUBLIC KEY-----/, "").replace(/\s+/g, "");
89
+ var pemDer = Buffer.from(body, "base64");
90
+ return { key: nodeCrypto.createPublicKey(k), spki: pemDer };
91
+ }
92
+ var key = nodeCrypto.createPublicKey(k); // other key spec (best-effort SPKI export)
93
+ return { key: key, spki: key.export({ format: "der", type: "spki" }) };
94
+ } catch (e) {
95
+ throw new PrivacyPassError("privacy-pass/bad-key", "privacyPass: could not import issuerPublicKey: " + ((e && e.message) || e));
96
+ }
97
+ }
98
+
99
+ /**
100
+ * @primitive b.privacyPass.parseToken
101
+ * @signature b.privacyPass.parseToken(token)
102
+ * @since 0.12.52
103
+ * @status experimental
104
+ * @related b.privacyPass.verifyToken, b.privacyPass.buildChallenge
105
+ *
106
+ * Parse a Privacy Pass token (RFC 9577 §2.2) into its fields: the
107
+ * <code>tokenType</code>, the client <code>nonce</code>, the
108
+ * <code>challengeDigest</code> (SHA-256 of the TokenChallenge the token
109
+ * answers), the <code>tokenKeyId</code> (SHA-256 of the issuer public
110
+ * key), and the <code>authenticator</code>. Structural only — call
111
+ * <code>verifyToken</code> to check the signature.
112
+ *
113
+ * @example
114
+ * var t = b.privacyPass.parseToken(tokenBytes);
115
+ * // → { tokenType: 2, nonce, challengeDigest, tokenKeyId, authenticator }
116
+ */
117
+ function parseToken(token) {
118
+ var b = _bytes(token, "token");
119
+ if (b.length < TOKEN_PREFIX_LEN + 1) throw new PrivacyPassError("privacy-pass/bad-token", "privacyPass.parseToken: token too short");
120
+ return {
121
+ tokenType: b.readUInt16BE(0),
122
+ nonce: b.slice(2, 34),
123
+ challengeDigest: b.slice(34, 66),
124
+ tokenKeyId: b.slice(66, 98),
125
+ authenticator: b.slice(98),
126
+ tokenInput: b.slice(0, TOKEN_PREFIX_LEN),
127
+ };
128
+ }
129
+
130
+ /**
131
+ * @primitive b.privacyPass.verifyToken
132
+ * @signature b.privacyPass.verifyToken(opts)
133
+ * @since 0.12.52
134
+ * @status experimental
135
+ * @compliance soc2
136
+ * @related b.privacyPass.buildChallenge, b.privacyPass.parseToken
137
+ *
138
+ * Verify a publicly verifiable Privacy Pass token (type 0x0002, Blind
139
+ * RSA — RFC 9578 §8.2). The authenticator is checked as an RSASSA-PSS
140
+ * (SHA-384, MGF1-SHA-384, 48-byte salt) signature over
141
+ * <code>token_input = token_type ‖ nonce ‖ challenge_digest ‖
142
+ * token_key_id</code> using the issuer's public key. The token is bound
143
+ * to that key — its <code>token_key_id</code> must equal the SHA-256 of
144
+ * the supplied key's SubjectPublicKeyInfo — and, when
145
+ * <code>opts.challenge</code> is given, to that challenge (its SHA-256
146
+ * must equal the token's <code>challenge_digest</code>), so a token
147
+ * minted for a different origin's challenge is refused.
148
+ *
149
+ * @opts
150
+ * {
151
+ * token: Buffer|base64, // the presented token
152
+ * issuerPublicKey: KeyObject|Buffer(SPKI DER)|PEM,
153
+ * challenge?: Buffer|base64, // the TokenChallenge this token must answer
154
+ * }
155
+ *
156
+ * @example
157
+ * var r = b.privacyPass.verifyToken({ token: tok, issuerPublicKey: issuerSpki });
158
+ * // → { ok: true, tokenType: 2, nonce, challengeDigest, tokenKeyId }
159
+ */
160
+ function verifyToken(opts) {
161
+ validateOpts.requireObject(opts, "privacyPass.verifyToken", PrivacyPassError);
162
+ validateOpts(opts, ["token", "issuerPublicKey", "challenge"], "privacyPass.verifyToken");
163
+ if (opts.issuerPublicKey === undefined || opts.issuerPublicKey === null) throw new PrivacyPassError("privacy-pass/bad-arg", "privacyPass.verifyToken: opts.issuerPublicKey is required");
164
+
165
+ var parsed = parseToken(opts.token);
166
+ if (parsed.tokenType !== TOKEN_TYPE_BLIND_RSA) {
167
+ throw new PrivacyPassError("privacy-pass/unsupported-token-type", "privacyPass.verifyToken: only token type 0x0002 (Blind RSA) is verifiable by the origin; got 0x" + parsed.tokenType.toString(16).padStart(4, "0")); // allow:raw-byte-literal — base-16 radix + 4-hex-digit pad, not a size
168
+ }
169
+
170
+ var imported = _importIssuerKey(opts.issuerPublicKey);
171
+ var key = imported.key;
172
+ if (key.asymmetricKeyType !== "rsa" && key.asymmetricKeyType !== "rsa-pss") {
173
+ throw new PrivacyPassError("privacy-pass/bad-key", "privacyPass.verifyToken: issuerPublicKey must be an RSA key for token type 0x0002");
174
+ }
175
+
176
+ // Bind the token to the issuer key: token_key_id = SHA-256(SPKI).
177
+ var keyId = nodeCrypto.createHash("sha256").update(imported.spki).digest();
178
+ if (!bCrypto.timingSafeEqual(keyId, parsed.tokenKeyId)) {
179
+ throw new PrivacyPassError("privacy-pass/key-id-mismatch", "privacyPass.verifyToken: token_key_id does not match the issuer public key");
180
+ }
181
+
182
+ // Bind the token to the challenge, when supplied.
183
+ if (opts.challenge !== undefined && opts.challenge !== null) {
184
+ var cd = nodeCrypto.createHash("sha256").update(_bytes(opts.challenge, "challenge")).digest();
185
+ if (!bCrypto.timingSafeEqual(cd, parsed.challengeDigest)) {
186
+ throw new PrivacyPassError("privacy-pass/challenge-mismatch", "privacyPass.verifyToken: challenge_digest does not match opts.challenge");
187
+ }
188
+ }
189
+
190
+ var ok;
191
+ try {
192
+ ok = nodeCrypto.verify(PSS_HASH, parsed.tokenInput, { key: key, padding: nodeCrypto.constants.RSA_PKCS1_PSS_PADDING, saltLength: PSS_SALT_LEN }, parsed.authenticator);
193
+ } catch (e) {
194
+ throw new PrivacyPassError("privacy-pass/verify-threw", "privacyPass.verifyToken: signature verification threw: " + ((e && e.message) || e));
195
+ }
196
+ if (!ok) throw new PrivacyPassError("privacy-pass/bad-authenticator", "privacyPass.verifyToken: token authenticator did not verify");
197
+ return { ok: true, tokenType: parsed.tokenType, nonce: parsed.nonce, challengeDigest: parsed.challengeDigest, tokenKeyId: parsed.tokenKeyId };
198
+ }
199
+
200
+ /**
201
+ * @primitive b.privacyPass.buildChallenge
202
+ * @signature b.privacyPass.buildChallenge(opts)
203
+ * @since 0.12.52
204
+ * @status experimental
205
+ * @related b.privacyPass.verifyToken
206
+ *
207
+ * Build a TokenChallenge (RFC 9577 §2.1) and the matching
208
+ * <code>WWW-Authenticate: PrivateToken</code> header value an origin
209
+ * returns to ask a client for a token. The challenge binds the token to
210
+ * this issuer (and optionally this origin and a redemption context);
211
+ * its SHA-256 is the <code>challenge_digest</code> that
212
+ * <code>verifyToken</code> checks.
213
+ *
214
+ * @opts
215
+ * {
216
+ * issuerName: string, // the token issuer's name
217
+ * tokenType?: number, // default 0x0002 (Blind RSA)
218
+ * originInfo?: string, // origin name(s) the token is scoped to (default: any)
219
+ * redemptionContext?: Buffer, // 0 or 32 bytes (default: empty)
220
+ * tokenKey?: Buffer|KeyObject, // issuer SPKI, included as token-key= when given
221
+ * }
222
+ *
223
+ * @example
224
+ * var c = b.privacyPass.buildChallenge({ issuerName: "issuer.example", originInfo: "origin.example" });
225
+ * res.setHeader("WWW-Authenticate", c.wwwAuthenticate);
226
+ */
227
+ function buildChallenge(opts) {
228
+ validateOpts.requireObject(opts, "privacyPass.buildChallenge", PrivacyPassError);
229
+ validateOpts(opts, ["issuerName", "tokenType", "originInfo", "redemptionContext", "tokenKey"], "privacyPass.buildChallenge");
230
+ if (typeof opts.issuerName !== "string" || opts.issuerName === "") throw new PrivacyPassError("privacy-pass/bad-arg", "privacyPass.buildChallenge: opts.issuerName is required");
231
+ var tokenType = opts.tokenType === undefined ? TOKEN_TYPE_BLIND_RSA : opts.tokenType;
232
+ if (typeof tokenType !== "number" || !Number.isInteger(tokenType) || tokenType < 0 || tokenType > 0xffff) throw new PrivacyPassError("privacy-pass/bad-arg", "privacyPass.buildChallenge: tokenType must be a uint16");
233
+
234
+ var issuer = Buffer.from(opts.issuerName, "utf8");
235
+ if (issuer.length > 0xffff) throw new PrivacyPassError("privacy-pass/bad-arg", "privacyPass.buildChallenge: issuerName too long");
236
+ var origin = Buffer.alloc(0);
237
+ if (opts.originInfo !== undefined && opts.originInfo !== null) {
238
+ if (typeof opts.originInfo !== "string") throw new PrivacyPassError("privacy-pass/bad-arg", "privacyPass.buildChallenge: originInfo must be a string");
239
+ origin = Buffer.from(opts.originInfo, "utf8");
240
+ if (origin.length > 0xffff) throw new PrivacyPassError("privacy-pass/bad-arg", "privacyPass.buildChallenge: originInfo too long");
241
+ }
242
+ var rc = opts.redemptionContext !== undefined && opts.redemptionContext !== null ? _bytes(opts.redemptionContext, "redemptionContext") : Buffer.alloc(0);
243
+ if (rc.length !== 0 && rc.length !== 32) throw new PrivacyPassError("privacy-pass/bad-arg", "privacyPass.buildChallenge: redemptionContext must be empty or 32 bytes"); // allow:raw-byte-literal — RFC 9577 redemption_context is 0 or 32 bytes
244
+
245
+ var u16 = function (n) { return Buffer.from([(n >> 8) & 0xff, n & 0xff]); };
246
+ var challenge = Buffer.concat([
247
+ u16(tokenType),
248
+ u16(issuer.length), issuer,
249
+ Buffer.from([rc.length]), rc,
250
+ u16(origin.length), origin,
251
+ ]);
252
+
253
+ var parts = ['PrivateToken challenge="' + _b64urlPadded(challenge) + '"'];
254
+ if (opts.tokenKey !== undefined && opts.tokenKey !== null) {
255
+ var spki = (opts.tokenKey && typeof opts.tokenKey.export === "function") ? opts.tokenKey.export({ format: "der", type: "spki" }) : _bytes(opts.tokenKey, "tokenKey");
256
+ parts.push('token-key="' + _b64urlPadded(spki) + '"');
257
+ }
258
+ return { challenge: challenge, wwwAuthenticate: parts.join(", ") };
259
+ }
260
+
261
+ module.exports = {
262
+ parseToken: parseToken,
263
+ verifyToken: verifyToken,
264
+ buildChallenge: buildChallenge,
265
+ TOKEN_TYPE_BLIND_RSA: TOKEN_TYPE_BLIND_RSA,
266
+ PrivacyPassError: PrivacyPassError,
267
+ };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.12.51",
3
+ "version": "0.12.52",
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.52",
4
+ "date": "2026-05-25",
5
+ "headline": "`b.privacyPass` — Privacy Pass origin-side token verification (RFC 9577 / 9578)",
6
+ "summary": "Anonymous, publicly verifiable authorization: an origin issues a WWW-Authenticate: PrivateToken challenge and verifies a presented token cryptographically, without learning who the client is and without a callback to the issuer. b.privacyPass implements the publicly verifiable token type 0x0002 (Blind RSA, 2048-bit): the token's authenticator is an RSA Blind Signature (RFC 9474) checked as RSASSA-PSS (SHA-384, 48-byte salt) over token_input = token_type ‖ nonce ‖ challenge_digest ‖ token_key_id, using only the issuer's public key. The token is bound to that key (token_key_id) and, optionally, to the challenge it answers, so a token minted for another origin is refused. Blind RSA is the algorithm Privacy Pass defines on the wire — like the DNSSEC / DANE verifiers it validates an external protocol's signatures rather than introducing classical crypto as a framework default. Verified against the RFC 9578 §8.2 test vector.",
7
+ "sections": [
8
+ {
9
+ "heading": "Added",
10
+ "items": [
11
+ {
12
+ "title": "`b.privacyPass.verifyToken(opts)` / `parseToken` / `buildChallenge`",
13
+ "body": "`buildChallenge` builds a TokenChallenge (RFC 9577 §2.1) and the matching `WWW-Authenticate: PrivateToken challenge=…, token-key=…` header an origin returns to request a token, scoped to an issuer (and optionally an origin and a 32-byte redemption context). `parseToken` splits a token into its fields (type / nonce / challenge_digest / token_key_id / authenticator). `verifyToken` verifies a type 0x0002 (Blind RSA) token: it confirms the token's `token_key_id` is the SHA-256 of the supplied issuer public key, optionally that its `challenge_digest` matches `opts.challenge`, and that the authenticator is a valid RSASSA-PSS signature over the token input. Refuses unknown / privately verifiable token types (the VOPRF type 0x0001 needs the issuer secret and is an issuer-side operation), key-id and challenge mismatches, and tampered authenticators. Marked experimental while the issuance protocols see deployment."
14
+ }
15
+ ]
16
+ }
17
+ ]
18
+ }
@@ -2271,9 +2271,10 @@ async function testNoDuplicateCodeBlocks() {
2271
2271
  "lib/mdoc.js:_bytes",
2272
2272
  "lib/network-dnssec.js:_bytes",
2273
2273
  "lib/network-dane.js:_bytes",
2274
+ "lib/privacy-pass.js:_bytes",
2274
2275
  "lib/tsa.js:_bytes",
2275
2276
  ],
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.",
2277
+ reason: "v0.12.48 / v0.12.51 / v0.12.52 — 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.",
2277
2278
  },
2278
2279
  {
2279
2280
  mode: "family-subset",
@@ -0,0 +1,120 @@
1
+ "use strict";
2
+ /**
3
+ * Layer 0 — b.privacyPass (Privacy Pass origin side, RFC 9577 / 9578).
4
+ * The oracle is the published RFC 9578 §8.2 test vector for token type
5
+ * 0x0002 (Blind RSA): the issuer public key, the TokenChallenge, and the
6
+ * issued token. A wrong token_input layout or PSS parameter would fail
7
+ * the real RSASSA-PSS verification, and the build-then-digest round trip
8
+ * reproduces the vector's challenge_digest byte-for-byte.
9
+ */
10
+
11
+ var b = require("../../index");
12
+ var helpers = require("../helpers");
13
+ var check = helpers.check;
14
+ var crypto = require("node:crypto");
15
+
16
+ // RFC 9578 §8.2, test vector 1 (token type 0x0002, Blind RSA 2048).
17
+ var PKI_SPKI_HEX = "30820152303d06092a864886f70d01010a3030a00d300b0609608648016503040202a11a301806092a864886f70d010108300b0609608648016503040202a2030201300382010f003082010a0282010100cb1aed6b6a95f5b1ce013a4cfcab25b94b2e64a23034e4250a7eab43c0df3a8c12993af12b111908d4b471bec31d4b6c9ad9cdda90612a2ee903523e6de5a224d6b02f09e5c374d0cfe01d8f529c500a78a2f67908fa682b5a2b430c81eaf1af72d7b5e794fc98a3139276879757ce453b526ef9bf6ceb99979b8423b90f4461a22af37aab0cf5733f7597abe44d31c732db68a181c6cbbe607d8c0e52e0655fd9996dc584eca0be87afbcd78a337d17b1dba9e828bbd81e291317144e7ff89f55619709b096cbb9ea474cead264c2073fe49740c01f00e109106066983d21e5f83f086e2e823c879cd43cef700d2a352a9babd612d03cad02db134b7e225a5f0203010001";
18
+ var TOKEN_CHALLENGE_HEX = "0002000e6973737565722e6578616d706c65208e7acc900e393381e8810b7c9e4a68b5163f1f880ab6688a6ffe780923609e88000e6f726967696e2e6578616d706c65";
19
+ var TOKEN_HEX = "0002aa72019d1f951df197021ce63876fe8b0a02dc1c31a12b0a2dd1508d07827f055969f643b4cfda5196d4aa86aeb5368834f4f06de46950ed435b3b81bd036d44ca572f8982a9ca248a3056186322d93ca147266121ddeb5632c07f1f71cd2708bc6a21b533d07294b5e900faf5537dd3eb33cee4e08c9670d1e5358fd184b0e00c637174f5206b14c7bb0e724ebf6b56271e5aa2ed94c051c4a433d302b23bc52460810d489fb050f9de5c868c6c1b06e3849fd087629f704cc724bc0d0984d5c339686fcdd75f9a9cdd25f37f855f6f4c584d84f716864f546b696d620c5bd41a811498de84ff9740ba3003ba2422d26b91eb745c084758974642a42078201543246ddb58030ea8e722376aa82484dca9610a8fb7e018e396165462e17a03e40ea7e128c090a911ecc708066cb201833010c1ebd4e910fc8e27a1be467f78671836a508257123a45e4e0ae2180a434bd1037713466347a8ebe46439d3da1970";
20
+
21
+ function spki() { return Buffer.from(PKI_SPKI_HEX, "hex"); }
22
+ function token() { return Buffer.from(TOKEN_HEX, "hex"); }
23
+ function challenge() { return Buffer.from(TOKEN_CHALLENGE_HEX, "hex"); }
24
+ function code(fn) { try { fn(); return "NO-THROW"; } catch (e) { return e.code; } }
25
+
26
+ function testSurface() {
27
+ check("b.privacyPass.verifyToken is a function", typeof b.privacyPass.verifyToken === "function");
28
+ check("b.privacyPass.parseToken is a function", typeof b.privacyPass.parseToken === "function");
29
+ check("b.privacyPass.buildChallenge is a function", typeof b.privacyPass.buildChallenge === "function");
30
+ check("b.privacyPass.TOKEN_TYPE_BLIND_RSA is 0x0002", b.privacyPass.TOKEN_TYPE_BLIND_RSA === 0x0002);
31
+ check("b.privacyPass.PrivacyPassError is the typed error class", typeof b.privacyPass.PrivacyPassError === "function" && code(function () { b.privacyPass.parseToken(Buffer.alloc(2)); }) === "privacy-pass/bad-token");
32
+ var threw = null; try { b.privacyPass.parseToken(Buffer.alloc(2)); } catch (e) { threw = e; }
33
+ check("PrivacyPassError instances are thrown", threw instanceof b.privacyPass.PrivacyPassError);
34
+ }
35
+
36
+ function testParse() {
37
+ var t = b.privacyPass.parseToken(token());
38
+ check("parseToken: token type 0x0002", t.tokenType === 0x0002);
39
+ check("parseToken: 32-byte nonce / digest / key-id", t.nonce.length === 32 && t.challengeDigest.length === 32 && t.tokenKeyId.length === 32);
40
+ check("parseToken: 256-byte authenticator (RSA-2048)", t.authenticator.length === 256);
41
+ // The embedded token_key_id is SHA-256 of the issuer SPKI.
42
+ var keyId = crypto.createHash("sha256").update(spki()).digest();
43
+ check("parseToken: token_key_id == SHA-256(issuer SPKI)", Buffer.compare(keyId, t.tokenKeyId) === 0);
44
+ }
45
+
46
+ function testRealVector() {
47
+ var out = b.privacyPass.verifyToken({ token: token(), issuerPublicKey: spki() });
48
+ check("verifyToken: real RFC 9578 §8.2 Blind RSA token verifies", out.ok && out.tokenType === 0x0002);
49
+ // Bound to the challenge it answers.
50
+ var out2 = b.privacyPass.verifyToken({ token: token(), issuerPublicKey: spki(), challenge: challenge() });
51
+ check("verifyToken: verifies when bound to the matching challenge", out2.ok === true);
52
+ }
53
+
54
+ function testBuildChallengeRoundTrip() {
55
+ // Rebuilding the vector's TokenChallenge reproduces it byte-for-byte,
56
+ // and its SHA-256 is the challenge_digest embedded in the token.
57
+ var rc = challenge().slice(19, 51); // the 32-byte redemption_context (after the 1-byte length at offset 18)
58
+ var c = b.privacyPass.buildChallenge({ issuerName: "issuer.example", originInfo: "origin.example", redemptionContext: rc });
59
+ check("buildChallenge: reproduces the RFC TokenChallenge bytes", Buffer.compare(c.challenge, challenge()) === 0);
60
+ var digest = crypto.createHash("sha256").update(c.challenge).digest();
61
+ var t = b.privacyPass.parseToken(token());
62
+ check("buildChallenge: SHA-256(challenge) == token challenge_digest", Buffer.compare(digest, t.challengeDigest) === 0);
63
+ check("buildChallenge: emits a PrivateToken WWW-Authenticate header", /^PrivateToken challenge="/.test(c.wwwAuthenticate));
64
+ // RFC 9577 §2.1: auth-param values are padded base64url.
65
+ var cv = c.wwwAuthenticate.match(/challenge="([^"]+)"/)[1];
66
+ check("buildChallenge: challenge value is base64url with padding (len % 4 === 0)", cv.length % 4 === 0 && !/[+/]/.test(cv));
67
+ var ck = b.privacyPass.buildChallenge({ issuerName: "issuer.example", tokenKey: spki() });
68
+ check("buildChallenge: token-key value is padded base64url", /token-key="([^"]+)"/.test(ck.wwwAuthenticate) && ck.wwwAuthenticate.match(/token-key="([^"]+)"/)[1].length % 4 === 0);
69
+ }
70
+
71
+ function testPemKeyId() {
72
+ // A PEM-encoded issuer key must derive the same token_key_id as the raw
73
+ // SPKI bytes (Node can re-encode an rsa-pss AlgorithmIdentifier on
74
+ // export, so the PEM body bytes — not a re-export — must be hashed).
75
+ var pem = "-----BEGIN PUBLIC KEY-----\n" + spki().toString("base64").replace(/(.{64})/g, "$1\n").replace(/\n$/, "") + "\n-----END PUBLIC KEY-----\n";
76
+ var out = b.privacyPass.verifyToken({ token: token(), issuerPublicKey: pem });
77
+ check("verifyToken: real token verifies with a PEM issuer key", out.ok === true);
78
+ }
79
+
80
+ function testRefusals() {
81
+ // Tampered authenticator fails.
82
+ check("verifyToken: tampered authenticator refused", code(function () {
83
+ var bad = token(); bad[bad.length - 1] ^= 0xff;
84
+ b.privacyPass.verifyToken({ token: bad, issuerPublicKey: spki() });
85
+ }) === "privacy-pass/bad-authenticator");
86
+ // Wrong issuer key → token_key_id mismatch (caught before signature).
87
+ check("verifyToken: wrong issuer key refused (key-id mismatch)", code(function () {
88
+ var otherKey = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 }).publicKey.export({ format: "der", type: "spki" });
89
+ b.privacyPass.verifyToken({ token: token(), issuerPublicKey: otherKey });
90
+ }) === "privacy-pass/key-id-mismatch");
91
+ // Wrong challenge → challenge_digest mismatch.
92
+ check("verifyToken: mismatched challenge refused", code(function () {
93
+ b.privacyPass.verifyToken({ token: token(), issuerPublicKey: spki(), challenge: Buffer.from("not the challenge") });
94
+ }) === "privacy-pass/challenge-mismatch");
95
+ // Privately verifiable VOPRF (0x0001) is not an origin-verify operation.
96
+ check("verifyToken: token type 0x0001 (VOPRF) refused", code(function () {
97
+ var t = token(); t.writeUInt16BE(0x0001, 0);
98
+ b.privacyPass.verifyToken({ token: t, issuerPublicKey: spki() });
99
+ }) === "privacy-pass/unsupported-token-type");
100
+ // Truncated token refused.
101
+ check("parseToken: short token refused", code(function () { b.privacyPass.parseToken(Buffer.alloc(40)); }) === "privacy-pass/bad-token");
102
+ }
103
+
104
+ async function run() {
105
+ testSurface();
106
+ testParse();
107
+ testRealVector();
108
+ testBuildChallengeRoundTrip();
109
+ testPemKeyId();
110
+ testRefusals();
111
+ }
112
+
113
+ module.exports = { run: run };
114
+
115
+ if (require.main === module) {
116
+ run().then(
117
+ function () { console.log("[privacy-pass] OK — " + helpers.getChecks() + " checks passed"); },
118
+ function (e) { console.error("FAIL:", e && e.stack || e); process.exit(1); }
119
+ );
120
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
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": {