@blamejs/blamejs-shop 0.1.14 → 0.1.16
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 +4 -0
- package/README.md +3 -3
- package/lib/admin.js +2 -0
- package/lib/checkout.js +133 -0
- package/lib/storefront.js +131 -1
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +2 -0
- package/lib/vendor/blamejs/README.md +1 -1
- package/lib/vendor/blamejs/api-snapshot.json +14 -2
- package/lib/vendor/blamejs/lib/structured-fields.js +85 -7
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.12.55.json +18 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/structured-fields-codec.test.js +36 -0
- package/package.json +1 -1
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.16 (2026-05-25) — **PayPal express checkout — the on-page button.** PayPal checkout is now usable from the storefront. When PayPal is configured, the checkout page shows a native PayPal button (distinct from PayPal-through-Stripe): it opens a PayPal order for the current cart, the buyer approves in the PayPal popup, and the order is captured and marked paid. A verified PayPal webhook is the asynchronous backstop. This completes the native PayPal integration on top of the adapter and checkout orchestration shipped in the previous two releases. Card / Stripe checkout is unchanged. **Added:** *PayPal button + create/capture routes on the storefront* — The checkout page renders a PayPal button when `PAYPAL_CLIENT_ID` is configured. Its create step posts to `POST /checkout/paypal/create` (prices the cart, opens a PayPal order, persists the local order pending) and its approve step posts to `POST /checkout/paypal/capture` (captures and advances the order to paid, then redirects to the order page). Both validate input and never 500 on a missing cart or id. The buttons collect the same shipping fields as the card form. · *PayPal webhook endpoint* — `POST /api/webhooks/paypal` is the asynchronous backstop for captures completed or refunded out of band. The container verifies each event server-to-server through PayPal's verify-webhook-signature API (no edge HMAC pre-check, unlike Stripe), then advances the order; re-deliveries are idempotent. Point a PayPal webhook at `/api/webhooks/paypal`. **Changed:** *PayPal listed as configurable; CSP note* — The integrations status page and README document PayPal as a first-class checkout option once configured. As with the Stripe pay page's `js.stripe.com`, operators must allow `www.paypal.com` in their Content-Security-Policy `script-src` / `frame-src` for the PayPal SDK and approval popup.
|
|
12
|
+
|
|
13
|
+
- v0.1.15 (2026-05-25) — **PayPal checkout orchestration.** Checkout can now run a PayPal order end to end, building on the adapter from the previous release. The orchestrator prices the cart, opens a PayPal order and persists the local order as pending, captures it after the buyer approves and advances the order to paid, and has a webhook backstop for captures completed or refunded out of band. Wired in when a PayPal app's credentials are present, and surfaced on the integrations status page. The storefront button and routes that drive this from the pay page come next; card / Stripe checkout is unchanged. **Added:** *checkout PayPal methods* — With a PayPal adapter wired (`paypal` dep), checkout gains three methods. `createPaypalOrder({ cart_id, ship_to, selected_shipping_id, customer, idempotency_key, return_url?, cancel_url? })` prices the cart, opens a PayPal Orders-v2 order, persists the local order in `pending` with the PayPal order id linked, and marks the cart converted. `capturePaypalOrder(paypalOrderId)` captures the approved order and advances the local order to `paid` (idempotent — a retry or a webhook that beat it won't double-transition). `handlePaypalEvent({ headers, rawBody })` is the asynchronous backstop: it verifies the event through PayPal, then maps `PAYMENT.CAPTURE.COMPLETED` → paid and `PAYMENT.CAPTURE.REFUNDED` → refunded, idempotent across re-deliveries. · *PayPal wired from configuration* — When `PAYPAL_CLIENT_ID` + `PAYPAL_SECRET` are set (with `PAYPAL_ENV` and `PAYPAL_WEBHOOK_ID`), the server builds the PayPal adapter and passes it to checkout; the integrations status page lists PayPal as action-needed once card checkout is also live. Off until configured; the existing checkout flow is untouched.
|
|
14
|
+
|
|
11
15
|
- v0.1.14 (2026-05-25) — **PayPal payment adapter (Orders v2).** `payment.create({ adapter: "paypal", … })` is a new native PayPal adapter alongside the Stripe one — a from-scratch Orders-v2 client over the framework's SSRF-gated HTTP client, with no PayPal SDK dependency. It exchanges an OAuth2 client-credentials token (cached until it nears expiry), creates and captures orders, fetches and refunds them, and verifies inbound webhooks through PayPal's verify-webhook-signature API. This ships the adapter only; wiring it into the checkout flow and a storefront button comes next. Card / Stripe checkout is unchanged. **Added:** *PayPal Orders-v2 adapter* — `payment.create({ adapter: "paypal", clientId, secret, sandbox?, webhookId?, apiBase? })` returns `{ createOrder, captureOrder, getOrder, refund, verifyWebhook }`. `createOrder({ amount_minor, currency, order_id?, return_url?, cancel_url? })` opens a CAPTURE-intent order (amounts converted to PayPal's decimal-string major units, including 0-decimal currencies); `captureOrder(id)` finalizes it; `refund({ capture_id, amount_minor?, currency? })` refunds full or partial; `getOrder(id)` reads status. Every call carries an OAuth2 bearer token exchanged once and cached until ~2 minutes before expiry, and a `PayPal-Request-Id` for idempotency (plus the shared idempotency cache when a `query` handle is wired). `verifyWebhook(headers, rawBody, { webhookId })` confirms an inbound event through PayPal's verify-webhook-signature API and returns `{ ok, event }`. Outbound HTTP goes through `b.httpClient` — no `paypal` npm dependency. Off until the operator supplies credentials; the Stripe adapter and existing checkout are unchanged.
|
|
12
16
|
|
|
13
17
|
- v0.1.13 (2026-05-25) — **Internal: uniform framework access across the library.** An internal consistency refactor with no API or behavior change. Every library module now captures the framework once at the top — straight from the vendored tree (`var b = require("./vendor/blamejs");`) — and uses `b.*` uniformly, replacing a per-module lazy accessor and its scattered call sites. Capturing the framework directly (rather than via the composing entry point) also avoids a circular-load edge case on leaf-first imports. Two source files that embedded a raw NUL byte as a map-key separator now use the `\u0000` escape, so the whole library is plain text. New lint rules lock all of this in place. Public APIs, runtime behavior, and the published surface are unchanged. **Added:** *Lint detectors for accessor uniformity and source hygiene* — Three repository lint rules now prevent drift: one rejects the reintroduction of the per-module lazy framework accessor (capture the framework once at module top); one rejects requiring the composing entry point from a leaf module (require the vendored framework directly — entry-point requires from a leaf create a circular-load hazard); and one rejects raw C0 control bytes in source files (write `\u0000` and friends as escapes so files stay plain text — grep / diff / editors handle them correctly). **Changed:** *Framework handle captured once per module* — Library modules previously reached the vendored framework through a lazy `_b()` accessor invoked at every call site. They now capture it once at module top — `var b = require("./vendor/blamejs");`, the same object the entry point re-exports as `.framework` — and reference `b.*` directly, matching how the edge worker already accesses it. Capturing it directly from the vendored tree (instead of through the composing entry point) keeps leaf-first module imports working — requiring a single module no longer pulls the entry point's whole assembly mid-initialization. No public API or runtime behavior changes.
|
package/README.md
CHANGED
|
@@ -57,9 +57,9 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
|
|
|
57
57
|
| **`lib/pricing.js`** | Pure-function money math — `lineTotal`, `subtotal`, `totals`, `format`. Multi-currency refused, banker's-style rounding, locale-aware via `Intl.NumberFormat`. |
|
|
58
58
|
| **`lib/tax.js`** | Operator-table adapter. Country / state / postal_prefix → rate_bps. Most-specific-first match, banker's rounding. Pluggable adapter shape for future Stripe Tax / TaxJar / Avalara. |
|
|
59
59
|
| **`lib/shipping.js`** | Operator-table adapter. Services with zones (flat or per-gram + base + min/max), free-over-threshold, `digital_only` flag. |
|
|
60
|
-
| **`lib/payment.js`** |
|
|
60
|
+
| **`lib/payment.js`** | Payment adapters — **Stripe** (verify webhook HMAC-SHA256 via upstream `b.webhook.verify`, create / retrieve / confirm / cancel PaymentIntent, refund, register / list payment-method domains for Apple/Google Pay) and **PayPal** (`adapter: "paypal"` — OAuth2 client-credentials token, create / capture / get / refund Orders v2, webhook verify via PayPal's verify-webhook-signature API). No `stripe` / `paypal` npm dep — outbound through `b.httpClient` (SSRF-gated, retried, circuit-broken). |
|
|
61
61
|
| **`lib/order.js`** | FSM-driven post-checkout record via upstream `b.fsm`. States: pending → paid → fulfilling → shipped → delivered (+ refunded / cancelled). Every transition appends to `order_transitions`. |
|
|
62
|
-
| **`lib/checkout.js`** | Orchestrator. `quote()` returns priced quote; `confirm()` creates PaymentIntent + persists order
|
|
62
|
+
| **`lib/checkout.js`** | Orchestrator. `quote()` returns priced quote; `confirm()` creates a Stripe PaymentIntent + persists order pending; `handleStripeEvent()` verifies webhook + fires the FSM transition. PayPal path: `createPaypalOrder()` opens a PayPal order + persists pending, `capturePaypalOrder()` captures → paid, `handlePaypalEvent()` is the webhook backstop. All 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
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. |
|
|
@@ -163,10 +163,10 @@ variables. A signed-in operator can see the live on/off status of each at
|
|
|
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
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. |
|
|
166
|
+
| **PayPal checkout** | A native PayPal button on `/checkout` (PayPal Orders v2 — create / approve / capture), distinct from PayPal-through-Stripe. | `PAYPAL_CLIENT_ID`, `PAYPAL_SECRET` (a PayPal REST app), `PAYPAL_WEBHOOK_ID`, `PAYPAL_ENV` (`sandbox`\|`live`); Stripe checkout must also be live | The shop exchanges the OAuth2 token and creates / captures orders server-side; the button drives `/checkout/paypal/create` + `/checkout/paypal/capture`. Point a PayPal webhook at `/api/webhooks/paypal` (verified through PayPal's API). Allow `www.paypal.com` in your CSP `script-src` / `frame-src` (as you would `js.stripe.com`). |
|
|
166
167
|
|
|
167
168
|
**Planned / not available:**
|
|
168
169
|
|
|
169
|
-
- **PayPal** — a separate adapter (Orders v2 + its own webhook); planned.
|
|
170
170
|
- **Shop Pay / "Sign in with Shop"** — **not available** to a self-hosted,
|
|
171
171
|
non-Shopify store: the credentials only issue from a Shopify Admin and payment
|
|
172
172
|
flows through Shopify Payments. There is no path to enable it here.
|
package/lib/admin.js
CHANGED
|
@@ -1785,6 +1785,8 @@ var INTEGRATIONS_CATALOG = [
|
|
|
1785
1785
|
set: "GOOGLE_OAUTH_CLIENT_ID, GOOGLE_OAUTH_CLIENT_SECRET, SHOP_ORIGIN. Add <SHOP_ORIGIN>/account/auth/google/callback as a Google OAuth redirect URI." },
|
|
1786
1786
|
{ key: "apple_signin", name: "Sign in with Apple", enables: "A “Continue with Apple” button on the account login page.",
|
|
1787
1787
|
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." },
|
|
1788
|
+
{ key: "paypal", name: "PayPal checkout", enables: "A native PayPal button on the checkout page (create / capture via PayPal Orders v2) — distinct from PayPal-through-Stripe.",
|
|
1789
|
+
set: "PAYPAL_CLIENT_ID, PAYPAL_SECRET (a PayPal REST app), PAYPAL_WEBHOOK_ID, PAYPAL_ENV (sandbox|live). Card checkout (Stripe) must be live too. Point a PayPal webhook at /api/webhooks/paypal." },
|
|
1788
1790
|
];
|
|
1789
1791
|
|
|
1790
1792
|
function renderAdminIntegrations(opts) {
|
package/lib/checkout.js
CHANGED
|
@@ -73,6 +73,15 @@ var STRIPE_EVENT_MAP = Object.freeze({
|
|
|
73
73
|
// Subscription webhook event types route to the subscriptions
|
|
74
74
|
// primitive (if wired via deps.subscriptions) instead of the order
|
|
75
75
|
// FSM. The one-time-order path stays unchanged.
|
|
76
|
+
// PayPal webhook event types → order FSM event. The create/capture flow
|
|
77
|
+
// marks the order paid directly; these are the asynchronous backstop.
|
|
78
|
+
var PAYPAL_EVENT_MAP = Object.freeze({
|
|
79
|
+
"PAYMENT.CAPTURE.COMPLETED": "mark_paid",
|
|
80
|
+
"PAYMENT.CAPTURE.DENIED": null, // no state change — operator decides
|
|
81
|
+
"PAYMENT.CAPTURE.REFUNDED": "refund",
|
|
82
|
+
"CHECKOUT.ORDER.APPROVED": null, // approved but not captured yet
|
|
83
|
+
});
|
|
84
|
+
|
|
76
85
|
var STRIPE_SUB_EVENT_TYPES = Object.freeze({
|
|
77
86
|
"customer.subscription.created": true,
|
|
78
87
|
"customer.subscription.updated": true,
|
|
@@ -90,6 +99,7 @@ function create(deps) {
|
|
|
90
99
|
var tax = deps.tax;
|
|
91
100
|
var shipping = deps.shipping;
|
|
92
101
|
var payment = deps.payment;
|
|
102
|
+
var paypal = deps.paypal || null; // PayPal adapter — checkout-via-PayPal disabled when absent
|
|
93
103
|
var order = deps.order;
|
|
94
104
|
var subscriptions = deps.subscriptions || null;
|
|
95
105
|
// Optional — when wired, the buyer email is hashed (via the same
|
|
@@ -313,6 +323,128 @@ function create(deps) {
|
|
|
313
323
|
}
|
|
314
324
|
return { handled: true, event_type: eventType, order: updated };
|
|
315
325
|
},
|
|
326
|
+
|
|
327
|
+
// ---- PayPal (Orders v2) ----------------------------------------------
|
|
328
|
+
//
|
|
329
|
+
// PayPal's flow is create → buyer-approve → capture, not a Stripe-style
|
|
330
|
+
// PaymentIntent + webhook. `createPaypalOrder` prices the cart, opens a
|
|
331
|
+
// PayPal order, and persists the local order in `pending` with the PayPal
|
|
332
|
+
// order id linked. `capturePaypalOrder` (called after the buyer approves)
|
|
333
|
+
// captures and advances the order to `paid`. `handlePaypalEvent` is the
|
|
334
|
+
// async backstop. All three require the `paypal` adapter to be wired.
|
|
335
|
+
|
|
336
|
+
createPaypalOrder: async function (input) {
|
|
337
|
+
if (!paypal) throw new TypeError("checkout.createPaypalOrder: paypal adapter not wired");
|
|
338
|
+
if (!input || typeof input !== "object") throw new TypeError("checkout.createPaypalOrder: input required");
|
|
339
|
+
if (!input.customer || typeof input.customer !== "object") throw new TypeError("checkout.createPaypalOrder: customer required");
|
|
340
|
+
var email = _email(input.customer.customer_email || input.customer.email);
|
|
341
|
+
if (!input.selected_shipping_id) throw new TypeError("checkout.createPaypalOrder: selected_shipping_id required");
|
|
342
|
+
var idempotencyKey = input.idempotency_key;
|
|
343
|
+
if (typeof idempotencyKey !== "string" || idempotencyKey.length < 8) {
|
|
344
|
+
throw new TypeError("checkout.createPaypalOrder: idempotency_key (≥8 chars) required");
|
|
345
|
+
}
|
|
346
|
+
var quote = await _buildQuote(input);
|
|
347
|
+
if (quote.totals.grand_total_minor <= 0) {
|
|
348
|
+
throw new TypeError("checkout.createPaypalOrder: grand_total_minor must be > 0");
|
|
349
|
+
}
|
|
350
|
+
var ppOrder = await paypal.createOrder({
|
|
351
|
+
amount_minor: quote.totals.grand_total_minor,
|
|
352
|
+
currency: quote.currency.toUpperCase(),
|
|
353
|
+
order_id: quote.cart_id,
|
|
354
|
+
return_url: input.return_url || undefined,
|
|
355
|
+
cancel_url: input.cancel_url || undefined,
|
|
356
|
+
}, idempotencyKey);
|
|
357
|
+
var cartRow = await cart.get(quote.cart_id);
|
|
358
|
+
var createdOrder = await order.createFromCart({
|
|
359
|
+
cart_id: quote.cart_id,
|
|
360
|
+
session_id: cartRow.session_id,
|
|
361
|
+
customer_id: cartRow.customer_id || null,
|
|
362
|
+
currency: quote.currency,
|
|
363
|
+
subtotal_minor: quote.totals.subtotal_minor,
|
|
364
|
+
discount_minor: quote.totals.discount_minor,
|
|
365
|
+
tax_minor: quote.totals.tax_minor,
|
|
366
|
+
shipping_minor: quote.totals.shipping_minor,
|
|
367
|
+
grand_total_minor: quote.totals.grand_total_minor,
|
|
368
|
+
payment_intent_id: ppOrder.id, // the PayPal order id (opaque); links the webhook + capture
|
|
369
|
+
ship_to: input.ship_to,
|
|
370
|
+
customer_email_hash: customers ? customers.hashEmail(email) : null,
|
|
371
|
+
lines: quote.lines.map(function (l) {
|
|
372
|
+
return { variant_id: l.variant_id, sku: l.sku, qty: l.qty, unit_amount_minor: l.unit_amount_minor, unit_currency: l.unit_currency };
|
|
373
|
+
}),
|
|
374
|
+
});
|
|
375
|
+
await cart.setStatus(quote.cart_id, "converted");
|
|
376
|
+
return { order: createdOrder, paypal_order_id: ppOrder.id, status: ppOrder.status };
|
|
377
|
+
},
|
|
378
|
+
|
|
379
|
+
// Capture an approved PayPal order, then advance the local order to paid.
|
|
380
|
+
// Idempotent: if the order already left `pending` (a webhook or a retry
|
|
381
|
+
// beat us), the capture result is returned without a second transition.
|
|
382
|
+
capturePaypalOrder: async function (paypalOrderId) {
|
|
383
|
+
if (!paypal) throw new TypeError("checkout.capturePaypalOrder: paypal adapter not wired");
|
|
384
|
+
if (typeof paypalOrderId !== "string" || !paypalOrderId.length) {
|
|
385
|
+
throw new TypeError("checkout.capturePaypalOrder: paypalOrderId required");
|
|
386
|
+
}
|
|
387
|
+
var o = await order.byPaymentIntent(paypalOrderId);
|
|
388
|
+
if (!o) return { handled: false, reason: "order-not-found", paypal_order_id: paypalOrderId };
|
|
389
|
+
// Guard on LOCAL status before calling PayPal: if a prior capture or a
|
|
390
|
+
// webhook already advanced the order, a second remote capture would be
|
|
391
|
+
// rejected by PayPal (orders capture once) — turning an idempotent retry
|
|
392
|
+
// into an exception. Treat already-advanced as a success no-op.
|
|
393
|
+
if (o.status !== "pending") {
|
|
394
|
+
return { handled: true, order: o, skipped: "already-advanced", paypal_order_id: paypalOrderId };
|
|
395
|
+
}
|
|
396
|
+
var cap = await paypal.captureOrder(paypalOrderId);
|
|
397
|
+
var captureId = null;
|
|
398
|
+
try { captureId = cap.purchase_units[0].payments.captures[0].id; } catch (_e) { captureId = null; }
|
|
399
|
+
var completed = cap && (cap.status === "COMPLETED" ||
|
|
400
|
+
(captureId && cap.purchase_units[0].payments.captures[0].status === "COMPLETED"));
|
|
401
|
+
if (completed && o.status === "pending") {
|
|
402
|
+
await order.transition(o.id, "mark_paid", {
|
|
403
|
+
reason: "paypal:capture",
|
|
404
|
+
metadata: { paypal_order_id: paypalOrderId, paypal_capture_id: captureId },
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
return { handled: !!completed, order: await order.get(o.id), capture_id: captureId, capture: cap };
|
|
408
|
+
},
|
|
409
|
+
|
|
410
|
+
// Verify a PayPal webhook (server-to-server, via the adapter) and apply
|
|
411
|
+
// the matching order transition. The capture flow above is primary; this
|
|
412
|
+
// catches captures completed/refunded out of band. { handled, ... }.
|
|
413
|
+
handlePaypalEvent: async function (input) {
|
|
414
|
+
if (!paypal) throw new TypeError("checkout.handlePaypalEvent: paypal adapter not wired");
|
|
415
|
+
if (!input || typeof input !== "object") throw new TypeError("checkout.handlePaypalEvent: input required");
|
|
416
|
+
if (typeof input.rawBody !== "string") throw new TypeError("checkout.handlePaypalEvent: rawBody (string) required");
|
|
417
|
+
var v = await paypal.verifyWebhook(input.headers || {}, input.rawBody, { webhookId: input.webhookId });
|
|
418
|
+
if (!v.ok) {
|
|
419
|
+
var err = new Error("checkout: paypal webhook verification failed — " + v.reason);
|
|
420
|
+
err.code = "WEBHOOK_INVALID";
|
|
421
|
+
err.reason = v.reason;
|
|
422
|
+
throw err;
|
|
423
|
+
}
|
|
424
|
+
var event = v.event || {};
|
|
425
|
+
var eventType = event.event_type;
|
|
426
|
+
if (!eventType || !Object.prototype.hasOwnProperty.call(PAYPAL_EVENT_MAP, eventType)) {
|
|
427
|
+
return { handled: false, event_type: eventType || null };
|
|
428
|
+
}
|
|
429
|
+
var fsmEvent = PAYPAL_EVENT_MAP[eventType];
|
|
430
|
+
if (!fsmEvent) return { handled: false, event_type: eventType, reason: "no-state-change" };
|
|
431
|
+
// The PayPal order id lives in the capture resource's related ids.
|
|
432
|
+
var ppOrderId = null;
|
|
433
|
+
try { ppOrderId = event.resource.supplementary_data.related_ids.order_id; } catch (_e) { ppOrderId = null; }
|
|
434
|
+
if (!ppOrderId) return { handled: false, event_type: eventType, reason: "no-order-id" };
|
|
435
|
+
var o = await order.byPaymentIntent(ppOrderId);
|
|
436
|
+
if (!o) return { handled: false, event_type: eventType, reason: "order-not-found" };
|
|
437
|
+
var alreadyAdvanced = (
|
|
438
|
+
(fsmEvent === "mark_paid" && o.status !== "pending") ||
|
|
439
|
+
(fsmEvent === "refund" && o.status === "refunded")
|
|
440
|
+
);
|
|
441
|
+
if (alreadyAdvanced) return { handled: true, event_type: eventType, order: o, skipped: "already-advanced" };
|
|
442
|
+
var updated = await order.transition(o.id, fsmEvent, {
|
|
443
|
+
reason: "paypal:" + eventType,
|
|
444
|
+
metadata: { paypal_event_id: event.id, paypal_order_id: ppOrderId },
|
|
445
|
+
});
|
|
446
|
+
return { handled: true, event_type: eventType, order: updated };
|
|
447
|
+
},
|
|
316
448
|
};
|
|
317
449
|
}
|
|
318
450
|
|
|
@@ -320,4 +452,5 @@ module.exports = {
|
|
|
320
452
|
create: create,
|
|
321
453
|
STRIPE_EVENT_MAP: STRIPE_EVENT_MAP,
|
|
322
454
|
STRIPE_SUB_EVENT_TYPES: STRIPE_SUB_EVENT_TYPES,
|
|
455
|
+
PAYPAL_EVENT_MAP: PAYPAL_EVENT_MAP,
|
|
323
456
|
};
|
package/lib/storefront.js
CHANGED
|
@@ -1620,6 +1620,15 @@ function renderCheckoutForm(opts) {
|
|
|
1620
1620
|
});
|
|
1621
1621
|
}
|
|
1622
1622
|
var body = _render(CHECKOUT_PAGE, { subtotal: subtotal });
|
|
1623
|
+
// When PayPal is configured, append its button below the card form. The
|
|
1624
|
+
// block is built as raw HTML (appended after the strict render) so the SDK
|
|
1625
|
+
// script + handlers survive; the client-id is the only interpolation and is
|
|
1626
|
+
// attribute-escaped. The PayPal button's createOrder/onApprove drive the
|
|
1627
|
+
// /checkout/paypal/create + /capture routes. (Operators must allow
|
|
1628
|
+
// www.paypal.com in their CSP script-src/frame-src, as for js.stripe.com.)
|
|
1629
|
+
if (opts.paypal_client_id) {
|
|
1630
|
+
body += _paypalCheckoutBlock(opts.paypal_client_id, totals.currency);
|
|
1631
|
+
}
|
|
1623
1632
|
return _wrap({
|
|
1624
1633
|
title: "Checkout",
|
|
1625
1634
|
shop_name: shopName,
|
|
@@ -1629,6 +1638,31 @@ function renderCheckoutForm(opts) {
|
|
|
1629
1638
|
});
|
|
1630
1639
|
}
|
|
1631
1640
|
|
|
1641
|
+
function _paypalCheckoutBlock(clientId, currency) {
|
|
1642
|
+
var esc = b.template.escapeHtml;
|
|
1643
|
+
var cid = esc(String(clientId));
|
|
1644
|
+
var cur = esc(String(currency || "USD"));
|
|
1645
|
+
return "\n<div class=\"checkout-paypal\" style=\"max-width:32rem;margin:1.5rem auto 0;\">" +
|
|
1646
|
+
"<div class=\"pay-card__divider\"><span>or pay with PayPal</span></div>" +
|
|
1647
|
+
"<div id=\"paypal-button-container\"></div>" +
|
|
1648
|
+
"<p id=\"paypal-error\" class=\"auth-form__message auth-form__message--err\" hidden></p>" +
|
|
1649
|
+
"</div>" +
|
|
1650
|
+
"<script src=\"https://www.paypal.com/sdk/js?client-id=" + cid + "¤cy=" + cur + "&intent=capture\"></script>" +
|
|
1651
|
+
"<script>(function(){" +
|
|
1652
|
+
"if(!window.paypal){return;}" +
|
|
1653
|
+
"var form=document.querySelector('.checkout-page form');" +
|
|
1654
|
+
"var errEl=document.getElementById('paypal-error');" +
|
|
1655
|
+
"function vals(){var d={};['email','name','country','state','postal'].forEach(function(k){var el=form&&form.elements[k];if(el){d[k]=el.value;}});return d;}" +
|
|
1656
|
+
"function showErr(m){if(errEl){errEl.hidden=false;errEl.textContent=m||'PayPal checkout could not be completed.';}}" +
|
|
1657
|
+
"paypal.Buttons({" +
|
|
1658
|
+
"onClick:function(_d,actions){if(form&&form.reportValidity&&!form.reportValidity()){return actions.reject();}return actions.resolve();}," +
|
|
1659
|
+
"createOrder:function(){return fetch('/checkout/paypal/create',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(vals())}).then(function(r){return r.json();}).then(function(d){if(!d.id){throw new Error(d.error||'create failed');}return d.id;});}," +
|
|
1660
|
+
"onApprove:function(data){return fetch('/checkout/paypal/capture',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({paypal_order_id:data.orderID})}).then(function(r){return r.json();}).then(function(d){if(d.redirect){window.location.href=d.redirect;}else{showErr(d.error);}});}," +
|
|
1661
|
+
"onError:function(){showErr();}" +
|
|
1662
|
+
"}).render('#paypal-button-container');" +
|
|
1663
|
+
"})();</script>\n";
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1632
1666
|
// Stripe Elements payment page — embeds Stripe.js + a minimal
|
|
1633
1667
|
// mount block. The publishable key is operator-supplied (env
|
|
1634
1668
|
// `STRIPE_PUBLISHABLE_KEY` → forwarded into the rendered HTML).
|
|
@@ -2808,7 +2842,7 @@ function mount(router, deps) {
|
|
|
2808
2842
|
return res.end ? res.end() : res.send("");
|
|
2809
2843
|
}
|
|
2810
2844
|
var totals = pricing.totals(c, lines, {});
|
|
2811
|
-
_send(res, 200, renderCheckoutForm({ lines: lines, totals: totals, shop_name: shopName, theme: theme }));
|
|
2845
|
+
_send(res, 200, renderCheckoutForm({ lines: lines, totals: totals, shop_name: shopName, theme: theme, paypal_client_id: deps.paypal ? deps.paypal_client_id : null }));
|
|
2812
2846
|
});
|
|
2813
2847
|
|
|
2814
2848
|
router.post("/checkout", async function (req, res) {
|
|
@@ -2867,6 +2901,76 @@ function mount(router, deps) {
|
|
|
2867
2901
|
}
|
|
2868
2902
|
});
|
|
2869
2903
|
|
|
2904
|
+
// PayPal express checkout (Orders v2). Mounts when the PayPal adapter is
|
|
2905
|
+
// wired. The pay-page PayPal button drives two AJAX calls: `create` opens
|
|
2906
|
+
// a PayPal order for the current cart (returns its id for the SDK to
|
|
2907
|
+
// approve in the popup); `capture` finalizes after approval and advances
|
|
2908
|
+
// the local order to paid. Both read the session cart; the button posts
|
|
2909
|
+
// the same ship_to fields the card form collects.
|
|
2910
|
+
if (deps.paypal) {
|
|
2911
|
+
router.post("/checkout/paypal/create", async function (req, res) {
|
|
2912
|
+
function _json(status, obj) {
|
|
2913
|
+
res.status(status);
|
|
2914
|
+
res.setHeader && res.setHeader("content-type", "application/json; charset=utf-8");
|
|
2915
|
+
var s = JSON.stringify(obj);
|
|
2916
|
+
return res.end ? res.end(s) : res.send(s);
|
|
2917
|
+
}
|
|
2918
|
+
var body = req.body || {};
|
|
2919
|
+
var sid = _readSidCookie(req);
|
|
2920
|
+
if (!sid) return _json(400, { error: "no-session" });
|
|
2921
|
+
var c = await deps.cart.bySession(sid);
|
|
2922
|
+
if (!c || c.status !== "active") return _json(409, { error: "no-active-cart" });
|
|
2923
|
+
var shipTo = {
|
|
2924
|
+
country: (body.country || "").toUpperCase(),
|
|
2925
|
+
state: body.state ? String(body.state).toUpperCase() : undefined,
|
|
2926
|
+
postal: body.postal || undefined,
|
|
2927
|
+
};
|
|
2928
|
+
try {
|
|
2929
|
+
var defaultShipId = typeof deps.default_shipping_id === "function"
|
|
2930
|
+
? await deps.default_shipping_id() : deps.default_shipping_id;
|
|
2931
|
+
var created = await deps.checkout.createPaypalOrder({
|
|
2932
|
+
cart_id: c.id,
|
|
2933
|
+
ship_to: shipTo,
|
|
2934
|
+
selected_shipping_id: body.selected_shipping_id || defaultShipId || "std",
|
|
2935
|
+
customer: { email: body.email, name: body.name },
|
|
2936
|
+
idempotency_key: "paypal:" + c.id + ":" + b.uuid.v7(),
|
|
2937
|
+
return_url: body.return_url || undefined,
|
|
2938
|
+
cancel_url: body.cancel_url || undefined,
|
|
2939
|
+
});
|
|
2940
|
+
// The PayPal JS SDK's createOrder expects `{ id }`.
|
|
2941
|
+
return _json(200, { id: created.paypal_order_id, order_id: created.order.id });
|
|
2942
|
+
} catch (e) {
|
|
2943
|
+
return _json(e instanceof TypeError ? 400 : 502, { error: (e && e.message) || "paypal-create-failed" });
|
|
2944
|
+
}
|
|
2945
|
+
});
|
|
2946
|
+
|
|
2947
|
+
router.post("/checkout/paypal/capture", async function (req, res) {
|
|
2948
|
+
function _json(status, obj) {
|
|
2949
|
+
res.status(status);
|
|
2950
|
+
res.setHeader && res.setHeader("content-type", "application/json; charset=utf-8");
|
|
2951
|
+
var s = JSON.stringify(obj);
|
|
2952
|
+
return res.end ? res.end(s) : res.send(s);
|
|
2953
|
+
}
|
|
2954
|
+
var body = req.body || {};
|
|
2955
|
+
var paypalOrderId = body.paypal_order_id || body.orderID || body.orderId;
|
|
2956
|
+
if (typeof paypalOrderId !== "string" || !paypalOrderId.length) return _json(400, { error: "paypal_order_id required" });
|
|
2957
|
+
try {
|
|
2958
|
+
var result = await deps.checkout.capturePaypalOrder(paypalOrderId);
|
|
2959
|
+
if (!result.order) return _json(404, { error: "order-not-found" });
|
|
2960
|
+
// Only redirect to the order page when the capture actually
|
|
2961
|
+
// completed (or the order is already paid). A non-completing capture
|
|
2962
|
+
// leaves the order pending — surface it as an error so the client
|
|
2963
|
+
// doesn't show success for an unpaid order.
|
|
2964
|
+
if (!result.handled && result.order.status !== "paid") {
|
|
2965
|
+
return _json(502, { error: "capture-incomplete", status: result.order.status });
|
|
2966
|
+
}
|
|
2967
|
+
return _json(200, { order_id: result.order.id, status: result.order.status, redirect: "/orders/" + result.order.id });
|
|
2968
|
+
} catch (e) {
|
|
2969
|
+
return _json(502, { error: (e && e.message) || "paypal-capture-failed" });
|
|
2970
|
+
}
|
|
2971
|
+
});
|
|
2972
|
+
}
|
|
2973
|
+
|
|
2870
2974
|
router.get("/pay/:order_id", async function (req, res) {
|
|
2871
2975
|
var orderId = req.params && req.params.order_id;
|
|
2872
2976
|
if (!orderId) return _send(res, 404, renderNotFound({ shop_name: shopName, theme: theme }));
|
|
@@ -2952,6 +3056,32 @@ function mount(router, deps) {
|
|
|
2952
3056
|
return res.end ? res.end("handler error") : res.send("");
|
|
2953
3057
|
}
|
|
2954
3058
|
});
|
|
3059
|
+
|
|
3060
|
+
// PayPal webhook — the async backstop for captures completed/refunded out
|
|
3061
|
+
// of band (the create/capture flow is primary). Verified server-to-server
|
|
3062
|
+
// through PayPal's API (handlePaypalEvent), so unlike Stripe there is no
|
|
3063
|
+
// edge HMAC pre-check; the raw body is captured upstream by
|
|
3064
|
+
// webhookRawBodyCapture. Mounts only when the PayPal adapter is wired.
|
|
3065
|
+
if (deps.paypal) {
|
|
3066
|
+
router.post("/api/webhooks/paypal", async function (req, res) {
|
|
3067
|
+
var raw = Buffer.isBuffer(req.body) ? req.body.toString("utf8")
|
|
3068
|
+
: (typeof req.body === "string" ? req.body : "");
|
|
3069
|
+
try {
|
|
3070
|
+
var result = await deps.checkout.handlePaypalEvent({ headers: req.headers || {}, rawBody: raw });
|
|
3071
|
+
res.status(200);
|
|
3072
|
+
res.setHeader && res.setHeader("content-type", "application/json; charset=utf-8");
|
|
3073
|
+
var payload = JSON.stringify({ ok: true, handled: !!(result && result.handled) });
|
|
3074
|
+
return res.end ? res.end(payload) : res.send(payload);
|
|
3075
|
+
} catch (e) {
|
|
3076
|
+
if (e && e.code === "WEBHOOK_INVALID") {
|
|
3077
|
+
res.status(400);
|
|
3078
|
+
return res.end ? res.end("invalid signature") : res.send("");
|
|
3079
|
+
}
|
|
3080
|
+
res.status(500);
|
|
3081
|
+
return res.end ? res.end("handler error") : res.send("");
|
|
3082
|
+
}
|
|
3083
|
+
});
|
|
3084
|
+
}
|
|
2955
3085
|
}
|
|
2956
3086
|
|
|
2957
3087
|
// ---- customer accounts (passkey-only) ------------------------------
|
package/lib/vendor/MANIFEST.json
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
"_about": "blamejs.shop vendors a single framework — blamejs — which itself bundles every server-side crypto/identity dependency. The transitive packages blamejs ships are surfaced in its own MANIFEST.json at lib/vendor/blamejs/lib/vendor/MANIFEST.json — Trivy / Grype rely on that nested data for CVE attribution.",
|
|
4
4
|
"packages": {
|
|
5
5
|
"blamejs": {
|
|
6
|
-
"version": "0.12.
|
|
7
|
-
"tag": "v0.12.
|
|
6
|
+
"version": "0.12.55",
|
|
7
|
+
"tag": "v0.12.55",
|
|
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.55 (2026-05-25) — **`b.structuredFields` — RFC 9651 Date and Display String types.** Brings the Structured Fields codec up to RFC 9651, which obsoletes RFC 8941 by adding two bare-item types. A Date (`@1659578233`) is an Integer number of seconds since the Unix epoch; a Display String (`%"f%c3%bc%c3%bc"`) is a Unicode string conveyed as percent-escaped UTF-8. parse returns them as distinct SfDate / SfDisplayString values, and serialize emits them canonically — a Date as `@` + integer, a Display String as `%"`-wrapped lowercase-percent-escaped UTF-8 that escapes only what RFC 9651 requires. Parsing is strict: a Date rejects a decimal / out-of-range value, and a Display String rejects uppercase escapes, raw non-ASCII, bad hex, and invalid UTF-8. Validated against the official httpwg structured-field-tests date and display-string vectors. **Added:** *RFC 9651 Date (`@…`) and Display String (`%"…"`) in `b.structuredFields`* — `parse` now reads the two RFC 9651 types: `@` + an Integer yields an `SfDate` (rejecting a decimal `@1.5`, an empty `@`, a sign-only `@-`, and out-of-range values), and `%"…"` yields an `SfDisplayString` (decoding lowercase `%XX` escapes as UTF-8, rejecting uppercase escapes, raw non-ASCII or control characters, malformed hex, and invalid UTF-8). `serialize` is the inverse — a Date as `@` + the integer, a Display String percent-escaping only non-printable / non-ASCII bytes plus `%` and `"`. The new `b.structuredFields.Date` and `b.structuredFields.DisplayString` wrappers construct these values. The module now tracks RFC 9651 (which obsoletes RFC 8941); the existing Item / List / Dictionary parsing is unchanged.
|
|
12
|
+
|
|
11
13
|
- v0.12.54 (2026-05-25) — **`b.structuredFields.parse` / `serialize` — full RFC 8941 Structured Fields codec.** The structured-fields module gains a complete RFC 8941 parser and serializer alongside its existing quote-aware helpers. b.structuredFields.parse reads an Item, List, or Dictionary into a typed value model — items are { value, params }, lists are arrays of items / inner lists, dictionaries are Maps — with Tokens and byte sequences returned as distinct SfToken / SfByteSequence instances. It enforces the grammar strictly: integer and decimal digit caps, printable-ASCII strings, canonical base64 byte sequences, valid token and key grammar, and no trailing characters. b.structuredFields.serialize is the exact inverse. This is the real parser the framework's Content-Digest, Client Hints, Web Push, and HTTP Message Signature surfaces can build on instead of open-coding each field. Validated against the official httpwg structured-field-tests conformance vectors. **Added:** *`b.structuredFields.parse(input, type, opts?)` / `serialize(value, type, opts?)` / `Token` / `ByteSequence`* — `parse` accepts `type` of `"item"`, `"list"`, or `"dictionary"` and returns the value model (items as `{ value, params }` with a `Map` of parameters; lists as arrays of items or inner lists; dictionaries as `Map`s). Bare items are JS numbers (Integer / Decimal), strings, booleans, `SfToken`, or `SfByteSequence`. Malformed input is rejected — out-of-range integers, over-long decimals, non-printable string bytes, non-canonical base64, invalid tokens / keys, and any trailing characters — and `opts.ErrorClass` yields a typed error. `serialize` is the inverse, rounding decimals to three fractional digits and refusing values outside the RFC's ranges or grammar. `b.structuredFields.Token` and `b.structuredFields.ByteSequence` wrap those bare-item types for serialization. The existing `splitTopLevel` / `refuseControlBytes` / `unquoteSfString` helpers are unchanged.
|
|
12
14
|
|
|
13
15
|
- v0.12.53 (2026-05-25) — **`b.contentDigest` — HTTP Content-Digest / Repr-Digest fields (RFC 9530).** Emit and verify the Content-Digest / Repr-Digest HTTP fields so a recipient can detect a corrupted or tampered message body. b.contentDigest.create builds the RFC 8941 dictionary value (sha-256=:base64:, sha-512=:base64:) over a body; b.contentDigest.verify recomputes each modern digest over the body and compares it in constant time. Only SHA-256 and SHA-512 are computed — the legacy algorithms RFC 9530 §6 marks insecure (MD5, SHA-1, the unix checksums) are ignored on verify, and a field carrying no modern digest is refused, so an attacker cannot downgrade integrity to an MD5-only digest. Content-Digest is the integrity companion to HTTP Message Signatures (b.httpSig, RFC 9421): sign the digest rather than the whole body. Verified against the RFC 9530 Appendix D worked examples. **Added:** *`b.contentDigest.create(body, opts?)` / `b.contentDigest.verify(fieldValue, body, opts?)`* — `create` returns a Content-Digest / Repr-Digest field value over the body — SHA-256 by default, or any subset of `["sha-256","sha-512"]` via `opts.algorithms` — and refuses insecure or unknown algorithms. `verify` parses the field, recomputes each SHA-256 / SHA-512 entry over the body, and compares constant-time; it throws `content-digest/mismatch` on any mismatch, ignores legacy / unknown entries, throws `content-digest/no-modern-digest` if the field has no SHA-256 / SHA-512 entry at all, and honours `opts.required` to force specific algorithms to be present and match. Composes the framework's structured-field helpers and constant-time compare; Repr-Digest is the same machinery over the selected representation (RFC 9110).
|
|
@@ -98,7 +98,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
|
|
|
98
98
|
- **AAD-bound sealed columns** — AEAD tag tied to `(table, rowId, column, schemaVersion)`; copy-paste between rows or schema-version replay surfaces as refused decrypt (`b.vault.aad`)
|
|
99
99
|
- **Signed webhooks + API encryption** — SLH-DSA-SHAKE-256f default; ML-DSA-65 opt-in; ECIES API encryption (`b.webhook`, `b.crypto`)
|
|
100
100
|
- **HPKE / HTTP signatures** — RFC 9180 HPKE with ML-KEM-1024 + HKDF-SHA3-512 + ChaCha20-Poly1305 (`b.crypto.hpke`); RFC 9421 HTTP Message Signatures with derived components and ed25519 / ML-DSA-65 (`b.crypto.httpSig`); RFC 9530 Content-Digest / Repr-Digest body-integrity fields (SHA-256 / SHA-512, legacy algorithms refused — `b.contentDigest`) to sign the digest rather than the whole body
|
|
101
|
-
- **Structured Fields** — full RFC
|
|
101
|
+
- **Structured Fields** — full RFC 9651 codec (`b.structuredFields.parse` / `serialize`): Items / Lists / Dictionaries, Inner Lists, Parameters, and every bare-item type (Integer / Decimal / String / Token / Byte Sequence / Boolean / Date / Display String) with strict grammar + range enforcement — the parser behind Content-Digest, Client Hints, and HTTP Message Signatures
|
|
102
102
|
- **CMS codec** — RFC 5652 Cryptographic Message Syntax encoder + decoder with PQC signers (ML-DSA-65 / ML-DSA-87 / SLH-DSA-SHAKE-256f; RFC 9909 + 9881) and KEMRecipientInfo recipients (ML-KEM-1024; RFC 9629 + 9936); ChaCha20-Poly1305 content encryption (RFC 8103) so Efail-class malleability cannot apply (`b.cms`)
|
|
103
103
|
- **Stream throttle** — shared token-bucket bandwidth limiter (RFC 2697 srTCM shape); N concurrent `node:stream` pipelines draw from one operator-configured `bytesPerSec` budget (`b.streamThrottle`)
|
|
104
104
|
- **TLS-RPT receiver** — RFC 8460 inbound aggregate-report ingest; HTTPS POST handler + §4.4 schema parser with gzip-bomb / ratio-bomb / depth-bomb defenses (`b.mail.deploy.parseTlsRptReport` / `b.mail.deploy.tlsRptIngestHttp`)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 1,
|
|
3
|
-
"frameworkVersion": "0.12.
|
|
4
|
-
"createdAt": "2026-05-
|
|
3
|
+
"frameworkVersion": "0.12.55",
|
|
4
|
+
"createdAt": "2026-05-25T19:45:48.985Z",
|
|
5
5
|
"exports": {
|
|
6
6
|
"a2a": {
|
|
7
7
|
"type": "object",
|
|
@@ -48698,6 +48698,18 @@
|
|
|
48698
48698
|
"type": "function",
|
|
48699
48699
|
"arity": 1
|
|
48700
48700
|
},
|
|
48701
|
+
"Date": {
|
|
48702
|
+
"type": "function",
|
|
48703
|
+
"arity": 1
|
|
48704
|
+
},
|
|
48705
|
+
"Decimal": {
|
|
48706
|
+
"type": "function",
|
|
48707
|
+
"arity": 1
|
|
48708
|
+
},
|
|
48709
|
+
"DisplayString": {
|
|
48710
|
+
"type": "function",
|
|
48711
|
+
"arity": 1
|
|
48712
|
+
},
|
|
48701
48713
|
"Token": {
|
|
48702
48714
|
"type": "function",
|
|
48703
48715
|
"arity": 1
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* @module b.structuredFields
|
|
4
4
|
* @nav HTTP
|
|
5
|
-
* @title RFC
|
|
5
|
+
* @title RFC 9651 Structured Fields
|
|
6
6
|
* @order 317
|
|
7
7
|
*
|
|
8
8
|
* @intro
|
|
@@ -67,6 +67,10 @@
|
|
|
67
67
|
* b.structuredFields.splitTopLevel('alg="x;y";nonce=42', ";");
|
|
68
68
|
* // → ['alg="x;y"', 'nonce=42']
|
|
69
69
|
*/
|
|
70
|
+
// node:util is a builtin (no lib require cycle) — used for strict UTF-8
|
|
71
|
+
// validation of RFC 9651 Display Strings.
|
|
72
|
+
var TextDecoder = require("node:util").TextDecoder;
|
|
73
|
+
|
|
70
74
|
function splitTopLevel(s, sep) {
|
|
71
75
|
if (typeof s !== "string") return [];
|
|
72
76
|
if (sep !== "," && sep !== ";") {
|
|
@@ -237,13 +241,15 @@ function containsControlBytes(value, opts) {
|
|
|
237
241
|
}
|
|
238
242
|
|
|
239
243
|
// ---------------------------------------------------------------------------
|
|
240
|
-
// Full RFC
|
|
244
|
+
// Full RFC 9651 codec (parse + serialize; RFC 9651 obsoletes RFC 8941
|
|
245
|
+
// and adds the Date and Display String types). The helpers above are the
|
|
241
246
|
// quote-aware splitters individual parsers reach for; the codec below is
|
|
242
247
|
// the complete grammar — Items, Lists, Dictionaries, Inner Lists,
|
|
243
248
|
// Parameters, and every bare-item type.
|
|
244
249
|
//
|
|
245
|
-
// Value model:
|
|
246
|
-
// bare item → number (Integer) | SfDecimal | string | boolean | SfToken
|
|
250
|
+
// Value model (RFC 9651, which obsoletes RFC 8941):
|
|
251
|
+
// bare item → number (Integer) | SfDecimal | string | boolean | SfToken
|
|
252
|
+
// | SfByteSequence | SfDate | SfDisplayString
|
|
247
253
|
// item → { value: bareItem, params: Map<string, bareItem> }
|
|
248
254
|
// inner list → { items: item[], params: Map<string, bareItem> }
|
|
249
255
|
// list → (item | innerList)[]
|
|
@@ -268,6 +274,18 @@ function SfDecimal(value) {
|
|
|
268
274
|
if (!(this instanceof SfDecimal)) return new SfDecimal(value);
|
|
269
275
|
this.value = Number(value);
|
|
270
276
|
}
|
|
277
|
+
// RFC 9651 §3.3.7 Date (an Integer number of seconds since the Unix
|
|
278
|
+
// epoch) and §3.3.8 Display String (a Unicode string conveyed as
|
|
279
|
+
// percent-escaped UTF-8). Wrapped so they stay distinct from Integers
|
|
280
|
+
// and plain Strings.
|
|
281
|
+
function SfDate(value) {
|
|
282
|
+
if (!(this instanceof SfDate)) return new SfDate(value);
|
|
283
|
+
this.value = Number(value);
|
|
284
|
+
}
|
|
285
|
+
function SfDisplayString(value) {
|
|
286
|
+
if (!(this instanceof SfDisplayString)) return new SfDisplayString(value);
|
|
287
|
+
this.value = String(value);
|
|
288
|
+
}
|
|
271
289
|
|
|
272
290
|
function _sfErr(opts) {
|
|
273
291
|
if (opts && typeof opts.ErrorClass === "function") {
|
|
@@ -359,12 +377,47 @@ function _parseToken(cx) {
|
|
|
359
377
|
return new SfToken(cx.s.slice(start, cx.i));
|
|
360
378
|
}
|
|
361
379
|
|
|
380
|
+
var _utf8Strict = new TextDecoder("utf-8", { fatal: true });
|
|
381
|
+
|
|
382
|
+
function _parseDate(cx, E) {
|
|
383
|
+
cx.i += 1; // "@"
|
|
384
|
+
var n = _parseNumber(cx, E);
|
|
385
|
+
if (n instanceof SfDecimal) throw E("structured-fields/parse", "date must be an integer number of seconds");
|
|
386
|
+
return new SfDate(n);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function _parseDisplayString(cx, E) {
|
|
390
|
+
cx.i += 1; // "%"
|
|
391
|
+
if (cx.s.charAt(cx.i) !== "\"") throw E("structured-fields/parse", "display string must open with %\"");
|
|
392
|
+
cx.i += 1;
|
|
393
|
+
var bytes = [];
|
|
394
|
+
while (cx.i < cx.s.length) {
|
|
395
|
+
var c = cx.s.charAt(cx.i); cx.i += 1;
|
|
396
|
+
if (c === "%") {
|
|
397
|
+
var h = cx.s.substr(cx.i, 2);
|
|
398
|
+
if (h.length !== 2 || !/^[0-9a-f]{2}$/.test(h)) throw E("structured-fields/parse", "display string escape must be %<lowercase-hex><lowercase-hex>"); // allow:raw-byte-literal — RFC 9651 §4.2.10 two-hex-digit escape
|
|
399
|
+
bytes.push(parseInt(h, 16));
|
|
400
|
+
cx.i += 2;
|
|
401
|
+
} else if (c === "\"") {
|
|
402
|
+
try { return new SfDisplayString(_utf8Strict.decode(Buffer.from(bytes))); }
|
|
403
|
+
catch (_e) { throw E("structured-fields/parse", "display string is not valid UTF-8"); }
|
|
404
|
+
} else {
|
|
405
|
+
var cc = c.charCodeAt(0);
|
|
406
|
+
if (cc < 0x20 || cc > 0x7e) throw E("structured-fields/parse", "display string contains a raw non-printable / non-ASCII character"); // allow:raw-byte-literal — RFC 9651 §4.2.10 printable-ASCII range
|
|
407
|
+
bytes.push(cc);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
throw E("structured-fields/parse", "unterminated display string");
|
|
411
|
+
}
|
|
412
|
+
|
|
362
413
|
function _parseBareItem(cx, E) {
|
|
363
414
|
var c = cx.s.charAt(cx.i);
|
|
364
415
|
if (c === "-" || _isDigit(c)) return _parseNumber(cx, E);
|
|
365
416
|
if (c === "\"") return _parseString(cx, E);
|
|
366
417
|
if (c === ":") return _parseByteSeq(cx, E);
|
|
367
418
|
if (c === "?") return _parseBoolean(cx, E);
|
|
419
|
+
if (c === "@") return _parseDate(cx, E);
|
|
420
|
+
if (c === "%") return _parseDisplayString(cx, E);
|
|
368
421
|
if (c === "*" || _isAlpha(c)) return _parseToken(cx);
|
|
369
422
|
throw E("structured-fields/parse", "unexpected character '" + (c || "<eof>") + "' at index " + cx.i);
|
|
370
423
|
}
|
|
@@ -455,9 +508,11 @@ function _parseDict(cx, E) {
|
|
|
455
508
|
* <code>"item"</code>, <code>"list"</code>, or <code>"dictionary"</code>.
|
|
456
509
|
* Returns the value model: an item is <code>{ value, params }</code>
|
|
457
510
|
* (params is a <code>Map</code>); a list is an array of items / inner
|
|
458
|
-
* lists; a dictionary is a <code>Map</code>. Tokens
|
|
459
|
-
* come back as <code>SfToken</code> /
|
|
460
|
-
*
|
|
511
|
+
* lists; a dictionary is a <code>Map</code>. Tokens, byte sequences,
|
|
512
|
+
* dates, and display strings come back as <code>SfToken</code> /
|
|
513
|
+
* <code>SfByteSequence</code> / <code>SfDate</code> /
|
|
514
|
+
* <code>SfDisplayString</code> instances so they stay distinct from
|
|
515
|
+
* plain strings and integers. Strictly enforces
|
|
461
516
|
* the grammar — integer / decimal digit caps, printable-ASCII strings,
|
|
462
517
|
* canonical base64, no trailing characters — and throws on any malformed
|
|
463
518
|
* input (pass <code>opts.ErrorClass</code> for a typed error).
|
|
@@ -493,10 +548,31 @@ function _serDecimal(v, E) {
|
|
|
493
548
|
if (s.indexOf(".") === -1) s += ".0"; // a Decimal must carry a fractional part
|
|
494
549
|
return s;
|
|
495
550
|
}
|
|
551
|
+
function _serDisplayString(s, E) {
|
|
552
|
+
if (typeof s !== "string") throw E("structured-fields/serialize", "display string value must be a string");
|
|
553
|
+
// RFC 9651 §4.1.10: serialize fails unless the value is a sequence of
|
|
554
|
+
// Unicode scalar values. A lone UTF-16 surrogate would otherwise be
|
|
555
|
+
// silently replaced with U+FFFD by Buffer.from, corrupting the output.
|
|
556
|
+
if (typeof s.isWellFormed === "function" ? !s.isWellFormed() : /[\uD800-\uDFFF]/.test(s.replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g, ""))) {
|
|
557
|
+
throw E("structured-fields/serialize", "display string contains a lone surrogate (not a valid Unicode string)");
|
|
558
|
+
}
|
|
559
|
+
var bytes = Buffer.from(s, "utf8"), out = "%\"";
|
|
560
|
+
for (var i = 0; i < bytes.length; i += 1) {
|
|
561
|
+
var b = bytes[i];
|
|
562
|
+
if (b >= 0x20 && b <= 0x7e && b !== 0x25 && b !== 0x22) out += String.fromCharCode(b); // allow:raw-byte-literal — RFC 9651 §4.1.10 printable ASCII except % and "
|
|
563
|
+
else out += "%" + (b < 0x10 ? "0" : "") + b.toString(16); // allow:raw-byte-literal — lowercase 2-hex escape
|
|
564
|
+
}
|
|
565
|
+
return out + "\"";
|
|
566
|
+
}
|
|
496
567
|
function _serBareItem(v, E) {
|
|
497
568
|
if (v === true) return "?1";
|
|
498
569
|
if (v === false) return "?0";
|
|
499
570
|
if (v instanceof SfDecimal) return _serDecimal(v.value, E);
|
|
571
|
+
if (v instanceof SfDate) {
|
|
572
|
+
if (!Number.isInteger(v.value) || v.value > INT_MAX || v.value < INT_MIN) throw E("structured-fields/serialize", "date must be an integer in RFC 9651 range");
|
|
573
|
+
return "@" + String(v.value);
|
|
574
|
+
}
|
|
575
|
+
if (v instanceof SfDisplayString) return _serDisplayString(v.value, E);
|
|
500
576
|
if (typeof v === "number") {
|
|
501
577
|
if (!isFinite(v)) throw E("structured-fields/serialize", "cannot serialize a non-finite number");
|
|
502
578
|
if (Number.isInteger(v)) {
|
|
@@ -603,4 +679,6 @@ module.exports = {
|
|
|
603
679
|
Token: SfToken,
|
|
604
680
|
ByteSequence: SfByteSequence,
|
|
605
681
|
Decimal: SfDecimal,
|
|
682
|
+
Date: SfDate,
|
|
683
|
+
DisplayString: SfDisplayString,
|
|
606
684
|
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../scripts/release-notes-schema.json",
|
|
3
|
+
"version": "0.12.55",
|
|
4
|
+
"date": "2026-05-25",
|
|
5
|
+
"headline": "`b.structuredFields` — RFC 9651 Date and Display String types",
|
|
6
|
+
"summary": "Brings the Structured Fields codec up to RFC 9651, which obsoletes RFC 8941 by adding two bare-item types. A Date (`@1659578233`) is an Integer number of seconds since the Unix epoch; a Display String (`%\"f%c3%bc%c3%bc\"`) is a Unicode string conveyed as percent-escaped UTF-8. parse returns them as distinct SfDate / SfDisplayString values, and serialize emits them canonically — a Date as `@` + integer, a Display String as `%\"`-wrapped lowercase-percent-escaped UTF-8 that escapes only what RFC 9651 requires. Parsing is strict: a Date rejects a decimal / out-of-range value, and a Display String rejects uppercase escapes, raw non-ASCII, bad hex, and invalid UTF-8. Validated against the official httpwg structured-field-tests date and display-string vectors.",
|
|
7
|
+
"sections": [
|
|
8
|
+
{
|
|
9
|
+
"heading": "Added",
|
|
10
|
+
"items": [
|
|
11
|
+
{
|
|
12
|
+
"title": "RFC 9651 Date (`@…`) and Display String (`%\"…\"`) in `b.structuredFields`",
|
|
13
|
+
"body": "`parse` now reads the two RFC 9651 types: `@` + an Integer yields an `SfDate` (rejecting a decimal `@1.5`, an empty `@`, a sign-only `@-`, and out-of-range values), and `%\"…\"` yields an `SfDisplayString` (decoding lowercase `%XX` escapes as UTF-8, rejecting uppercase escapes, raw non-ASCII or control characters, malformed hex, and invalid UTF-8). `serialize` is the inverse — a Date as `@` + the integer, a Display String percent-escaping only non-printable / non-ASCII bytes plus `%` and `\"`. The new `b.structuredFields.Date` and `b.structuredFields.DisplayString` wrappers construct these values. The module now tracks RFC 9651 (which obsoletes RFC 8941); the existing Item / List / Dictionary parsing is unchanged."
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|
|
17
|
+
]
|
|
18
|
+
}
|
|
@@ -24,6 +24,8 @@ function mineVal(v) {
|
|
|
24
24
|
if (v instanceof SF.Token) return { token: v.value };
|
|
25
25
|
if (v instanceof SF.ByteSequence) return { binary: b32(v.value) };
|
|
26
26
|
if (v instanceof SF.Decimal) return v.value; // compare a Decimal numerically (httpwg uses plain numbers)
|
|
27
|
+
if (v instanceof SF.Date) return { date: v.value };
|
|
28
|
+
if (v instanceof SF.DisplayString) return { displaystring: v.value };
|
|
27
29
|
return v;
|
|
28
30
|
}
|
|
29
31
|
function mineParams(map) { var o = []; map.forEach(function (v, k) { o.push([k, mineVal(v)]); }); return o; }
|
|
@@ -37,6 +39,8 @@ function mine(out, type) {
|
|
|
37
39
|
function httpVal(v) {
|
|
38
40
|
if (v && v.__type === "token") return { token: v.value };
|
|
39
41
|
if (v && v.__type === "binary") return { binary: v.value };
|
|
42
|
+
if (v && v.__type === "date") return { date: v.value };
|
|
43
|
+
if (v && v.__type === "displaystring") return { displaystring: v.value };
|
|
40
44
|
return v;
|
|
41
45
|
}
|
|
42
46
|
function httpParams(arr) { return arr.map(function (p) { return [p[0], httpVal(p[1])]; }); }
|
|
@@ -86,6 +90,26 @@ var CASES = [
|
|
|
86
90
|
{ name: "dictionary bare key", raw: "a=1, b, c=3", t: "dictionary", expected: [["a", [1, []]], ["b", [true, []]], ["c", [3, []]]] },
|
|
87
91
|
{ name: "dictionary inner-list value", raw: "a=(1 2)", t: "dictionary", expected: [["a", [[[1, []], [2, []]], []]]] },
|
|
88
92
|
{ name: "trailing comma dict", raw: "a=1,", t: "dictionary", must_fail: true },
|
|
93
|
+
// RFC 9651 Date (§3.3.7)
|
|
94
|
+
{ name: "date epoch", raw: "@0", t: "item", expected: [{ __type: "date", value: 0 }, []] },
|
|
95
|
+
{ name: "date positive", raw: "@1659578233", t: "item", expected: [{ __type: "date", value: 1659578233 }, []] },
|
|
96
|
+
{ name: "date negative", raw: "@-1659578233", t: "item", expected: [{ __type: "date", value: -1659578233 }, []] },
|
|
97
|
+
{ name: "date decimal", raw: "@1659578233.12", t: "item", must_fail: true },
|
|
98
|
+
{ name: "date too large", raw: "@1000000000000000", t: "item", must_fail: true },
|
|
99
|
+
{ name: "date empty", raw: "@", t: "item", must_fail: true },
|
|
100
|
+
{ name: "date sign only", raw: "@-", t: "item", must_fail: true },
|
|
101
|
+
{ name: "date non-digit", raw: "@abc", t: "item", must_fail: true },
|
|
102
|
+
// RFC 9651 Display String (§3.3.8)
|
|
103
|
+
{ name: "ascii display string", raw: '%"foo bar"', t: "item", expected: [{ __type: "displaystring", value: "foo bar" }, []] },
|
|
104
|
+
{ name: "non-ascii display string (lowercase escaping)", raw: '%"f%c3%bc%c3%bc"', t: "item", expected: [{ __type: "displaystring", value: "füü" }, []] },
|
|
105
|
+
{ name: "non-ascii display string (uppercase escaping)", raw: '%"f%C3%BC"', t: "item", must_fail: true },
|
|
106
|
+
{ name: "non-ascii display string (unescaped)", raw: '%"füü"', t: "item", must_fail: true },
|
|
107
|
+
{ name: "display string quoting", raw: '%"foo %22bar%22 \\ baz"', t: "item", expected: [{ __type: "displaystring", value: 'foo "bar" \\ baz' }, []] },
|
|
108
|
+
{ name: "bad display string utf-8", raw: '%"%c3%28"', t: "item", must_fail: true },
|
|
109
|
+
{ name: "bad display string hex", raw: '%"%g0%1w"', t: "item", must_fail: true },
|
|
110
|
+
{ name: "truncated display string escape", raw: '%"%"', t: "item", must_fail: true },
|
|
111
|
+
{ name: "unbalanced display string", raw: '%"foo', t: "item", must_fail: true },
|
|
112
|
+
{ name: "single-quoted display string", raw: "%'foo'", t: "item", must_fail: true },
|
|
89
113
|
];
|
|
90
114
|
|
|
91
115
|
function testConformance() {
|
|
@@ -135,6 +159,15 @@ function testDecimalTypePreserved() {
|
|
|
135
159
|
check("serialize: an integral JS number serializes as an Integer", SF.serialize({ value: 5, params: new Map() }, "item") === "5");
|
|
136
160
|
}
|
|
137
161
|
|
|
162
|
+
function testDisplayStringSurrogate() {
|
|
163
|
+
function code(fn) { try { fn(); return "NO-THROW"; } catch (e) { return e.code; } }
|
|
164
|
+
// A lone UTF-16 surrogate is not a valid Unicode string — serialize must
|
|
165
|
+
// fail rather than silently emit U+FFFD (RFC 9651 §4.1.10).
|
|
166
|
+
check("serialize: lone surrogate display string refused", code(function () { SF.serialize({ value: new SF.DisplayString("a\uD800b"), params: new Map() }, "item"); }) === "structured-fields/serialize");
|
|
167
|
+
// A valid astral code point (surrogate pair) serializes fine.
|
|
168
|
+
check("serialize: astral code point display string ok", SF.serialize({ value: new SF.DisplayString("\u{1F600}"), params: new Map() }, "item") === '%"%f0%9f%98%80"');
|
|
169
|
+
}
|
|
170
|
+
|
|
138
171
|
function testTypedError() {
|
|
139
172
|
function E(code, msg) { this.code = code; this.message = msg; }
|
|
140
173
|
E.prototype = Object.create(Error.prototype);
|
|
@@ -151,6 +184,8 @@ function testSurface() {
|
|
|
151
184
|
check("b.structuredFields.Token constructs a token", new b.structuredFields.Token("gzip").value === "gzip");
|
|
152
185
|
check("b.structuredFields.ByteSequence constructs a byte sequence", Buffer.isBuffer(new b.structuredFields.ByteSequence(Buffer.from("x")).value));
|
|
153
186
|
check("b.structuredFields.Decimal constructs a decimal", new b.structuredFields.Decimal(1.5).value === 1.5);
|
|
187
|
+
check("b.structuredFields.Date round-trips", b.structuredFields.serialize({ value: new b.structuredFields.Date(1659578233), params: new Map() }, "item") === "@1659578233");
|
|
188
|
+
check("b.structuredFields.DisplayString escapes non-ASCII", b.structuredFields.serialize({ value: new b.structuredFields.DisplayString("füü"), params: new Map() }, "item") === '%"f%c3%bc%c3%bc"');
|
|
154
189
|
}
|
|
155
190
|
|
|
156
191
|
async function run() {
|
|
@@ -158,6 +193,7 @@ async function run() {
|
|
|
158
193
|
testConformance();
|
|
159
194
|
testSerialize();
|
|
160
195
|
testDecimalTypePreserved();
|
|
196
|
+
testDisplayStringSurrogate();
|
|
161
197
|
testTypedError();
|
|
162
198
|
}
|
|
163
199
|
|
package/package.json
CHANGED