@blamejs/core 0.12.55 → 0.12.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.12.x
10
10
 
11
+ - v0.12.57 (2026-05-25) — **`b.linkHeader` — RFC 8288 Web Linking (HTTP Link header) codec.** Parse and build the HTTP Link header (RFC 8288) — the standard way to convey resource relations, most visibly REST pagination (Link: <…?page=2>; rel="next"). b.linkHeader.parse returns one { uri, rel, params } per link, splitting multiple links on top-level commas and unwrapping quoted parameter values via the framework's RFC 8941 structured-field helpers, so a comma inside a quoted title never fake-splits the list; rel is exposed as its array of space-separated relation types. b.linkHeader.serialize is the inverse, angle-bracketing the URI, emitting rel first, and double-quoting parameter values. Verified against the RFC 8288 §3.5 examples and GitHub-style pagination links. **Added:** *`b.linkHeader.parse(headerValue)` / `b.linkHeader.serialize(links)`* — `parse` reads an HTTP Link header into `[{ uri, rel, params }]` — `uri` is the angle-bracketed target, `rel` the array of space-separated relation types, `params` the remaining parameters with quoted values unwrapped (a repeated parameter keeps the first occurrence per RFC 8288 §3.4). It refuses a link without a bracketed URI, an unterminated URI or quoted parameter, control bytes, and oversized headers. `serialize` builds the header value from `{ uri, rel, params? }` objects (or a single one), angle-bracketing the URI and double-quoting parameter values. Composes the framework's structured-field splitter so quoting is handled consistently; pair with `b.pagination` to emit standard `rel="next"` / `rel="prev"` navigation.
12
+
13
+ - v0.12.56 (2026-05-25) — **`b.canonicalJson` — RFC 8785 JSON Canonicalization Scheme, now a public primitive.** The deterministic JSON serializer the framework uses internally for audit-chain and config-drift fingerprints is now an operator-facing primitive, for signing your own JSON (custom credentials, receipts, deterministic request signing). b.canonicalJson.stringifyJcs is strict RFC 8785: keys sorted in UTF-16 code-unit order at every depth, numbers in the ECMAScript format JCS references, and types JCS does not define (BigInt / Buffer / Date / Map / Set / circular references) refused rather than silently lost. b.canonicalJson.stringify is a lenient variant that also serializes Buffers (hex), Dates (ISO-8601), and BigInts. Exposing it surfaced and fixed a latent ordering bug: the serializer built a sorted-key object and let JSON.stringify emit it, but V8 hoists integer-like keys ("1", "10") to the front — so canonical output was wrong for objects with integer-like string keys. Members are now written in sorted order directly. Validated against the official cyberphone/json-canonicalization conformance vectors. **Added:** *`b.canonicalJson.stringifyJcs(value)` / `stringify(value, opts?)` / `sortKeys(obj)`* — `stringifyJcs` produces strict RFC 8785 canonical JSON — the byte-for-byte stable form to hash or sign — with UTF-16 code-unit key sorting and ECMAScript number formatting, refusing BigInt / Buffer / Date / Map / Set / RegExp / Symbol / function / circular references. `stringify` is the lenient framework variant (Buffers → hex, Dates → ISO-8601, BigInts → decimal; `opts.bufferAs: "reject"` to forbid binary). `sortKeys` returns an object's own keys in the canonical UTF-16 ordering. These were framework-internal; they are now documented public API. **Fixed:** *Canonical JSON now emits integer-like keys in sorted order* — The canonical serializer built a sorted-key object and serialized it with JSON.stringify, which hoists integer-like string keys ("1", "10") to the front per V8 own-property ordering — producing non-canonical output for objects containing such keys (a violation of RFC 8785 §3.2.3). Members are now written in sorted-key order directly. Real-world consumers (audit-chain, config-drift) use named fields and are unaffected; only objects with integer-like string keys change, and the new output is the correct canonical form.
14
+
11
15
  - 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
16
 
13
17
  - 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.
