@blamejs/blamejs-shop 0.1.13 → 0.1.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2 -0
- package/lib/payment.js +252 -4
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +2 -0
- package/lib/vendor/blamejs/README.md +1 -0
- package/lib/vendor/blamejs/api-snapshot.json +18 -2
- package/lib/vendor/blamejs/lib/structured-fields.js +362 -0
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.12.54.json +18 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/structured-fields-codec.test.js +171 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.1.x
|
|
10
10
|
|
|
11
|
+
- v0.1.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
|
+
|
|
11
13
|
- 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.
|
|
12
14
|
|
|
13
15
|
- 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`.
|
package/lib/payment.js
CHANGED
|
@@ -43,6 +43,10 @@ var IDEMPOTENT_OPERATIONS = {
|
|
|
43
43
|
"subscription.create": true,
|
|
44
44
|
"subscription.update": true,
|
|
45
45
|
"subscription.cancel": true,
|
|
46
|
+
// PayPal adapter mutating operations (Orders v2).
|
|
47
|
+
"paypal_order.create": true,
|
|
48
|
+
"paypal_capture.create": true,
|
|
49
|
+
"paypal_refund.create": true,
|
|
46
50
|
};
|
|
47
51
|
|
|
48
52
|
// ---- validation -----------------------------------------------------------
|
|
@@ -502,17 +506,261 @@ function stripe(opts) {
|
|
|
502
506
|
};
|
|
503
507
|
}
|
|
504
508
|
|
|
505
|
-
|
|
509
|
+
// ---- PayPal adapter -------------------------------------------------------
|
|
510
|
+
//
|
|
511
|
+
// PayPal Orders v2 over `b.httpClient`. Two structural differences from the
|
|
512
|
+
// Stripe adapter: (1) every call needs an OAuth2 client-credentials access
|
|
513
|
+
// token, exchanged up front and cached until it nears expiry; (2) webhook
|
|
514
|
+
// verification is a server-to-server call to PayPal's own
|
|
515
|
+
// verify-webhook-signature API — PayPal has no offline-HMAC shape like
|
|
516
|
+
// Stripe's. Outbound goes through `b.httpClient` (SSRF-gated, retried); the
|
|
517
|
+
// shared `_runIdempotent` cache applies when `query` is wired.
|
|
518
|
+
var PAYPAL_API_BASE_LIVE = "https://api-m.paypal.com";
|
|
519
|
+
var PAYPAL_API_BASE_SANDBOX = "https://api-m.sandbox.paypal.com";
|
|
520
|
+
var PAYPAL_HTTP_TIMEOUT_MS = 15000;
|
|
521
|
+
var PAYPAL_TOKEN_SKEW_MS = C.TIME.minutes(2); // refresh this far before expiry
|
|
522
|
+
|
|
523
|
+
// PayPal rejects decimal places for these currencies; everything else is
|
|
524
|
+
// 2-decimal. Amounts cross the wire as decimal strings in MAJOR units.
|
|
525
|
+
var PAYPAL_ZERO_DECIMAL = { HUF: true, JPY: true, TWD: true };
|
|
526
|
+
|
|
527
|
+
function _paypalApiBase(opts) {
|
|
528
|
+
if (opts.apiBase) return opts.apiBase.replace(/\/$/, "");
|
|
529
|
+
return opts.sandbox ? PAYPAL_API_BASE_SANDBOX : PAYPAL_API_BASE_LIVE;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function _minorToDecimalString(minor, currency) {
|
|
533
|
+
var dec = PAYPAL_ZERO_DECIMAL[currency] ? 0 : 2;
|
|
534
|
+
var neg = minor < 0;
|
|
535
|
+
var s = String(Math.abs(minor));
|
|
536
|
+
if (dec === 0) return (neg ? "-" : "") + s;
|
|
537
|
+
while (s.length <= dec) s = "0" + s;
|
|
538
|
+
return (neg ? "-" : "") + s.slice(0, s.length - dec) + "." + s.slice(s.length - dec);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function _headerCI(headers, name) {
|
|
542
|
+
if (!headers) return undefined;
|
|
543
|
+
if (headers[name] != null) return headers[name];
|
|
544
|
+
var lower = name.toLowerCase();
|
|
545
|
+
for (var k in headers) {
|
|
546
|
+
if (Object.prototype.hasOwnProperty.call(headers, k) && k.toLowerCase() === lower) return headers[k];
|
|
547
|
+
}
|
|
548
|
+
return undefined;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
async function _paypalToken(opts, state) {
|
|
552
|
+
var now = state.now();
|
|
553
|
+
if (state.token && now < state.tokenExpiresAt) return state.token;
|
|
554
|
+
var httpClient = opts.httpClient || b.httpClient;
|
|
555
|
+
var basic = Buffer.from(opts.clientId + ":" + opts.secret).toString("base64");
|
|
556
|
+
var res = await httpClient.request({
|
|
557
|
+
method: "POST",
|
|
558
|
+
url: _paypalApiBase(opts) + "/v1/oauth2/token",
|
|
559
|
+
headers: {
|
|
560
|
+
"authorization": "Basic " + basic,
|
|
561
|
+
"accept": "application/json",
|
|
562
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
563
|
+
"user-agent": "blamejs-shop (zero-dep)",
|
|
564
|
+
},
|
|
565
|
+
body: "grant_type=client_credentials",
|
|
566
|
+
timeoutMs: opts.timeoutMs || PAYPAL_HTTP_TIMEOUT_MS,
|
|
567
|
+
});
|
|
568
|
+
var text = res.body && res.body.toString ? res.body.toString("utf8") : "";
|
|
569
|
+
var json; try { json = text.length ? JSON.parse(text) : {}; } catch (_e) { json = {}; }
|
|
570
|
+
if (res.statusCode < 200 || res.statusCode >= 300 || !json.access_token) {
|
|
571
|
+
var err = new Error("paypal: OAuth2 token exchange failed → HTTP " + res.statusCode +
|
|
572
|
+
(json && json.error_description ? " — " + json.error_description : ""));
|
|
573
|
+
err.code = "PAYPAL_AUTH_" + res.statusCode;
|
|
574
|
+
err.statusCode = res.statusCode;
|
|
575
|
+
throw err;
|
|
576
|
+
}
|
|
577
|
+
state.token = json.access_token;
|
|
578
|
+
var ttlMs = (typeof json.expires_in === "number" ? json.expires_in : 0) * 1000; // allow:raw-time-literal — PayPal expires_in is a runtime seconds value; *1000 → ms
|
|
579
|
+
state.tokenExpiresAt = now + Math.max(0, ttlMs - PAYPAL_TOKEN_SKEW_MS);
|
|
580
|
+
return state.token;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
async function _paypalCall(opts, state, method, path, bodyObj, requestId) {
|
|
584
|
+
var token = await _paypalToken(opts, state);
|
|
585
|
+
var headers = {
|
|
586
|
+
"authorization": "Bearer " + token,
|
|
587
|
+
"accept": "application/json",
|
|
588
|
+
"content-type": "application/json",
|
|
589
|
+
"user-agent": "blamejs-shop (zero-dep)",
|
|
590
|
+
};
|
|
591
|
+
if (requestId) headers["paypal-request-id"] = requestId;
|
|
592
|
+
var body = bodyObj != null ? JSON.stringify(bodyObj) : undefined;
|
|
593
|
+
if (body) headers["content-length"] = Buffer.byteLength(body, "utf8");
|
|
594
|
+
var httpClient = opts.httpClient || b.httpClient;
|
|
595
|
+
var res = await httpClient.request({
|
|
596
|
+
method: method,
|
|
597
|
+
url: _paypalApiBase(opts) + path,
|
|
598
|
+
headers: headers,
|
|
599
|
+
body: body,
|
|
600
|
+
timeoutMs: opts.timeoutMs || PAYPAL_HTTP_TIMEOUT_MS,
|
|
601
|
+
});
|
|
602
|
+
var text = res.body && res.body.toString ? res.body.toString("utf8") : "";
|
|
603
|
+
var json; try { json = text.length ? JSON.parse(text) : {}; } catch (_e) { json = { _raw: text }; }
|
|
604
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
605
|
+
var detail = json && (json.message || (json.details && json.details[0] && json.details[0].description)) || "";
|
|
606
|
+
var err = new Error("paypal: " + method + " " + path + " → HTTP " + res.statusCode + (detail ? " — " + detail : ""));
|
|
607
|
+
err.code = (json && json.name) || "PAYPAL_HTTP_" + res.statusCode;
|
|
608
|
+
err.statusCode = res.statusCode;
|
|
609
|
+
err.paypal = json || null;
|
|
610
|
+
throw err;
|
|
611
|
+
}
|
|
612
|
+
Object.defineProperty(json, "_paypalStatus", { value: res.statusCode, enumerable: false });
|
|
613
|
+
Object.defineProperty(json, "_paypalRawText", { value: text, enumerable: false });
|
|
614
|
+
return json;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function paypal(opts) {
|
|
506
618
|
opts = opts || {};
|
|
507
|
-
|
|
508
|
-
|
|
619
|
+
_assertSecret(opts.clientId, "clientId");
|
|
620
|
+
_assertSecret(opts.secret, "secret");
|
|
621
|
+
if (opts.query != null && typeof opts.query !== "function") {
|
|
622
|
+
throw new TypeError("payment: query must be a function (sql, params) => Promise<{ rows }>");
|
|
623
|
+
}
|
|
624
|
+
if (opts.now != null && typeof opts.now !== "function") {
|
|
625
|
+
throw new TypeError("payment: now must be a function returning current epoch ms");
|
|
626
|
+
}
|
|
627
|
+
var state = {
|
|
628
|
+
query: opts.query || null,
|
|
629
|
+
now: typeof opts.now === "function" ? opts.now : function () { return Date.now(); },
|
|
630
|
+
token: null,
|
|
631
|
+
tokenExpiresAt: 0,
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
function _maybeIdempotent(operation, idempotencyKey, requestObj, doCall) {
|
|
635
|
+
if (!state.query || idempotencyKey == null) return doCall();
|
|
636
|
+
return _runIdempotent(state, operation, idempotencyKey, requestObj, doCall);
|
|
509
637
|
}
|
|
510
|
-
|
|
638
|
+
|
|
639
|
+
function _amount(input, label) {
|
|
640
|
+
_positiveInt(input.amount_minor, "amount_minor");
|
|
641
|
+
if (typeof input.currency !== "string" || !/^[A-Z]{3}$/.test(input.currency)) {
|
|
642
|
+
throw new TypeError("payment." + label + ": currency must be a 3-letter uppercase ISO 4217 code");
|
|
643
|
+
}
|
|
644
|
+
return { currency_code: input.currency, value: _minorToDecimalString(input.amount_minor, input.currency) };
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
return {
|
|
648
|
+
name: "paypal",
|
|
649
|
+
|
|
650
|
+
// Create an Orders-v2 order (intent CAPTURE). The returned `id` is the
|
|
651
|
+
// PayPal order id the buyer approves; `captureOrder` finalizes it.
|
|
652
|
+
createOrder: function (input, idempotencyKey) {
|
|
653
|
+
if (!input || typeof input !== "object") throw new TypeError("payment.createOrder: input object required");
|
|
654
|
+
var bodyObj = {
|
|
655
|
+
intent: "CAPTURE",
|
|
656
|
+
purchase_units: [{
|
|
657
|
+
amount: _amount(input, "createOrder"),
|
|
658
|
+
custom_id: input.order_id || undefined,
|
|
659
|
+
invoice_id: input.invoice_id || undefined,
|
|
660
|
+
}],
|
|
661
|
+
};
|
|
662
|
+
if (input.return_url || input.cancel_url) {
|
|
663
|
+
bodyObj.payment_source = { paypal: { experience_context: {
|
|
664
|
+
return_url: input.return_url || undefined,
|
|
665
|
+
cancel_url: input.cancel_url || undefined,
|
|
666
|
+
} } };
|
|
667
|
+
}
|
|
668
|
+
var requestId = "order:" + (input.order_id || idempotencyKey || b.uuid.v7());
|
|
669
|
+
return _maybeIdempotent("paypal_order.create", idempotencyKey, { op: "createOrder", input: input }, function () {
|
|
670
|
+
return _paypalCall(opts, state, "POST", "/v2/checkout/orders", bodyObj, requestId);
|
|
671
|
+
});
|
|
672
|
+
},
|
|
673
|
+
|
|
674
|
+
// Capture an approved order. Returns the capture resource (the
|
|
675
|
+
// `purchase_units[0].payments.captures[0].id` is the capture id refunds
|
|
676
|
+
// reference).
|
|
677
|
+
captureOrder: function (orderId, idempotencyKey) {
|
|
678
|
+
if (typeof orderId !== "string" || !orderId.length) throw new TypeError("payment.captureOrder: orderId required");
|
|
679
|
+
var requestId = "capture:" + orderId + (idempotencyKey ? ":" + idempotencyKey : "");
|
|
680
|
+
return _maybeIdempotent("paypal_capture.create", idempotencyKey, { op: "captureOrder", orderId: orderId }, function () {
|
|
681
|
+
return _paypalCall(opts, state, "POST", "/v2/checkout/orders/" + encodeURIComponent(orderId) + "/capture", {}, requestId);
|
|
682
|
+
});
|
|
683
|
+
},
|
|
684
|
+
|
|
685
|
+
getOrder: function (orderId) {
|
|
686
|
+
if (typeof orderId !== "string" || !orderId.length) throw new TypeError("payment.getOrder: orderId required");
|
|
687
|
+
return _paypalCall(opts, state, "GET", "/v2/checkout/orders/" + encodeURIComponent(orderId), null, null);
|
|
688
|
+
},
|
|
689
|
+
|
|
690
|
+
// Refund a capture — full when no amount is given, partial with
|
|
691
|
+
// { amount_minor, currency }.
|
|
692
|
+
refund: function (input, idempotencyKey) {
|
|
693
|
+
if (!input || typeof input !== "object") throw new TypeError("payment.refund: input object required");
|
|
694
|
+
if (typeof input.capture_id !== "string" || !input.capture_id.length) {
|
|
695
|
+
throw new TypeError("payment.refund: capture_id required");
|
|
696
|
+
}
|
|
697
|
+
var bodyObj = {};
|
|
698
|
+
if (input.amount_minor != null) bodyObj.amount = _amount(input, "refund");
|
|
699
|
+
if (input.note_to_payer) bodyObj.note_to_payer = input.note_to_payer;
|
|
700
|
+
if (input.invoice_id) bodyObj.invoice_id = input.invoice_id;
|
|
701
|
+
// Multiple partial refunds on the SAME capture are legitimate + distinct,
|
|
702
|
+
// so the PayPal-Request-Id (PayPal's idempotency identity) must be unique
|
|
703
|
+
// per call by default — reusing `capture_id` alone would make PayPal
|
|
704
|
+
// replay the first refund instead of executing the next. A caller that
|
|
705
|
+
// wants a retry deduplicated passes an explicit idempotencyKey (or
|
|
706
|
+
// input.idempotency_suffix); otherwise a fresh uuid keeps each refund
|
|
707
|
+
// its own request. (createOrder / captureOrder stay stable on purpose —
|
|
708
|
+
// retrying those SHOULD be idempotent.)
|
|
709
|
+
var requestId = "refund:" + input.capture_id + ":" + (idempotencyKey || input.idempotency_suffix || b.uuid.v7());
|
|
710
|
+
return _maybeIdempotent("paypal_refund.create", idempotencyKey, { op: "refund", input: input }, function () {
|
|
711
|
+
return _paypalCall(opts, state, "POST",
|
|
712
|
+
"/v2/payments/captures/" + encodeURIComponent(input.capture_id) + "/refund", bodyObj, requestId);
|
|
713
|
+
});
|
|
714
|
+
},
|
|
715
|
+
|
|
716
|
+
// Verify an inbound webhook by calling PayPal's verify-webhook-signature
|
|
717
|
+
// API with the transmission headers + the configured webhook id + the
|
|
718
|
+
// event body. Returns { ok, event } on a SUCCESS verification status,
|
|
719
|
+
// { ok:false, reason } otherwise (drop-silent — never throws).
|
|
720
|
+
verifyWebhook: async function (headers, rawBody, vOpts) {
|
|
721
|
+
var webhookId = (vOpts && vOpts.webhookId) || opts.webhookId;
|
|
722
|
+
if (!webhookId) return { ok: false, reason: "no-webhook-id" };
|
|
723
|
+
var authAlgo = _headerCI(headers, "paypal-auth-algo");
|
|
724
|
+
var certUrl = _headerCI(headers, "paypal-cert-url");
|
|
725
|
+
var transmissionId = _headerCI(headers, "paypal-transmission-id");
|
|
726
|
+
var transmissionSig = _headerCI(headers, "paypal-transmission-sig");
|
|
727
|
+
var transmissionTime= _headerCI(headers, "paypal-transmission-time");
|
|
728
|
+
if (!authAlgo || !certUrl || !transmissionId || !transmissionSig || !transmissionTime) {
|
|
729
|
+
return { ok: false, reason: "missing-transmission-headers" };
|
|
730
|
+
}
|
|
731
|
+
var event;
|
|
732
|
+
try { event = typeof rawBody === "string" ? JSON.parse(rawBody) : rawBody; }
|
|
733
|
+
catch (_e) { return { ok: false, reason: "malformed-body" }; }
|
|
734
|
+
var verifyBody = {
|
|
735
|
+
auth_algo: authAlgo,
|
|
736
|
+
cert_url: certUrl,
|
|
737
|
+
transmission_id: transmissionId,
|
|
738
|
+
transmission_sig: transmissionSig,
|
|
739
|
+
transmission_time: transmissionTime,
|
|
740
|
+
webhook_id: webhookId,
|
|
741
|
+
webhook_event: event,
|
|
742
|
+
};
|
|
743
|
+
var res;
|
|
744
|
+
try { res = await _paypalCall(opts, state, "POST", "/v1/notifications/verify-webhook-signature", verifyBody, null); }
|
|
745
|
+
catch (e) { return { ok: false, reason: "verify-call-failed", error: e && e.message }; }
|
|
746
|
+
if (res && res.verification_status === "SUCCESS") return { ok: true, event: event };
|
|
747
|
+
return { ok: false, reason: "verification-status-" + ((res && res.verification_status) || "unknown") };
|
|
748
|
+
},
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function create(opts) {
|
|
753
|
+
opts = opts || {};
|
|
754
|
+
var adapter = opts.adapter || "stripe";
|
|
755
|
+
if (adapter === "stripe") return stripe(opts);
|
|
756
|
+
if (adapter === "paypal") return paypal(opts);
|
|
757
|
+
throw new TypeError("payment.create: unknown adapter " + JSON.stringify(opts.adapter) + " — 'stripe' and 'paypal' are supported");
|
|
511
758
|
}
|
|
512
759
|
|
|
513
760
|
module.exports = {
|
|
514
761
|
create: create,
|
|
515
762
|
stripe: stripe,
|
|
763
|
+
paypal: paypal,
|
|
516
764
|
STRIPE_WEBHOOK_TOLERANCE: STRIPE_WEBHOOK_TOLERANCE,
|
|
517
765
|
IDEMPOTENCY_TTL_MS: IDEMPOTENCY_TTL_MS,
|
|
518
766
|
// Exposed for tests + Worker to share form-encoding shape.
|
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.54",
|
|
7
|
+
"tag": "v0.12.54",
|
|
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.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
|
+
|
|
11
13
|
- 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).
|
|
12
14
|
|
|
13
15
|
- 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.
|
|
@@ -98,6 +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 8941 codec (`b.structuredFields.parse` / `serialize`): Items / Lists / Dictionaries, Inner Lists, Parameters, and all bare-item types (Integer / Decimal / String / Token / Byte Sequence / Boolean) with strict grammar + range enforcement — the parser behind Content-Digest, Client Hints, and HTTP Message Signatures
|
|
101
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`)
|
|
102
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`)
|
|
103
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.54",
|
|
4
|
+
"createdAt": "2026-05-25T18:34:52.229Z",
|
|
5
5
|
"exports": {
|
|
6
6
|
"a2a": {
|
|
7
7
|
"type": "object",
|
|
@@ -48694,14 +48694,30 @@
|
|
|
48694
48694
|
"structuredFields": {
|
|
48695
48695
|
"type": "object",
|
|
48696
48696
|
"members": {
|
|
48697
|
+
"ByteSequence": {
|
|
48698
|
+
"type": "function",
|
|
48699
|
+
"arity": 1
|
|
48700
|
+
},
|
|
48701
|
+
"Token": {
|
|
48702
|
+
"type": "function",
|
|
48703
|
+
"arity": 1
|
|
48704
|
+
},
|
|
48697
48705
|
"containsControlBytes": {
|
|
48698
48706
|
"type": "function",
|
|
48699
48707
|
"arity": 2
|
|
48700
48708
|
},
|
|
48709
|
+
"parse": {
|
|
48710
|
+
"type": "function",
|
|
48711
|
+
"arity": 3
|
|
48712
|
+
},
|
|
48701
48713
|
"refuseControlBytes": {
|
|
48702
48714
|
"type": "function",
|
|
48703
48715
|
"arity": 2
|
|
48704
48716
|
},
|
|
48717
|
+
"serialize": {
|
|
48718
|
+
"type": "function",
|
|
48719
|
+
"arity": 3
|
|
48720
|
+
},
|
|
48705
48721
|
"splitTopLevel": {
|
|
48706
48722
|
"type": "function",
|
|
48707
48723
|
"arity": 2
|
|
@@ -236,9 +236,371 @@ function containsControlBytes(value, opts) {
|
|
|
236
236
|
return false;
|
|
237
237
|
}
|
|
238
238
|
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
// Full RFC 8941 codec (parse + serialize). The helpers above are the
|
|
241
|
+
// quote-aware splitters individual parsers reach for; the codec below is
|
|
242
|
+
// the complete grammar — Items, Lists, Dictionaries, Inner Lists,
|
|
243
|
+
// Parameters, and every bare-item type.
|
|
244
|
+
//
|
|
245
|
+
// Value model:
|
|
246
|
+
// bare item → number (Integer) | SfDecimal | string | boolean | SfToken | SfByteSequence
|
|
247
|
+
// item → { value: bareItem, params: Map<string, bareItem> }
|
|
248
|
+
// inner list → { items: item[], params: Map<string, bareItem> }
|
|
249
|
+
// list → (item | innerList)[]
|
|
250
|
+
// dictionary → Map<string, item | innerList>
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
// RFC 8941 §3.3.4 Token / §3.3.5 Byte Sequence are wrapped so they stay
|
|
254
|
+
// distinct from plain strings on both parse output and serialize input.
|
|
255
|
+
function SfToken(value) {
|
|
256
|
+
if (!(this instanceof SfToken)) return new SfToken(value);
|
|
257
|
+
this.value = String(value);
|
|
258
|
+
}
|
|
259
|
+
function SfByteSequence(value) {
|
|
260
|
+
if (!(this instanceof SfByteSequence)) return new SfByteSequence(value);
|
|
261
|
+
this.value = Buffer.isBuffer(value) ? value : Buffer.from(value);
|
|
262
|
+
}
|
|
263
|
+
// A Decimal preserves its type across parse → serialize even when its
|
|
264
|
+
// value is numerically integral ("1.0" must not serialize back to "1").
|
|
265
|
+
// A plain JS number serializes as an Integer when integral, a Decimal
|
|
266
|
+
// otherwise; wrap in SfDecimal to force the Decimal form.
|
|
267
|
+
function SfDecimal(value) {
|
|
268
|
+
if (!(this instanceof SfDecimal)) return new SfDecimal(value);
|
|
269
|
+
this.value = Number(value);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function _sfErr(opts) {
|
|
273
|
+
if (opts && typeof opts.ErrorClass === "function") {
|
|
274
|
+
return function (code, msg) { return opts.useNativeError === true ? new opts.ErrorClass(msg) : new opts.ErrorClass(code, msg); };
|
|
275
|
+
}
|
|
276
|
+
return function (code, msg) { var e = new Error(msg); e.code = code; return e; };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
var INT_MAX = 999999999999999; // 15 digits (RFC 8941 §3.3.1)
|
|
280
|
+
var INT_MIN = -999999999999999;
|
|
281
|
+
function _isDigit(c) { return c >= "0" && c <= "9"; }
|
|
282
|
+
function _isLcAlpha(c) { return c >= "a" && c <= "z"; }
|
|
283
|
+
function _isAlpha(c) { return (c >= "A" && c <= "Z") || (c >= "a" && c <= "z"); }
|
|
284
|
+
function _isTchar(c) { return _isAlpha(c) || _isDigit(c) || "!#$%&'*+-.^_`|~".indexOf(c) !== -1; }
|
|
285
|
+
function _isKeyChar(c) { return _isLcAlpha(c) || _isDigit(c) || c === "_" || c === "-" || c === "." || c === "*"; }
|
|
286
|
+
|
|
287
|
+
function _parseNumber(cx, E) {
|
|
288
|
+
var sign = 1, type = "integer", num = "";
|
|
289
|
+
if (cx.s.charAt(cx.i) === "-") { sign = -1; cx.i += 1; }
|
|
290
|
+
if (cx.i >= cx.s.length || !_isDigit(cx.s.charAt(cx.i))) throw E("structured-fields/parse", "expected a digit at index " + cx.i);
|
|
291
|
+
for (;;) {
|
|
292
|
+
if (cx.i >= cx.s.length) break;
|
|
293
|
+
var c = cx.s.charAt(cx.i);
|
|
294
|
+
if (_isDigit(c)) { num += c; cx.i += 1; }
|
|
295
|
+
else if (type === "integer" && c === ".") {
|
|
296
|
+
if (num.length > 12) throw E("structured-fields/parse", "integer part of a decimal exceeds 12 digits"); // allow:raw-byte-literal — RFC 8941 §4.2.4 decimal integer-part cap
|
|
297
|
+
num += "."; type = "decimal"; cx.i += 1;
|
|
298
|
+
} else break;
|
|
299
|
+
if (type === "integer" && num.length > 15) throw E("structured-fields/parse", "integer exceeds 15 digits"); // allow:raw-byte-literal — §3.3.1 integer digit cap
|
|
300
|
+
if (type === "decimal" && num.length > 16) throw E("structured-fields/parse", "decimal exceeds the digit limit"); // allow:raw-byte-literal — 12 int + "." + 3 frac
|
|
301
|
+
}
|
|
302
|
+
if (type === "integer") return sign * parseInt(num, 10);
|
|
303
|
+
if (num.charAt(num.length - 1) === ".") throw E("structured-fields/parse", "decimal must not end with '.'");
|
|
304
|
+
if (num.length - num.indexOf(".") - 1 > 3) throw E("structured-fields/parse", "decimal fraction exceeds 3 digits");
|
|
305
|
+
return new SfDecimal(sign * parseFloat(num));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function _parseString(cx, E) {
|
|
309
|
+
cx.i += 1; // opening DQUOTE
|
|
310
|
+
var out = "";
|
|
311
|
+
while (cx.i < cx.s.length) {
|
|
312
|
+
var c = cx.s.charAt(cx.i); cx.i += 1;
|
|
313
|
+
if (c === "\\") {
|
|
314
|
+
if (cx.i >= cx.s.length) throw E("structured-fields/parse", "trailing backslash in string");
|
|
315
|
+
var n = cx.s.charAt(cx.i); cx.i += 1;
|
|
316
|
+
if (n !== "\\" && n !== "\"") throw E("structured-fields/parse", "invalid backslash escape in string");
|
|
317
|
+
out += n;
|
|
318
|
+
} else if (c === "\"") { return out; }
|
|
319
|
+
else {
|
|
320
|
+
var cc = c.charCodeAt(0);
|
|
321
|
+
if (cc < 0x20 || cc > 0x7e) throw E("structured-fields/parse", "non-printable character in string"); // allow:raw-byte-literal — RFC 8941 §4.2.5 printable-ASCII range
|
|
322
|
+
out += c;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
throw E("structured-fields/parse", "unterminated string");
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function _parseByteSeq(cx, E) {
|
|
329
|
+
cx.i += 1; // opening ":"
|
|
330
|
+
var start = cx.i;
|
|
331
|
+
while (cx.i < cx.s.length && cx.s.charAt(cx.i) !== ":") cx.i += 1;
|
|
332
|
+
if (cx.i >= cx.s.length) throw E("structured-fields/parse", "unterminated byte sequence");
|
|
333
|
+
var b64 = cx.s.slice(start, cx.i); cx.i += 1; // closing ":"
|
|
334
|
+
// RFC 8941 §4.2.7 synthesizes padding, so an unpadded value like
|
|
335
|
+
// `:aGVsbG8:` is valid input. Pad an unpadded value to a base64
|
|
336
|
+
// quantum, then require the decoded bytes to re-encode to exactly that
|
|
337
|
+
// padded text — rejecting stray characters, misplaced "=" padding, and
|
|
338
|
+
// non-zero trailing bits (Node's decoder is otherwise permissive).
|
|
339
|
+
var padded = b64.indexOf("=") === -1 ? b64 + "====".slice(0, (4 - (b64.length % 4)) % 4) : b64;
|
|
340
|
+
var buf = Buffer.from(padded, "base64");
|
|
341
|
+
if (buf.toString("base64") !== padded) throw E("structured-fields/parse", "byte sequence is not valid base64");
|
|
342
|
+
return new SfByteSequence(buf);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function _parseBoolean(cx, E) {
|
|
346
|
+
cx.i += 1; // "?"
|
|
347
|
+
var c = cx.s.charAt(cx.i); cx.i += 1;
|
|
348
|
+
if (c === "1") return true;
|
|
349
|
+
if (c === "0") return false;
|
|
350
|
+
throw E("structured-fields/parse", "boolean must be ?0 or ?1");
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function _parseToken(cx) {
|
|
354
|
+
var start = cx.i; cx.i += 1; // first char already ALPHA / "*"
|
|
355
|
+
while (cx.i < cx.s.length) {
|
|
356
|
+
var c = cx.s.charAt(cx.i);
|
|
357
|
+
if (_isTchar(c) || c === ":" || c === "/") cx.i += 1; else break;
|
|
358
|
+
}
|
|
359
|
+
return new SfToken(cx.s.slice(start, cx.i));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function _parseBareItem(cx, E) {
|
|
363
|
+
var c = cx.s.charAt(cx.i);
|
|
364
|
+
if (c === "-" || _isDigit(c)) return _parseNumber(cx, E);
|
|
365
|
+
if (c === "\"") return _parseString(cx, E);
|
|
366
|
+
if (c === ":") return _parseByteSeq(cx, E);
|
|
367
|
+
if (c === "?") return _parseBoolean(cx, E);
|
|
368
|
+
if (c === "*" || _isAlpha(c)) return _parseToken(cx);
|
|
369
|
+
throw E("structured-fields/parse", "unexpected character '" + (c || "<eof>") + "' at index " + cx.i);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function _parseKey(cx, E) {
|
|
373
|
+
var c = cx.s.charAt(cx.i);
|
|
374
|
+
if (!(c === "*" || _isLcAlpha(c))) throw E("structured-fields/parse", "key must start with lcalpha or '*'");
|
|
375
|
+
var start = cx.i; cx.i += 1;
|
|
376
|
+
while (cx.i < cx.s.length && _isKeyChar(cx.s.charAt(cx.i))) cx.i += 1;
|
|
377
|
+
return cx.s.slice(start, cx.i);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function _parseParams(cx, E) {
|
|
381
|
+
var params = new Map();
|
|
382
|
+
while (cx.i < cx.s.length && cx.s.charAt(cx.i) === ";") {
|
|
383
|
+
cx.i += 1;
|
|
384
|
+
while (cx.s.charAt(cx.i) === " ") cx.i += 1;
|
|
385
|
+
var key = _parseKey(cx, E);
|
|
386
|
+
var val = true;
|
|
387
|
+
if (cx.s.charAt(cx.i) === "=") { cx.i += 1; val = _parseBareItem(cx, E); }
|
|
388
|
+
params.set(key, val); // last value wins (RFC 8941 §4.2.3.2)
|
|
389
|
+
}
|
|
390
|
+
return params;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function _parseItem(cx, E) {
|
|
394
|
+
var value = _parseBareItem(cx, E);
|
|
395
|
+
return { value: value, params: _parseParams(cx, E) };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function _parseInnerList(cx, E) {
|
|
399
|
+
cx.i += 1; // "("
|
|
400
|
+
var items = [];
|
|
401
|
+
for (;;) {
|
|
402
|
+
while (cx.s.charAt(cx.i) === " ") cx.i += 1;
|
|
403
|
+
if (cx.i >= cx.s.length) throw E("structured-fields/parse", "unterminated inner list");
|
|
404
|
+
if (cx.s.charAt(cx.i) === ")") { cx.i += 1; return { items: items, params: _parseParams(cx, E) }; }
|
|
405
|
+
items.push(_parseItem(cx, E));
|
|
406
|
+
var c = cx.s.charAt(cx.i);
|
|
407
|
+
if (c !== " " && c !== ")") throw E("structured-fields/parse", "inner-list items must be space-separated");
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function _parseItemOrInnerList(cx, E) {
|
|
412
|
+
return cx.s.charAt(cx.i) === "(" ? _parseInnerList(cx, E) : _parseItem(cx, E);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function _skipOWS(cx) { while (cx.s.charAt(cx.i) === " " || cx.s.charAt(cx.i) === "\t") cx.i += 1; }
|
|
416
|
+
|
|
417
|
+
function _parseList(cx, E) {
|
|
418
|
+
var members = [];
|
|
419
|
+
if (cx.i >= cx.s.length) return members;
|
|
420
|
+
for (;;) {
|
|
421
|
+
members.push(_parseItemOrInnerList(cx, E));
|
|
422
|
+
_skipOWS(cx);
|
|
423
|
+
if (cx.i >= cx.s.length) return members;
|
|
424
|
+
if (cx.s.charAt(cx.i) !== ",") throw E("structured-fields/parse", "expected ',' between list members");
|
|
425
|
+
cx.i += 1; _skipOWS(cx);
|
|
426
|
+
if (cx.i >= cx.s.length) throw E("structured-fields/parse", "trailing comma in list");
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function _parseDict(cx, E) {
|
|
431
|
+
var dict = new Map();
|
|
432
|
+
if (cx.i >= cx.s.length) return dict;
|
|
433
|
+
for (;;) {
|
|
434
|
+
var key = _parseKey(cx, E);
|
|
435
|
+
var member;
|
|
436
|
+
if (cx.s.charAt(cx.i) === "=") { cx.i += 1; member = _parseItemOrInnerList(cx, E); }
|
|
437
|
+
else { member = { value: true, params: _parseParams(cx, E) }; }
|
|
438
|
+
dict.set(key, member); // last key wins (RFC 8941 §4.2.2)
|
|
439
|
+
_skipOWS(cx);
|
|
440
|
+
if (cx.i >= cx.s.length) return dict;
|
|
441
|
+
if (cx.s.charAt(cx.i) !== ",") throw E("structured-fields/parse", "expected ',' between dictionary members");
|
|
442
|
+
cx.i += 1; _skipOWS(cx);
|
|
443
|
+
if (cx.i >= cx.s.length) throw E("structured-fields/parse", "trailing comma in dictionary");
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* @primitive b.structuredFields.parse
|
|
449
|
+
* @signature b.structuredFields.parse(input, type, opts?)
|
|
450
|
+
* @since 0.12.54
|
|
451
|
+
* @status stable
|
|
452
|
+
* @related b.structuredFields.serialize, b.structuredFields.splitTopLevel
|
|
453
|
+
*
|
|
454
|
+
* Parse an RFC 8941 Structured Field value. <code>type</code> is
|
|
455
|
+
* <code>"item"</code>, <code>"list"</code>, or <code>"dictionary"</code>.
|
|
456
|
+
* Returns the value model: an item is <code>{ value, params }</code>
|
|
457
|
+
* (params is a <code>Map</code>); a list is an array of items / inner
|
|
458
|
+
* lists; a dictionary is a <code>Map</code>. Tokens and byte sequences
|
|
459
|
+
* come back as <code>SfToken</code> / <code>SfByteSequence</code>
|
|
460
|
+
* instances so they stay distinct from plain strings. Strictly enforces
|
|
461
|
+
* the grammar — integer / decimal digit caps, printable-ASCII strings,
|
|
462
|
+
* canonical base64, no trailing characters — and throws on any malformed
|
|
463
|
+
* input (pass <code>opts.ErrorClass</code> for a typed error).
|
|
464
|
+
*
|
|
465
|
+
* @opts
|
|
466
|
+
* ErrorClass?: Function, // typed error class (default: native Error with .code)
|
|
467
|
+
*
|
|
468
|
+
* @example
|
|
469
|
+
* b.structuredFields.parse("a=1, b=(x y);q=2", "dictionary");
|
|
470
|
+
* // → Map { "a" => { value: 1, params: Map{} },
|
|
471
|
+
* // "b" => { items: [...], params: Map{ "q" => 2 } } }
|
|
472
|
+
*/
|
|
473
|
+
function parse(input, type, opts) {
|
|
474
|
+
var E = _sfErr(opts);
|
|
475
|
+
if (typeof input !== "string") throw E("structured-fields/bad-input", "structuredFields.parse: input must be a string");
|
|
476
|
+
var cx = { s: input, i: 0 };
|
|
477
|
+
while (cx.s.charAt(cx.i) === " ") cx.i += 1; // §4.2 discard leading SP
|
|
478
|
+
var out;
|
|
479
|
+
if (type === "item") out = _parseItem(cx, E);
|
|
480
|
+
else if (type === "list") out = _parseList(cx, E);
|
|
481
|
+
else if (type === "dictionary") out = _parseDict(cx, E);
|
|
482
|
+
else throw E("structured-fields/bad-type", "structuredFields.parse: type must be 'item' | 'list' | 'dictionary'");
|
|
483
|
+
while (cx.s.charAt(cx.i) === " ") cx.i += 1; // §4.2 discard trailing SP
|
|
484
|
+
if (cx.i !== cx.s.length) throw E("structured-fields/parse", "trailing characters after the field value");
|
|
485
|
+
return out;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function _serDecimal(v, E) {
|
|
489
|
+
if (!isFinite(v)) throw E("structured-fields/serialize", "cannot serialize a non-finite decimal");
|
|
490
|
+
var n = Math.round(v * 1000) / 1000; // allow:raw-byte-literal allow:raw-time-literal — RFC 8941 §4.1.5 decimal scale 10^3 (3 fractional digits), not a size or duration
|
|
491
|
+
if (Math.abs(Math.trunc(n)).toString().length > 12) throw E("structured-fields/serialize", "decimal integer part exceeds 12 digits"); // allow:raw-byte-literal — §4.1.5 cap
|
|
492
|
+
var s = n.toString();
|
|
493
|
+
if (s.indexOf(".") === -1) s += ".0"; // a Decimal must carry a fractional part
|
|
494
|
+
return s;
|
|
495
|
+
}
|
|
496
|
+
function _serBareItem(v, E) {
|
|
497
|
+
if (v === true) return "?1";
|
|
498
|
+
if (v === false) return "?0";
|
|
499
|
+
if (v instanceof SfDecimal) return _serDecimal(v.value, E);
|
|
500
|
+
if (typeof v === "number") {
|
|
501
|
+
if (!isFinite(v)) throw E("structured-fields/serialize", "cannot serialize a non-finite number");
|
|
502
|
+
if (Number.isInteger(v)) {
|
|
503
|
+
if (v > INT_MAX || v < INT_MIN) throw E("structured-fields/serialize", "integer out of RFC 8941 range");
|
|
504
|
+
return String(v);
|
|
505
|
+
}
|
|
506
|
+
return _serDecimal(v, E); // a fractional JS number serializes as a Decimal
|
|
507
|
+
}
|
|
508
|
+
if (typeof v === "string") {
|
|
509
|
+
var out = "\"";
|
|
510
|
+
for (var i = 0; i < v.length; i += 1) {
|
|
511
|
+
var c = v.charAt(i), cc = v.charCodeAt(i);
|
|
512
|
+
if (cc < 0x20 || cc > 0x7e) throw E("structured-fields/serialize", "string contains a non-printable character"); // allow:raw-byte-literal — §4.1.6 printable-ASCII range
|
|
513
|
+
if (c === "\\" || c === "\"") out += "\\";
|
|
514
|
+
out += c;
|
|
515
|
+
}
|
|
516
|
+
return out + "\"";
|
|
517
|
+
}
|
|
518
|
+
if (v instanceof SfToken) {
|
|
519
|
+
var t = v.value;
|
|
520
|
+
if (t.length === 0 || !(t.charAt(0) === "*" || _isAlpha(t.charAt(0)))) throw E("structured-fields/serialize", "invalid token");
|
|
521
|
+
for (var j = 1; j < t.length; j += 1) { var tc = t.charAt(j); if (!(_isTchar(tc) || tc === ":" || tc === "/")) throw E("structured-fields/serialize", "invalid token character"); }
|
|
522
|
+
return t;
|
|
523
|
+
}
|
|
524
|
+
if (v instanceof SfByteSequence) return ":" + v.value.toString("base64") + ":";
|
|
525
|
+
throw E("structured-fields/serialize", "unsupported bare-item type");
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function _serParams(params, E) {
|
|
529
|
+
if (!params) return "";
|
|
530
|
+
var out = "";
|
|
531
|
+
params.forEach(function (val, key) {
|
|
532
|
+
out += ";" + _serKey(key, E);
|
|
533
|
+
if (val !== true) out += "=" + _serBareItem(val, E);
|
|
534
|
+
});
|
|
535
|
+
return out;
|
|
536
|
+
}
|
|
537
|
+
function _serKey(key, E) {
|
|
538
|
+
if (typeof key !== "string" || key.length === 0 || !(key.charAt(0) === "*" || _isLcAlpha(key.charAt(0)))) throw E("structured-fields/serialize", "invalid parameter/dictionary key");
|
|
539
|
+
for (var i = 1; i < key.length; i += 1) { if (!_isKeyChar(key.charAt(i))) throw E("structured-fields/serialize", "invalid key character"); }
|
|
540
|
+
return key;
|
|
541
|
+
}
|
|
542
|
+
function _serItem(item, E) { return _serBareItem(item.value, E) + _serParams(item.params, E); }
|
|
543
|
+
function _serMember(m, E) {
|
|
544
|
+
if (m && Array.isArray(m.items)) {
|
|
545
|
+
return "(" + m.items.map(function (it) { return _serItem(it, E); }).join(" ") + ")" + _serParams(m.params, E);
|
|
546
|
+
}
|
|
547
|
+
return _serItem(m, E);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* @primitive b.structuredFields.serialize
|
|
552
|
+
* @signature b.structuredFields.serialize(value, type, opts?)
|
|
553
|
+
* @since 0.12.54
|
|
554
|
+
* @status stable
|
|
555
|
+
* @related b.structuredFields.parse
|
|
556
|
+
*
|
|
557
|
+
* Serialize a value model back to an RFC 8941 field value (the inverse
|
|
558
|
+
* of <code>parse</code>). <code>type</code> is <code>"item"</code>,
|
|
559
|
+
* <code>"list"</code>, or <code>"dictionary"</code>. Numbers serialize as
|
|
560
|
+
* Integers when integral and Decimals (rounded to 3 fractional digits)
|
|
561
|
+
* otherwise; wrap Tokens / byte strings in <code>SfToken</code> /
|
|
562
|
+
* <code>SfByteSequence</code>. Throws on values outside the RFC's ranges
|
|
563
|
+
* or grammar (out-of-range integers, non-printable string characters,
|
|
564
|
+
* invalid tokens / keys).
|
|
565
|
+
*
|
|
566
|
+
* @opts
|
|
567
|
+
* ErrorClass?: Function, // typed error class (default: native Error with .code)
|
|
568
|
+
*
|
|
569
|
+
* @example
|
|
570
|
+
* var sf = b.structuredFields;
|
|
571
|
+
* sf.serialize({ value: new sf.Token("gzip"), params: new Map([["q", 1]]) }, "item");
|
|
572
|
+
* // → "gzip;q=1"
|
|
573
|
+
*/
|
|
574
|
+
function serialize(value, type, opts) {
|
|
575
|
+
var E = _sfErr(opts);
|
|
576
|
+
if (type === "item") {
|
|
577
|
+
if (!value || typeof value !== "object" || !("value" in value)) throw E("structured-fields/serialize", "item must be { value, params }");
|
|
578
|
+
return _serItem(value, E);
|
|
579
|
+
}
|
|
580
|
+
if (type === "list") {
|
|
581
|
+
if (!Array.isArray(value)) throw E("structured-fields/serialize", "list must be an array");
|
|
582
|
+
return value.map(function (m) { return _serMember(m, E); }).join(", ");
|
|
583
|
+
}
|
|
584
|
+
if (type === "dictionary") {
|
|
585
|
+
var entries = value instanceof Map ? Array.from(value.entries()) : (value && typeof value === "object" ? Object.keys(value).map(function (k) { return [k, value[k]]; }) : null);
|
|
586
|
+
if (!entries) throw E("structured-fields/serialize", "dictionary must be a Map or object");
|
|
587
|
+
return entries.map(function (e) {
|
|
588
|
+
var key = _serKey(e[0], E), m = e[1];
|
|
589
|
+
if (m && !Array.isArray(m.items) && m.value === true) return key + _serParams(m.params, E); // bare-true member omits "=?1"
|
|
590
|
+
return key + "=" + _serMember(m, E);
|
|
591
|
+
}).join(", ");
|
|
592
|
+
}
|
|
593
|
+
throw E("structured-fields/bad-type", "structuredFields.serialize: type must be 'item' | 'list' | 'dictionary'");
|
|
594
|
+
}
|
|
595
|
+
|
|
239
596
|
module.exports = {
|
|
240
597
|
splitTopLevel: splitTopLevel,
|
|
241
598
|
refuseControlBytes: refuseControlBytes,
|
|
242
599
|
containsControlBytes: containsControlBytes,
|
|
243
600
|
unquoteSfString: unquoteSfString,
|
|
601
|
+
parse: parse,
|
|
602
|
+
serialize: serialize,
|
|
603
|
+
Token: SfToken,
|
|
604
|
+
ByteSequence: SfByteSequence,
|
|
605
|
+
Decimal: SfDecimal,
|
|
244
606
|
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../scripts/release-notes-schema.json",
|
|
3
|
+
"version": "0.12.54",
|
|
4
|
+
"date": "2026-05-25",
|
|
5
|
+
"headline": "`b.structuredFields.parse` / `serialize` — full RFC 8941 Structured Fields codec",
|
|
6
|
+
"summary": "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.",
|
|
7
|
+
"sections": [
|
|
8
|
+
{
|
|
9
|
+
"heading": "Added",
|
|
10
|
+
"items": [
|
|
11
|
+
{
|
|
12
|
+
"title": "`b.structuredFields.parse(input, type, opts?)` / `serialize(value, type, opts?)` / `Token` / `ByteSequence`",
|
|
13
|
+
"body": "`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."
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|
|
17
|
+
]
|
|
18
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Layer 0 — b.structuredFields full RFC 8941 codec (parse + serialize).
|
|
4
|
+
* The oracle is a curated set of the official httpwg/structured-field-tests
|
|
5
|
+
* conformance vectors (the same JSON the spec authors maintain): each
|
|
6
|
+
* `raw` parses to the published `expected` value model, each `must_fail`
|
|
7
|
+
* case is rejected, and every passing value round-trips through serialize.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
var b = require("../../index");
|
|
11
|
+
var helpers = require("../helpers");
|
|
12
|
+
var check = helpers.check;
|
|
13
|
+
var SF = b.structuredFields;
|
|
14
|
+
|
|
15
|
+
// --- httpwg expected-value normaliser (their format → comparable JSON) ---
|
|
16
|
+
function b32(buf) {
|
|
17
|
+
var A = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567", bits = 0, val = 0, out = "";
|
|
18
|
+
for (var i = 0; i < buf.length; i++) { val = (val << 8) | buf[i]; bits += 8; while (bits >= 5) { out += A[(val >> (bits - 5)) & 31]; bits -= 5; } }
|
|
19
|
+
if (bits > 0) out += A[(val << (5 - bits)) & 31];
|
|
20
|
+
while (out.length % 8 !== 0) out += "=";
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
function mineVal(v) {
|
|
24
|
+
if (v instanceof SF.Token) return { token: v.value };
|
|
25
|
+
if (v instanceof SF.ByteSequence) return { binary: b32(v.value) };
|
|
26
|
+
if (v instanceof SF.Decimal) return v.value; // compare a Decimal numerically (httpwg uses plain numbers)
|
|
27
|
+
return v;
|
|
28
|
+
}
|
|
29
|
+
function mineParams(map) { var o = []; map.forEach(function (v, k) { o.push([k, mineVal(v)]); }); return o; }
|
|
30
|
+
function mineItem(it) { return [mineVal(it.value), mineParams(it.params)]; }
|
|
31
|
+
function mineMember(m) { return Array.isArray(m.items) ? [m.items.map(mineItem), mineParams(m.params)] : mineItem(m); }
|
|
32
|
+
function mine(out, type) {
|
|
33
|
+
if (type === "item") return mineItem(out);
|
|
34
|
+
if (type === "list") return out.map(mineMember);
|
|
35
|
+
var o = []; out.forEach(function (m, k) { o.push([k, mineMember(m)]); }); return o;
|
|
36
|
+
}
|
|
37
|
+
function httpVal(v) {
|
|
38
|
+
if (v && v.__type === "token") return { token: v.value };
|
|
39
|
+
if (v && v.__type === "binary") return { binary: v.value };
|
|
40
|
+
return v;
|
|
41
|
+
}
|
|
42
|
+
function httpParams(arr) { return arr.map(function (p) { return [p[0], httpVal(p[1])]; }); }
|
|
43
|
+
function httpItem(it) { return [httpVal(it[0]), httpParams(it[1])]; }
|
|
44
|
+
function httpMember(m) { return Array.isArray(m[0]) ? [m[0].map(httpItem), httpParams(m[1])] : httpItem(m); }
|
|
45
|
+
function http(exp, type) {
|
|
46
|
+
if (type === "item") return httpItem(exp);
|
|
47
|
+
if (type === "list") return exp.map(httpMember);
|
|
48
|
+
return exp.map(function (e) { return [e[0], httpMember(e[1])]; });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Curated cases from httpwg/structured-field-tests (number, string, token,
|
|
52
|
+
// boolean, binary, item, list, dictionary, param-list).
|
|
53
|
+
var CASES = [
|
|
54
|
+
{ name: "basic integer", raw: "42", t: "item", expected: [42, []] },
|
|
55
|
+
{ name: "negative integer", raw: "-42", t: "item", expected: [-42, []] },
|
|
56
|
+
{ name: "negative zero", raw: "-0", t: "item", expected: [0, []] },
|
|
57
|
+
{ name: "basic decimal", raw: "1.5", t: "item", expected: [1.5, []] },
|
|
58
|
+
{ name: "negative decimal", raw: "-1.5", t: "item", expected: [-1.5, []] },
|
|
59
|
+
{ name: "too many int digits", raw: "1111111111111111", t: "item", must_fail: true },
|
|
60
|
+
{ name: "trailing decimal point", raw: "1.", t: "item", must_fail: true },
|
|
61
|
+
{ name: "too many frac digits", raw: "1.1234", t: "item", must_fail: true },
|
|
62
|
+
{ name: "basic string", raw: '"foo bar"', t: "item", expected: ["foo bar", []] },
|
|
63
|
+
{ name: "empty string", raw: '""', t: "item", expected: ["", []] },
|
|
64
|
+
{ name: "escaped quote", raw: '"b\\"a"', t: "item", expected: ['b"a', []] },
|
|
65
|
+
{ name: "unterminated string", raw: '"foo', t: "item", must_fail: true },
|
|
66
|
+
{ name: "basic token", raw: "a_b-c.d3:f%00/*", t: "item", expected: [{ __type: "token", value: "a_b-c.d3:f%00/*" }, []] },
|
|
67
|
+
{ name: "token with capitals", raw: "fooBar", t: "item", expected: [{ __type: "token", value: "fooBar" }, []] },
|
|
68
|
+
{ name: "true boolean", raw: "?1", t: "item", expected: [true, []] },
|
|
69
|
+
{ name: "false boolean", raw: "?0", t: "item", expected: [false, []] },
|
|
70
|
+
{ name: "unknown boolean", raw: "?Q", t: "item", must_fail: true },
|
|
71
|
+
{ name: "basic binary", raw: ":aGVsbG8=:", t: "item", expected: [{ __type: "binary", value: "NBSWY3DP" }, []] },
|
|
72
|
+
{ name: "empty binary", raw: "::", t: "item", expected: [{ __type: "binary", value: "" }, []] },
|
|
73
|
+
{ name: "unpadded binary (RFC 8941 §4.2.7 synthesizes padding)", raw: ":aGVsbG8:", t: "item", expected: [{ __type: "binary", value: "NBSWY3DP" }, []] },
|
|
74
|
+
{ name: "padding at beginning", raw: ":=aGVsbG8=:", t: "item", must_fail: true },
|
|
75
|
+
{ name: "empty item", raw: "", t: "item", must_fail: true },
|
|
76
|
+
{ name: "leading space item", raw: " \t 1", t: "item", must_fail: true },
|
|
77
|
+
{ name: "trailing space item", raw: "1 \t ", t: "item", must_fail: true },
|
|
78
|
+
{ name: "item with param", raw: "5; foo=bar", t: "item", expected: [5, [["foo", { __type: "token", value: "bar" }]]] },
|
|
79
|
+
{ name: "boolean param value", raw: "1; a; b=?0", t: "item", expected: [1, [["a", true], ["b", false]]] },
|
|
80
|
+
{ name: "basic list", raw: "1, 42", t: "list", expected: [[1, []], [42, []]] },
|
|
81
|
+
{ name: "empty list", raw: "", t: "list", expected: [] },
|
|
82
|
+
{ name: "basic inner list", raw: "(1 2)", t: "list", expected: [[[[1, []], [2, []]], []]] },
|
|
83
|
+
{ name: "inner list with params", raw: "(1 2);a=1", t: "list", expected: [[[[1, []], [2, []]], [["a", 1]]]] },
|
|
84
|
+
{ name: "trailing comma list", raw: "1, 42, ", t: "list", must_fail: true },
|
|
85
|
+
{ name: "basic dictionary", raw: "a=1, b=2", t: "dictionary", expected: [["a", [1, []]], ["b", [2, []]]] },
|
|
86
|
+
{ name: "dictionary bare key", raw: "a=1, b, c=3", t: "dictionary", expected: [["a", [1, []]], ["b", [true, []]], ["c", [3, []]]] },
|
|
87
|
+
{ name: "dictionary inner-list value", raw: "a=(1 2)", t: "dictionary", expected: [["a", [[[1, []], [2, []]], []]]] },
|
|
88
|
+
{ name: "trailing comma dict", raw: "a=1,", t: "dictionary", must_fail: true },
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
function testConformance() {
|
|
92
|
+
var passed = 0, failed = 0, roundtrips = 0;
|
|
93
|
+
CASES.forEach(function (c) {
|
|
94
|
+
if (c.must_fail) {
|
|
95
|
+
var threw = false;
|
|
96
|
+
try { SF.parse(c.raw, c.t); } catch (_e) { threw = true; }
|
|
97
|
+
if (threw) failed++; else check("must_fail rejected: " + c.name, false);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
var got;
|
|
101
|
+
try { got = SF.parse(c.raw, c.t); }
|
|
102
|
+
catch (_e) { check("parse ok: " + c.name, false); return; }
|
|
103
|
+
var ok = JSON.stringify(mine(got, c.t)) === JSON.stringify(http(c.expected, c.t));
|
|
104
|
+
if (ok) passed++; else check("value matches RFC vector: " + c.name + " (got " + JSON.stringify(mine(got, c.t)) + ")", false);
|
|
105
|
+
// Round-trip: serialize → parse → serialize must be stable.
|
|
106
|
+
try {
|
|
107
|
+
var s1 = SF.serialize(got, c.t);
|
|
108
|
+
var s2 = SF.serialize(SF.parse(s1, c.t), c.t);
|
|
109
|
+
if (s1 === s2) roundtrips++; else check("round-trip stable: " + c.name + " (" + s1 + " vs " + s2 + ")", false);
|
|
110
|
+
} catch (e) { check("round-trip ok: " + c.name + " — " + e.message, false); }
|
|
111
|
+
});
|
|
112
|
+
check("all passing vectors parse to the RFC value model (" + passed + ")", passed === CASES.filter(function (c) { return !c.must_fail; }).length);
|
|
113
|
+
check("all must_fail vectors are rejected (" + failed + ")", failed === CASES.filter(function (c) { return c.must_fail; }).length);
|
|
114
|
+
check("all passing vectors round-trip stably (" + roundtrips + ")", roundtrips === passed);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function testSerialize() {
|
|
118
|
+
check("serialize: token item with param", SF.serialize({ value: new SF.Token("gzip"), params: new Map([["q", 1]]) }, "item") === "gzip;q=1");
|
|
119
|
+
check("serialize: string item", SF.serialize({ value: "a b", params: new Map() }, "item") === '"a b"');
|
|
120
|
+
check("serialize: byte sequence", SF.serialize({ value: new SF.ByteSequence(Buffer.from("hello")), params: new Map() }, "item") === ":aGVsbG8=:");
|
|
121
|
+
check("serialize: list of inner list", SF.serialize([{ items: [{ value: 1, params: new Map() }, { value: 2, params: new Map() }], params: new Map() }], "list") === "(1 2)");
|
|
122
|
+
check("serialize: dictionary from object", SF.serialize({ a: { value: 1, params: new Map() }, b: { value: true, params: new Map() } }, "dictionary") === "a=1, b");
|
|
123
|
+
function code(fn) { try { fn(); return "NO-THROW"; } catch (e) { return e.code; } }
|
|
124
|
+
check("serialize: out-of-range integer refused", code(function () { SF.serialize({ value: 10000000000000000, params: new Map() }, "item"); }) === "structured-fields/serialize");
|
|
125
|
+
check("serialize: invalid token refused", code(function () { SF.serialize({ value: new SF.Token("1bad"), params: new Map() }, "item"); }) === "structured-fields/serialize");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function testDecimalTypePreserved() {
|
|
129
|
+
// A numerically-integral Decimal must NOT serialize back to an Integer.
|
|
130
|
+
var parsed = SF.parse("1.0", "item");
|
|
131
|
+
check("parse: '1.0' yields a Decimal (not a bare integer)", parsed.value instanceof SF.Decimal);
|
|
132
|
+
check("serialize: Decimal 1.0 round-trips to '1.0', not '1'", SF.serialize(parsed, "item") === "1.0");
|
|
133
|
+
check("serialize: explicit SfDecimal forces the decimal form", SF.serialize({ value: new SF.Decimal(5), params: new Map() }, "item") === "5.0");
|
|
134
|
+
check("serialize: a fractional JS number still serializes as a Decimal", SF.serialize({ value: 2.5, params: new Map() }, "item") === "2.5");
|
|
135
|
+
check("serialize: an integral JS number serializes as an Integer", SF.serialize({ value: 5, params: new Map() }, "item") === "5");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function testTypedError() {
|
|
139
|
+
function E(code, msg) { this.code = code; this.message = msg; }
|
|
140
|
+
E.prototype = Object.create(Error.prototype);
|
|
141
|
+
var threw = null;
|
|
142
|
+
try { SF.parse("1.", "item", { ErrorClass: E }); } catch (e) { threw = e; }
|
|
143
|
+
check("parse: typed ErrorClass honored", threw instanceof E && threw.code === "structured-fields/parse");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function testSurface() {
|
|
147
|
+
// Reference the full b.structuredFields.* paths so the coverage gate
|
|
148
|
+
// sees them (the suite otherwise uses the SF alias).
|
|
149
|
+
check("b.structuredFields.parse parses an item", b.structuredFields.parse("42", "item").value === 42);
|
|
150
|
+
check("b.structuredFields.serialize round-trips an item", b.structuredFields.serialize({ value: 42, params: new Map() }, "item") === "42");
|
|
151
|
+
check("b.structuredFields.Token constructs a token", new b.structuredFields.Token("gzip").value === "gzip");
|
|
152
|
+
check("b.structuredFields.ByteSequence constructs a byte sequence", Buffer.isBuffer(new b.structuredFields.ByteSequence(Buffer.from("x")).value));
|
|
153
|
+
check("b.structuredFields.Decimal constructs a decimal", new b.structuredFields.Decimal(1.5).value === 1.5);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function run() {
|
|
157
|
+
testSurface();
|
|
158
|
+
testConformance();
|
|
159
|
+
testSerialize();
|
|
160
|
+
testDecimalTypePreserved();
|
|
161
|
+
testTypedError();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
module.exports = { run: run };
|
|
165
|
+
|
|
166
|
+
if (require.main === module) {
|
|
167
|
+
run().then(
|
|
168
|
+
function () { console.log("[structured-fields-codec] OK — " + helpers.getChecks() + " checks passed"); },
|
|
169
|
+
function (e) { console.error("FAIL:", e && e.stack || e); process.exit(1); }
|
|
170
|
+
);
|
|
171
|
+
}
|
package/package.json
CHANGED