@blamejs/core 0.12.53 → 0.12.54

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,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.
package/README.md CHANGED
@@ -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`)
@@ -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
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.12.53",
3
+ "version": "0.12.54",
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:20868c28-f68f-42c3-a926-c9e46216c41e",
5
+ "serialNumber": "urn:uuid:14059245-176a-4c8f-b9d4-123c8285acfa",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-25T18:23:33.227Z",
8
+ "timestamp": "2026-05-25T19:38:02.951Z",
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.53",
22
+ "bom-ref": "@blamejs/core@0.12.54",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.12.53",
25
+ "version": "0.12.54",
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.53",
29
+ "purl": "pkg:npm/%40blamejs/core@0.12.54",
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.53",
57
+ "ref": "@blamejs/core@0.12.54",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]