package/README.md CHANGED
@@ -98,6 +98,8 @@ 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
+ - **Link header** — RFC 8288 Web Linking codec (`b.linkHeader.parse` / `serialize`): parse and build `Link: <uri>; rel="next"` relations, the standard REST pagination mechanism; quote-aware (a comma inside a quoted parameter never splits the list)
102
+ - **Canonical JSON** — RFC 8785 JSON Canonicalization Scheme (`b.canonicalJson.stringifyJcs`): the deterministic, sorted-key byte form to hash or sign (custom credentials, receipts, deterministic request signing); UTF-16 key ordering + ECMAScript number formatting, with a lenient `stringify` variant for Buffers / Dates / BigInts
101
103
  - **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
104
  - **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
105
  - **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`)
package/index.js CHANGED
@@ -396,6 +396,8 @@ var dbsc = require("./lib/dbsc");
396
396
  var importmapIntegrity = require("./lib/importmap-integrity");
397
397
  var privacyPass = require("./lib/privacy-pass");
398
398
  var contentDigest = require("./lib/content-digest");
399
+ var canonicalJson = require("./lib/canonical-json");
400
+ var linkHeader = require("./lib/link-header");
399
401
  var standardWebhooks = require("./lib/standard-webhooks");
400
402
  var lro = require("./lib/lro");
401
403
  var jsonApi = require("./lib/jsonapi");
@@ -413,6 +415,8 @@ module.exports = {
413
415
  importmapIntegrity: importmapIntegrity,
414
416
  privacyPass: privacyPass,
415
417
  contentDigest: contentDigest,
418
+ canonicalJson: canonicalJson,
419
+ linkHeader: linkHeader,
416
420
  standardWebhooks: standardWebhooks,
417
421
  lro: lro,
418
422
  jsonApi: jsonApi,
@@ -1,79 +1,83 @@
1
1
  "use strict";
2
2
  /**
3
- * Canonical JSON — deterministic stringify with sorted keys at every depth.
3
+ * @module b.canonicalJson
4
+ * @nav Data
5
+ * @title Canonical JSON
4
6
  *
5
- * Replaces the four near-identical implementations that grew up across
6
- * `lib/audit-chain.js`, `lib/audit-tools.js`, `lib/config-drift.js`, and
7
- * `lib/pagination.js`. They all walked `typeof === "object"` with
8
- * `Object.keys(...).sort()` and silently round-tripped Date as `{}`,
9
- * Buffer as `{"0":97,"1":98,…}`, Map / Set / RegExp as `{}`, Symbol /
10
- * function as missing keys, and BigInt as a thrown
11
- * `Do not know how to serialize a BigInt` mid-emit. Circular references
12
- * stack-overflowed instead of producing a clean framework error.
7
+ * @intro
8
+ * Deterministic JSON serialization with keys sorted at every depth —
9
+ * the byte-for-byte stable form you hash or sign so two parties that
10
+ * build the same data produce the same bytes. <code>stringifyJcs</code>
11
+ * is strict RFC 8785 (JSON Canonicalization Scheme); <code>stringify</code>
12
+ * is a lenient variant that additionally serializes Buffers (as hex),
13
+ * Dates (ISO-8601), and BigInts (decimal) for the framework's own audit
14
+ * / config-drift fingerprints.
13
15
  *
14
- * The walk:
16
+ * Both walks close the silent-data-loss class that ad-hoc
17
+ * <code>Object.keys(...).sort()</code> serializers fall into: Map /
18
+ * Set / RegExp / class instances, Symbols, functions, and circular
19
+ * references all throw a clean error rather than emitting <code>{}</code>
20
+ * or stack-overflowing. RFC 8785 strict mode additionally refuses
21
+ * BigInt / Buffer / Date (types JCS does not define) so the operator
22
+ * converts them to JSON-native shapes before signing.
15
23
  *
16
- * primitives + null + undefined → JSON.stringify (undefined → "null")
17
- * bigint → decimal string ("123" not 123n)
18
- * Date → ISO string
19
- * Buffer / Uint8Array → hex (when bufferAs = "hex", default)
20
- * throw (when bufferAs = "reject")
21
- * Map / Set / RegExp → throw with constructor name
22
- * symbol / function → throw with type name
23
- * circular reference → throw via WeakSet detection
24
- * plain array → recurse, preserve order
25
- * plain object → recurse with sorted keys
24
+ * Key ordering is V8's <code>Object.keys(...).sort()</code>
25
+ * lexicographic UTF-16 code-unit order, which is exactly RFC 8785
26
+ * §3.2.3 and numbers are formatted by <code>JSON.stringify</code>,
27
+ * whose output is the ECMA-262 Number-to-string algorithm that RFC
28
+ * 8785 §3.2.2.3 references.
26
29
  *
27
- * Two consumer policies on Buffer / Uint8Array are documented because
28
- * the framework historically chose differently per call site:
29
- *
30
- * bufferAs: "hex" audit-chain / audit-tools / config-drift
31
- * binary data is legitimate (cert PEMs, key
32
- * material, hash bytes); preserve as hex so the
33
- * canonical output is reversible.
34
- * bufferAs: "reject" pagination — cursor state is operator-supplied
35
- * primitive data; binary in a cursor is almost
36
- * always a bug; reject loudly.
37
- *
38
- * Operators don't call this directly — it's a framework-internal walker.
30
+ * @card
31
+ * Canonical JSON (RFC 8785 JCS) the deterministic, sorted-key byte
32
+ * form you sign or hash. Strict <code>stringifyJcs</code> for
33
+ * interop, plus a lenient framework variant that serializes Buffers /
34
+ * Dates / BigInts. Lossy ad-hoc serializers (Map / Set / circular →
35
+ * <code>{}</code>) are refused.
39
36
  */
40
37
 
41
- function _scrub(value, seen, bufferAs) {
42
- if (value === null || typeof value === "undefined") return null;
38
+ // Emit the canonical JSON STRING in one ordered pass. Object members are
39
+ // written in sorted-key order directly building a plain object and
40
+ // relying on JSON.stringify would silently hoist integer-like keys
41
+ // ("1", "10") to the front (V8 own-property ordering), breaking the
42
+ // RFC 8785 §3.2.3 sort. Primitives, strings, and numbers use
43
+ // JSON.stringify, whose escaping (§3.2.2.2) and ECMAScript number format
44
+ // (§3.2.2.3) are exactly what JCS references.
45
+ function _emit(value, seen, bufferAs) {
46
+ if (value === null || typeof value === "undefined") return "null";
43
47
  var t = typeof value;
44
- if (t === "string" || t === "boolean" || t === "number") return value;
48
+ if (t === "number" || t === "string" || t === "boolean") return JSON.stringify(value);
45
49
  if (t === "bigint") {
46
50
  if (bufferAs === "reject-jcs") {
47
51
  throw new Error("canonical-json: BigInt is not serialisable under " +
48
52
  "RFC 8785 (JCS); convert to a string or number before passing in");
49
53
  }
50
- return String(value);
54
+ return JSON.stringify(String(value));
51
55
  }
52
56
  if (t === "symbol" || t === "function") {
53
57
  throw new Error("canonical-json: " + t + " value is not " +
54
58
  "serialisable; convert to a string before passing in");
55
59
  }
56
60
  // Buffer / Uint8Array — policy-driven
57
- if (Buffer.isBuffer(value)) {
61
+ if (Buffer.isBuffer(value)) {
58
62
  if (bufferAs === "reject" || bufferAs === "reject-jcs") {
59
63
  throw new Error("canonical-json: Buffer is not serialisable in this " +
60
64
  "context (bufferAs=reject); convert to a string or hex first");
61
65
  }
62
- return value.toString("hex");
66
+ return JSON.stringify(value.toString("hex"));
63
67
  }
64
68
  if (value instanceof Uint8Array) {
65
69
  if (bufferAs === "reject" || bufferAs === "reject-jcs") {
66
70
  throw new Error("canonical-json: Uint8Array is not serialisable in " +
67
71
  "this context (bufferAs=reject); convert to a string or hex first");
68
72
  }
69
- return Buffer.from(value).toString("hex");
73
+ return JSON.stringify(Buffer.from(value).toString("hex"));
70
74
  }
71
75
  if (value instanceof Date) {
72
76
  if (bufferAs === "reject-jcs") {
73
77
  throw new Error("canonical-json: Date is not serialisable under " +
74
78
  "RFC 8785 (JCS); convert to ISO-8601 string before passing in");
75
79
  }
76
- return value.toISOString();
80
+ return JSON.stringify(value.toISOString());
77
81
  }
78
82
  // After primitives + Date + Buffer + Uint8Array, any remaining "object"
79
83
  // must be a plain object or array. Map / Set / RegExp / class instances
@@ -88,35 +92,73 @@ function _scrub(value, seen, bufferAs) {
88
92
  }
89
93
  seen.add(value);
90
94
  if (Array.isArray(value)) {
91
- return value.map(function (v) { return _scrub(v, seen, bufferAs); });
95
+ // Index loop, not .map(): map() skips holes in a sparse array,
96
+ // which join() would then render as invalid elisions ([,1]). A hole
97
+ // reads as undefined → _emit returns "null" (matching JSON.stringify).
98
+ var items = [];
99
+ for (var ai = 0; ai < value.length; ai += 1) {
100
+ items.push(_emit(value[ai], seen, bufferAs));
101
+ }
102
+ return "[" + items.join(",") + "]";
92
103
  }
93
104
  // Canonical-json IS the destination for sorted-keys walks across the
94
105
  // codebase; the keys-then-sort here is the canonical primitive itself.
95
106
  var keys = Object.keys(value);
96
107
  keys.sort();
97
- var out = {};
108
+ var parts = [];
98
109
  for (var i = 0; i < keys.length; i++) {
99
- out[keys[i]] = _scrub(value[keys[i]], seen, bufferAs);
110
+ parts.push(JSON.stringify(keys[i]) + ":" + _emit(value[keys[i]], seen, bufferAs));
100
111
  }
101
- return out;
112
+ return "{" + parts.join(",") + "}";
102
113
  }
103
114
 
104
- // Return the deterministic JSON string. opts.bufferAs picks the Buffer
105
- // policy ("hex" default, "reject" for callers like pagination).
115
+ /**
116
+ * @primitive b.canonicalJson.stringify
117
+ * @signature b.canonicalJson.stringify(value, opts?)
118
+ * @since 0.5.0
119
+ * @status stable
120
+ * @related b.canonicalJson.stringifyJcs, b.canonicalJson.sortKeys
121
+ *
122
+ * Deterministic JSON with keys sorted at every depth — the lenient
123
+ * framework variant. Beyond JSON-native values it serializes Buffers /
124
+ * Uint8Arrays (hex), Dates (ISO-8601), and BigInts (decimal string); Map
125
+ * / Set / RegExp / class instances, Symbols, functions, and circular
126
+ * references throw rather than silently emitting <code>{}</code>. Use
127
+ * <code>stringifyJcs</code> for strict RFC 8785 interop.
128
+ *
129
+ * @opts
130
+ * bufferAs: string, // "hex" (default) | "reject" — Buffer / Uint8Array policy
131
+ *
132
+ * @example
133
+ * b.canonicalJson.stringify({ b: 1, a: 2 });
134
+ * // → '{"a":2,"b":1}'
135
+ */
106
136
  function stringify(value, opts) {
107
137
  var bufferAs = (opts && opts.bufferAs) || "hex";
108
138
  if (bufferAs !== "hex" && bufferAs !== "reject" && bufferAs !== "reject-jcs") {
109
139
  throw new Error("canonical-json: bufferAs must be 'hex' / 'reject' / 'reject-jcs'; got " +
110
140
  JSON.stringify(bufferAs));
111
141
  }
112
- return JSON.stringify(_scrub(value, null, bufferAs));
142
+ return _emit(value, null, bufferAs);
113
143
  }
114
144
 
115
- // Stable key ordering for an object — same lexicographic sort used by
116
- // the canonical-json walker. Exposed so call sites that need a sorted
117
- // key list (CLI report ordering, fingerprint inputs) route through
118
- // the framework's single source-of-truth ordering rule rather than
119
- // re-implementing the keys-then-sort dance inline.
145
+ /**
146
+ * @primitive b.canonicalJson.sortKeys
147
+ * @signature b.canonicalJson.sortKeys(obj)
148
+ * @since 0.5.0
149
+ * @status stable
150
+ * @related b.canonicalJson.stringify
151
+ *
152
+ * The object's own keys in the framework's single canonical ordering —
153
+ * lexicographic UTF-16 code-unit sort (the same ordering the canonical
154
+ * serializers use). Returns an empty array for a non-object. Route
155
+ * fingerprint / report ordering through this rather than re-implementing
156
+ * the keys-then-sort dance inline.
157
+ *
158
+ * @example
159
+ * b.canonicalJson.sortKeys({ b: 1, a: 2, c: 3 });
160
+ * // → ["a", "b", "c"]
161
+ */
120
162
  function sortKeys(obj) {
121
163
  if (!obj || typeof obj !== "object") return [];
122
164
  var keys = Object.keys(obj);
@@ -124,16 +166,30 @@ function sortKeys(obj) {
124
166
  return keys;
125
167
  }
126
168
 
127
- // stringifyJcs — RFC 8785 (JSON Canonicalization Scheme) strict mode.
128
- // Refuses inputs JCS does NOT cover (BigInt, Buffer / Uint8Array, Date,
129
- // Map, Set, RegExp, Symbol, function); operators carrying those types
130
- // must convert to JSON-native shapes upfront. Object key ordering and
131
- // number formatting already match JCS §3.2.2 — V8's
132
- // `Object.keys(...).sort()` is lexicographic UTF-16 code-unit order
133
- // (JCS §3.2.3) and `JSON.stringify` formats numbers per
134
- // ECMA-262 §7.1.12.1 which JCS §3.2.2.3 references.
169
+ /**
170
+ * @primitive b.canonicalJson.stringifyJcs
171
+ * @signature b.canonicalJson.stringifyJcs(value)
172
+ * @since 0.12.56
173
+ * @status stable
174
+ * @compliance soc2
175
+ * @related b.canonicalJson.stringify, b.vc.issue, b.scitt.signStatement
176
+ *
177
+ * Strict RFC 8785 JSON Canonicalization Scheme — the deterministic byte
178
+ * form to hash or sign when two parties must agree on the exact bytes
179
+ * (signed JSON credentials, receipts, deterministic request signing).
180
+ * Keys are sorted in UTF-16 code-unit order at every depth (§3.2.3) and
181
+ * numbers use the ECMAScript Number-to-string formatting §3.2.2.3
182
+ * references. Inputs JCS does not define — BigInt, Buffer / Uint8Array,
183
+ * Date, Map, Set, RegExp, Symbol, function, and circular references —
184
+ * are refused, so the operator converts them to JSON-native shapes
185
+ * before signing rather than getting a silently lossy result.
186
+ *
187
+ * @example
188
+ * b.canonicalJson.stringifyJcs({ "€": 1, "$": 2 });
189
+ * // → '{"$":2,"€":1}' (keys sorted by UTF-16 code unit)
190
+ */
135
191
  function stringifyJcs(value) {
136
- return JSON.stringify(_scrub(value, null, "reject-jcs"));
192
+ return _emit(value, null, "reject-jcs");
137
193
  }
138
194
 
139
195
  module.exports = {
@@ -0,0 +1,169 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.linkHeader
4
+ * @nav HTTP
5
+ * @title Link header
6
+ *
7
+ * @intro
8
+ * Parse and build the HTTP <code>Link</code> header (RFC 8288 Web
9
+ * Linking) — the standard way to convey relations between resources,
10
+ * most visibly REST pagination
11
+ * (<code>Link: &lt;…?page=2&gt;; rel="next"</code>). A header carries
12
+ * one or more comma-separated links, each an angle-bracketed URI
13
+ * reference followed by <code>;</code>-separated parameters
14
+ * (<code>rel</code>, <code>title</code>, <code>type</code>, …).
15
+ *
16
+ * <code>parse</code> returns one object per link with its
17
+ * <code>uri</code>, the (space-split) <code>rel</code> relation types,
18
+ * and the remaining <code>params</code>; <code>serialize</code> is the
19
+ * inverse. The comma split and quoted-parameter unwrapping reuse the
20
+ * framework's RFC 8941 structured-field helpers so a comma inside a
21
+ * quoted <code>title</code> never fake-splits the list.
22
+ *
23
+ * @card
24
+ * HTTP Link header codec (RFC 8288 Web Linking) — parse and build
25
+ * <code>Link: &lt;uri&gt;; rel="next"</code> relations, the standard
26
+ * REST pagination mechanism. Quote-aware so a comma inside a quoted
27
+ * parameter never splits the list.
28
+ */
29
+
30
+ var structuredFields = require("./structured-fields");
31
+ var { defineClass } = require("./framework-error");
32
+
33
+ var LinkHeaderError = defineClass("LinkHeaderError", { alwaysPermanent: true });
34
+
35
+ var MAX_HEADER_BYTES = 16384; // allow:raw-byte-literal — defensive cap on a parsed Link header
36
+
37
+ // Split a Link header on the commas that separate links — those OUTSIDE
38
+ // a <uri-reference> and outside a quoted-string. structuredFields'
39
+ // splitter is quote-aware but not angle-bracket-aware, so a comma inside
40
+ // a URI (`<https://x/a,b>`) must not split the list (RFC 8288 §3.5).
41
+ function _splitLinks(s) {
42
+ var out = [], start = 0, inUri = false, inQuote = false, esc = false;
43
+ for (var i = 0; i < s.length; i += 1) {
44
+ var c = s.charAt(i);
45
+ if (esc) { esc = false; continue; }
46
+ if (inQuote) { if (c === "\\") esc = true; else if (c === "\"") inQuote = false; continue; }
47
+ if (c === "\"") { inQuote = true; }
48
+ else if (c === "<") { inUri = true; }
49
+ else if (c === ">") { inUri = false; }
50
+ else if (c === "," && !inUri) { out.push(s.slice(start, i)); start = i + 1; }
51
+ }
52
+ out.push(s.slice(start));
53
+ return out;
54
+ }
55
+
56
+ /**
57
+ * @primitive b.linkHeader.parse
58
+ * @signature b.linkHeader.parse(headerValue)
59
+ * @since 0.12.57
60
+ * @status stable
61
+ * @related b.linkHeader.serialize, b.pagination.cursor
62
+ *
63
+ * Parse an HTTP <code>Link</code> header value (RFC 8288) into an array
64
+ * of <code>{ uri, rel, params }</code> — one per link. <code>uri</code>
65
+ * is the angle-bracketed target, <code>rel</code> is the array of
66
+ * (space-separated) relation types, and <code>params</code> is the
67
+ * remaining parameters with quoted values unwrapped. A comma inside a
68
+ * quoted parameter value does not split the list. A link without a
69
+ * bracketed URI is refused.
70
+ *
71
+ * @example
72
+ * b.linkHeader.parse('<https://api/x?page=2>; rel="next", <https://api/x?page=9>; rel="last"');
73
+ * // → [ { uri: "https://api/x?page=2", rel: ["next"], params: {} },
74
+ * // { uri: "https://api/x?page=9", rel: ["last"], params: {} } ]
75
+ */
76
+ function parse(headerValue) {
77
+ if (typeof headerValue !== "string") throw new LinkHeaderError("link-header/bad-input", "linkHeader.parse: headerValue must be a string");
78
+ if (headerValue.length > MAX_HEADER_BYTES) throw new LinkHeaderError("link-header/too-large", "linkHeader.parse: Link header exceeds " + MAX_HEADER_BYTES + " bytes");
79
+ structuredFields.refuseControlBytes(headerValue, { ErrorClass: LinkHeaderError, code: "link-header/bad-input", label: "Link header" });
80
+ var out = [];
81
+ var members = _splitLinks(headerValue);
82
+ for (var i = 0; i < members.length; i++) {
83
+ var raw = members[i].trim();
84
+ if (raw === "") continue;
85
+ if (raw.charAt(0) !== "<") throw new LinkHeaderError("link-header/bad-link", "linkHeader.parse: link must start with a <uri-reference>");
86
+ var close = raw.indexOf(">");
87
+ if (close === -1) throw new LinkHeaderError("link-header/bad-link", "linkHeader.parse: unterminated <uri-reference>");
88
+ var uri = raw.slice(1, close);
89
+ var rest = raw.slice(close + 1);
90
+ var paramParts = structuredFields.splitTopLevel(rest, ";");
91
+ var rel = [], relSet = false, params = Object.create(null);
92
+ for (var p = 0; p < paramParts.length; p++) {
93
+ var piece = paramParts[p].trim();
94
+ if (piece === "") continue;
95
+ var eq = piece.indexOf("=");
96
+ var name = (eq === -1 ? piece : piece.slice(0, eq)).trim().toLowerCase();
97
+ if (name === "") continue;
98
+ var value = eq === -1 ? "" : structuredFields.unquoteSfString(piece.slice(eq + 1).trim());
99
+ if (value === null) throw new LinkHeaderError("link-header/bad-link", "linkHeader.parse: unterminated quoted parameter on '" + name + "'");
100
+ // RFC 8288 §3.3 / §3.4: a repeated rel (or any parameter) keeps the
101
+ // FIRST occurrence; later ones are ignored.
102
+ if (name === "rel") { if (!relSet) { rel = value.split(/\s+/).filter(Boolean); relSet = true; } continue; }
103
+ if (!(name in params)) params[name] = value;
104
+ }
105
+ out.push({ uri: uri, rel: rel, params: params });
106
+ }
107
+ return out;
108
+ }
109
+
110
+ // Quote every parameter value (always valid RFC 8288, and required for
111
+ // space-separated multi-rel and non-token values like "text/html"); the
112
+ // common convention (RFC 8288 examples, REST pagination) quotes too.
113
+ function _serParam(name, value) {
114
+ if (value === "" || value === true) return name; // valueless parameter
115
+ return name + "=\"" + String(value).replace(/\\/g, "\\\\").replace(/"/g, "\\\"") + "\"";
116
+ }
117
+
118
+ /**
119
+ * @primitive b.linkHeader.serialize
120
+ * @signature b.linkHeader.serialize(links)
121
+ * @since 0.12.57
122
+ * @status stable
123
+ * @related b.linkHeader.parse
124
+ *
125
+ * Build an HTTP <code>Link</code> header value from an array of
126
+ * <code>{ uri, rel, params? }</code> (or a single such object). The URI
127
+ * is angle-bracketed, <code>rel</code> (string or array) is emitted
128
+ * first, and parameters are token-valued when they fit RFC 7230 token
129
+ * grammar or double-quoted otherwise. Useful for emitting standard REST
130
+ * pagination links.
131
+ *
132
+ * @example
133
+ * b.linkHeader.serialize([
134
+ * { uri: "https://api/x?page=2", rel: "next" },
135
+ * { uri: "https://api/x?page=9", rel: "last", params: { title: "end" } },
136
+ * ]);
137
+ * // → '<https://api/x?page=2>; rel="next", <https://api/x?page=9>; rel="last"; title="end"'
138
+ */
139
+ function serialize(links) {
140
+ var arr = Array.isArray(links) ? links : [links];
141
+ var parts = [];
142
+ for (var i = 0; i < arr.length; i++) {
143
+ var link = arr[i];
144
+ if (!link || typeof link !== "object" || typeof link.uri !== "string" || link.uri === "") {
145
+ throw new LinkHeaderError("link-header/bad-link", "linkHeader.serialize: links[" + i + "] requires a non-empty uri");
146
+ }
147
+ if (link.uri.indexOf(">") !== -1 || link.uri.indexOf("<") !== -1) {
148
+ throw new LinkHeaderError("link-header/bad-link", "linkHeader.serialize: uri must not contain angle brackets");
149
+ }
150
+ var seg = "<" + link.uri + ">";
151
+ var rel = Array.isArray(link.rel) ? link.rel.join(" ") : (link.rel || "");
152
+ if (rel !== "") seg += "; " + _serParam("rel", rel);
153
+ if (link.params && typeof link.params === "object") {
154
+ var keys = Object.keys(link.params);
155
+ for (var k = 0; k < keys.length; k++) {
156
+ if (keys[k].toLowerCase() === "rel") continue; // rel is emitted from link.rel
157
+ seg += "; " + _serParam(keys[k].toLowerCase(), link.params[keys[k]]);
158
+ }
159
+ }
160
+ parts.push(seg);
161
+ }
162
+ return parts.join(", ");
163
+ }
164
+
165
+ module.exports = {
166
+ parse: parse,
167
+ serialize: serialize,
168
+ LinkHeaderError: LinkHeaderError,
169
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.12.55",
3
+ "version": "0.12.57",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
package/sbom.cdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:ef5c0483-0d22-4060-acde-37144284b24a",
5
+ "serialNumber": "urn:uuid:a145d843-4618-44c4-8e2b-082f70687064",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-25T20:44:23.370Z",
8
+ "timestamp": "2026-05-25T23:07:17.164Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/core@0.12.55",
22
+ "bom-ref": "@blamejs/core@0.12.57",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.12.55",
25
+ "version": "0.12.57",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "The Node framework that owns its stack.",
29
- "purl": "pkg:npm/%40blamejs/core@0.12.55",
29
+ "purl": "pkg:npm/%40blamejs/core@0.12.57",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/core@0.12.55",
57
+ "ref": "@blamejs/core@0.12.57",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]