@blamejs/core 0.8.82 → 0.8.86

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/lib/a2a.js CHANGED
@@ -389,9 +389,19 @@ function verifyCard(envelope, publicKeyPem, opts) {
389
389
  };
390
390
  }
391
391
 
392
+ var tasks = require("./a2a-tasks");
393
+
392
394
  module.exports = {
393
395
  signCard: signCard,
394
396
  verifyCard: verifyCard,
395
397
  canonicalize: canonicalize,
396
398
  createCard: createCard,
399
+ tasks: {
400
+ send: tasks.send,
401
+ get: tasks.get,
402
+ cancel: tasks.cancel,
403
+ ALLOWED_METHODS: tasks.ALLOWED_METHODS,
404
+ },
405
+ middleware: tasks.middleware,
406
+ A2aTasksError: tasks.A2aTasksError,
397
407
  };
package/lib/acme.js CHANGED
@@ -565,6 +565,23 @@ function create(opts) {
565
565
  var payload = { identifiers: orderOpts.identifiers.slice() };
566
566
  if (typeof orderOpts.notBefore === "string") payload.notBefore = orderOpts.notBefore;
567
567
  if (typeof orderOpts.notAfter === "string") payload.notAfter = orderOpts.notAfter;
568
+ // draft-aaron-acme-profiles — operator-selected certificate profile.
569
+ // The CA advertises profile names + descriptions via
570
+ // `directory.meta.profiles`; operator passes the chosen name through
571
+ // newOrder. CAs honoring the draft return 400 when the name isn't
572
+ // in the advertised set; ones that haven't adopted the draft ignore
573
+ // the field. v1-defensible scope: refuse non-string + cap length so
574
+ // attacker-supplied profile values can't bloat the JSON payload.
575
+ if (typeof orderOpts.profile === "string") {
576
+ if (orderOpts.profile.length === 0 || orderOpts.profile.length > C.BYTES.bytes(64)) {
577
+ throw _err("acme/bad-profile",
578
+ "newOrder: profile name must be a non-empty string <= 64 bytes", true);
579
+ }
580
+ payload.profile = orderOpts.profile;
581
+ } else if (orderOpts.profile !== undefined) {
582
+ throw _err("acme/bad-profile",
583
+ "newOrder: profile must be a string when provided", true);
584
+ }
568
585
  var rsp = await _signedPost(state.directory.newOrder, payload);
569
586
  if (rsp.statusCode !== 201) {
570
587
  _emitAudit(audit, "acme.order.created", "failure",
@@ -710,7 +727,31 @@ function create(opts) {
710
727
  async function renewIfDue(opts2) {
711
728
  var ari = await fetchAri(opts2);
712
729
  var nowMs = Date.now();
713
- if (nowMs < ari.suggestedWindow.startMs) {
730
+ // RFC 9773 §4.2 — when called inside the suggested window, return
731
+ // a renewAt timestamp picked uniformly across the remaining window
732
+ // so a fleet of operators running on the same poll cadence don't
733
+ // cluster their renewal storms at the window-start instant. Operators
734
+ // opt in via `{ jitter: true }`; default behavior preserves the
735
+ // pre-0.8.83 "renew now" semantics.
736
+ var jitter = opts2 && opts2.jitter === true;
737
+ var beforeWindow = nowMs < ari.suggestedWindow.startMs;
738
+ var pastWindow = nowMs > ari.suggestedWindow.endMs;
739
+ var renewAtMs = null;
740
+ if (jitter) {
741
+ // Uniform random point in [max(now, start), end].
742
+ var jLo = beforeWindow ? ari.suggestedWindow.startMs : nowMs;
743
+ var jHi = ari.suggestedWindow.endMs;
744
+ if (jHi >= jLo) {
745
+ // Non-crypto: RFC 9773 §4.2 fleet-scheduling jitter inside the
746
+ // CA-suggested renewal window. Predictability is not a threat
747
+ // here; uniform distribution across the window is the goal.
748
+ renewAtMs = jLo + Math.floor(Math.random() * (jHi - jLo + 1)); // allow:math-random-noncrypto — RFC 9773 fleet jitter, predictability not a threat
749
+ } else {
750
+ // Past-window — renew immediately, no jitter.
751
+ renewAtMs = nowMs;
752
+ }
753
+ }
754
+ if (beforeWindow) {
714
755
  _emitAudit(audit, "acme.cert.renew.skipped", "success", {
715
756
  certId: ari.certId,
716
757
  windowStart: ari.suggestedWindow.start,
@@ -718,24 +759,31 @@ function create(opts) {
718
759
  nowIso: new Date(nowMs).toISOString(),
719
760
  });
720
761
  _emitObs("acme.cert.renew.skipped", { reason: "before-window" });
721
- return { shouldRenew: false, reason: "before-window", ari: ari };
762
+ var ret = { shouldRenew: false, reason: "before-window", ari: ari };
763
+ if (jitter) ret.renewAt = new Date(renewAtMs).toISOString();
764
+ return ret;
722
765
  }
723
- if (nowMs > ari.suggestedWindow.endMs) {
766
+ if (pastWindow) {
724
767
  _emitAudit(audit, "acme.cert.renew.scheduled", "warning", {
725
768
  certId: ari.certId,
726
769
  reason: "past-window",
727
770
  windowEnd: ari.suggestedWindow.end,
728
771
  });
729
772
  _emitObs("acme.cert.renew.scheduled", { reason: "past-window" });
730
- return { shouldRenew: true, reason: "past-window", ari: ari };
773
+ var rp = { shouldRenew: true, reason: "past-window", ari: ari };
774
+ if (jitter) rp.renewAt = new Date(renewAtMs).toISOString();
775
+ return rp;
731
776
  }
732
777
  _emitAudit(audit, "acme.cert.renew.scheduled", "success", {
733
778
  certId: ari.certId,
734
779
  windowStart: ari.suggestedWindow.start,
735
780
  windowEnd: ari.suggestedWindow.end,
781
+ renewAt: jitter ? new Date(renewAtMs).toISOString() : null,
736
782
  });
737
783
  _emitObs("acme.cert.renew.scheduled", { reason: "in-window" });
738
- return { shouldRenew: true, reason: "in-window", ari: ari };
784
+ var ri = { shouldRenew: true, reason: "in-window", ari: ari };
785
+ if (jitter) ri.renewAt = new Date(renewAtMs).toISOString();
786
+ return ri;
739
787
  }
740
788
 
741
789
  /**
@@ -896,6 +944,118 @@ function create(opts) {
896
944
  return crypto.createHash("sha256").update(keyAuth, "utf8").digest();
897
945
  }
898
946
 
947
+ /**
948
+ * @primitive b.acme.create.listProfiles
949
+ * @signature b.acme.create.listProfiles()
950
+ * @since 0.8.83
951
+ * @status experimental
952
+ *
953
+ * Returns the CA-advertised certificate profile catalog as
954
+ * `{ name: description }` per draft-aaron-acme-profiles. Operators
955
+ * pass the chosen name through `newOrder({ profile: name })`; CAs
956
+ * use the profile to select certificate lifetime + key-usage +
957
+ * validation rigor. As CA/B Forum 47-day cert TTLs phase in (Mar
958
+ * 2026 ballot SC-081v3), profile-name vocabulary becomes the
959
+ * operator-facing handle for "long-lived" vs "47-day" vs "short-
960
+ * lived". Returns an empty object when the directory has no
961
+ * `meta.profiles` map (CA hasn't adopted the draft). Refreshes the
962
+ * directory cache when none has been fetched yet.
963
+ *
964
+ * @example
965
+ * await acme.fetchDirectory();
966
+ * var profiles = acme.listProfiles();
967
+ * // → { "default": "Standard 90-day certificate",
968
+ * // "shortlived": "47-day certificate (CA/B Forum SC-081v3)",
969
+ * // "tlsserver": "TLS server profile with Must-Staple" }
970
+ *
971
+ * await acme.newOrder({ identifiers: [{ type: "dns", value: "example.com" }],
972
+ * profile: "shortlived" });
973
+ */
974
+ function listProfiles() {
975
+ if (!state.directory) return {};
976
+ var meta = state.directory.meta;
977
+ if (!meta || typeof meta !== "object") return {};
978
+ var profiles = meta.profiles;
979
+ if (!profiles || typeof profiles !== "object") return {};
980
+ var out = {};
981
+ var keys = Object.keys(profiles);
982
+ for (var i = 0; i < keys.length; i += 1) {
983
+ var k = keys[i];
984
+ var v = profiles[k];
985
+ out[k] = typeof v === "string" ? v : "";
986
+ }
987
+ return out;
988
+ }
989
+
990
+ /**
991
+ * @primitive b.acme.create.dnsAccount01ChallengeRecord
992
+ * @signature b.acme.create.dnsAccount01ChallengeRecord(token, opts?)
993
+ * @since 0.8.83
994
+ * @status experimental
995
+ * @related b.acme.create.tlsAlpn01KeyAuthorization
996
+ *
997
+ * Build the DNS TXT record an operator publishes to satisfy a
998
+ * `dns-account-01` challenge per draft-ietf-acme-dns-account-label.
999
+ * Unlike `dns-01` (record at `_acme-challenge.<host>`),
1000
+ * `dns-account-01` scopes the record by account so the same domain
1001
+ * can be validated from multiple ACME accounts without record-name
1002
+ * collisions; the record name becomes
1003
+ * `_<accountLabel>._acme-challenge.<identifier>` where
1004
+ * `accountLabel` is the SHA-256 truncated-base32 of the account URL.
1005
+ *
1006
+ * Returns `{ name, value, ttl }` where `name` is the FQDN to publish
1007
+ * the TXT record at (with operator-supplied `identifier` substituted
1008
+ * in) and `value` is the SHA-256 of the key authorization in
1009
+ * unpadded base64url (same as `dns-01`). Refuses when `newAccount`
1010
+ * has not run (no accountUrl yet); refuses non-string token /
1011
+ * identifier.
1012
+ *
1013
+ * @opts
1014
+ * identifier: string, // host being validated (required)
1015
+ * ttl: number, // suggested DNS TTL in seconds; default: 60
1016
+ *
1017
+ * @example
1018
+ * await acme.newAccount({ contact: ["mailto:ops@example.com"] });
1019
+ * var rec = acme.dnsAccount01ChallengeRecord("token123", {
1020
+ * identifier: "example.com",
1021
+ * });
1022
+ * // rec.name → "_<accountLabel>._acme-challenge.example.com"
1023
+ * // rec.value → "<base64url-of-sha256(token123.<thumbprint>)>"
1024
+ * // rec.ttl → 60
1025
+ */
1026
+ function dnsAccount01ChallengeRecord(token, opts2) {
1027
+ if (typeof token !== "string" || token.length === 0) {
1028
+ throw _err("acme/bad-token", "dnsAccount01ChallengeRecord: token must be a non-empty string", true);
1029
+ }
1030
+ if (!opts2 || typeof opts2 !== "object" || typeof opts2.identifier !== "string" || opts2.identifier.length === 0) {
1031
+ throw _err("acme/bad-identifier", "dnsAccount01ChallengeRecord: opts.identifier (host) is required", true);
1032
+ }
1033
+ if (opts2.identifier.length > C.BYTES.bytes(255)) {
1034
+ throw _err("acme/bad-identifier", "dnsAccount01ChallengeRecord: identifier exceeds 255 bytes", true);
1035
+ }
1036
+ if (!state.accountUrl) {
1037
+ throw _err("acme/no-account",
1038
+ "dnsAccount01ChallengeRecord: newAccount() must run first (account URL is the label seed)", true);
1039
+ }
1040
+ if (opts2.ttl !== undefined && (typeof opts2.ttl !== "number" || !isFinite(opts2.ttl) || opts2.ttl < 1 || opts2.ttl > C.TIME.hours(24) / C.TIME.seconds(1))) {
1041
+ throw _err("acme/bad-ttl",
1042
+ "dnsAccount01ChallengeRecord: ttl must be a positive integer <= 86400 seconds", true);
1043
+ }
1044
+ var crypto = require("node:crypto");
1045
+ // Account label: lowercase base32 of first 10 bytes of SHA-256(accountUrl)
1046
+ // (per draft-ietf-acme-dns-account-label §3.1 — 80-bit truncated label).
1047
+ var hash = crypto.createHash("sha256").update(state.accountUrl, "utf8").digest();
1048
+ var label = _base32lc(hash.subarray(0, 10));
1049
+ // Record value: same key-authorization digest shape as dns-01.
1050
+ var keyAuth = token + "." + _jwkThumbprint(publicJwk);
1051
+ var digest = crypto.createHash("sha256").update(keyAuth, "utf8").digest();
1052
+ return {
1053
+ name: "_" + label + "._acme-challenge." + opts2.identifier,
1054
+ value: _b64u(digest),
1055
+ ttl: typeof opts2.ttl === "number" ? Math.floor(opts2.ttl) : (C.TIME.minutes(1) / C.TIME.seconds(1)),
1056
+ };
1057
+ }
1058
+
899
1059
  return Object.freeze({
900
1060
  fetchDirectory: fetchDirectory,
901
1061
  newAccount: newAccount,
@@ -908,6 +1068,8 @@ function create(opts) {
908
1068
  accountKeyRollover: accountKeyRollover,
909
1069
  deactivateAccount: deactivateAccount,
910
1070
  tlsAlpn01KeyAuthorization: tlsAlpn01KeyAuthorization,
1071
+ listProfiles: listProfiles,
1072
+ dnsAccount01ChallengeRecord: dnsAccount01ChallengeRecord,
911
1073
  accountUrl: function () { return state.accountUrl; },
912
1074
  directory: function () { return state.directory; },
913
1075
  publicJwk: function () { return Object.assign({}, publicJwk); },
@@ -955,6 +1117,28 @@ function _sleep(ms) {
955
1117
  });
956
1118
  }
957
1119
 
1120
+ // RFC 4648 §6 base32 lowercase (no padding) — used by
1121
+ // draft-ietf-acme-dns-account-label to derive the 80-bit account label
1122
+ // from SHA-256(accountUrl). 5-bit groups MSB-first.
1123
+ function _base32lc(buf) {
1124
+ var alphabet = "abcdefghijklmnopqrstuvwxyz234567";
1125
+ var out = "";
1126
+ var bits = 0;
1127
+ var value = 0;
1128
+ for (var i = 0; i < buf.length; i += 1) {
1129
+ value = (value << 8) | buf[i]; // allow:raw-byte-literal — bit-shift count, byte boundary
1130
+ bits += 8; // allow:raw-byte-literal — bits-per-byte constant
1131
+ while (bits >= 5) {
1132
+ out += alphabet[(value >>> (bits - 5)) & 31];
1133
+ bits -= 5;
1134
+ }
1135
+ }
1136
+ if (bits > 0) {
1137
+ out += alphabet[(value << (5 - bits)) & 31];
1138
+ }
1139
+ return out;
1140
+ }
1141
+
958
1142
  module.exports = {
959
1143
  create: create,
960
1144
  AcmeError: AcmeError,
package/lib/audit.js CHANGED
@@ -294,6 +294,7 @@ var FRAMEWORK_NAMESPACES = [
294
294
  "mailbimi", // b.mail.bimi (mail.bimi.vmc.fetched / verified — RFC 9091 VMC chain validation)
295
295
  "localdb", // b.localDb.thin (localdb.thin.opened / recovered / closed — desktop-daemon SQLite wrapper)
296
296
  "dataact", // b.dataAct (EU Data Act 2023/2854 — product_declared / user_access / share_with_third_party / share_refused / switch_request)
297
+ "idempotency", // b.middleware.idempotencyKey (idempotency.missing_key / bad_key / replay / key_reuse_mismatch / cache_store / store_read_failed / store_write_failed / skip_5xx / body_too_large — draft-ietf-httpapi-idempotency-key)
297
298
  ];
298
299
  var registeredNamespaces = new Set(FRAMEWORK_NAMESPACES);
299
300
 
@@ -0,0 +1,288 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.cacheStatus
4
+ * @nav HTTP
5
+ * @title RFC 9211 Cache-Status
6
+ * @order 310
7
+ *
8
+ * @intro
9
+ * RFC 9211 Cache-Status response header builder + parser. The
10
+ * `Cache-Status` header documents which intermediate cache (CDN,
11
+ * reverse proxy, application cache) handled a request — operators
12
+ * diagnosing why a request was slow / stale / not-cached read the
13
+ * header and see the entire cache-decision chain instead of
14
+ * guessing from elapsed-time metrics.
15
+ *
16
+ * Each cache in the response path appends a comma-separated entry:
17
+ *
18
+ * Cache-Status: ExampleCache; hit; fwd=stale; ttl=600
19
+ *
20
+ * Where:
21
+ * - The first token is the cache identifier (sf-string)
22
+ * - Parameters follow as `key` or `key=value` pairs
23
+ * - Standard parameters per RFC 9211 §2: `hit`, `fwd`, `fwd-status`,
24
+ * `ttl`, `stored`, `collapsed`, `key`, `detail`
25
+ *
26
+ * `b.cacheStatus.append(prevHeader, entry)` builds a single
27
+ * well-formed entry and appends to whatever previous caches in the
28
+ * chain wrote. `b.cacheStatus.parse(headerValue)` returns the
29
+ * parsed chain as an array of `{ cache, params }` records.
30
+ *
31
+ * @card
32
+ * RFC 9211 Cache-Status header — documents which intermediate caches handled a request with structured `hit` / `fwd` / `ttl` parameters so operators diagnose cache-decision chains.
33
+ */
34
+
35
+ var validateOpts = require("./validate-opts");
36
+ var { defineClass } = require("./framework-error");
37
+
38
+ var CacheStatusError = defineClass("CacheStatusError", { alwaysPermanent: true });
39
+
40
+ // RFC 9211 §2 — cache identifier is a Structured-Fields Item: sf-token
41
+ // (RFC 8941 §3.3.4) OR sf-string. We accept sf-token shape bare; an
42
+ // operator wanting an identifier with sf-delimiter chars (comma /
43
+ // semicolon / quote / backslash / whitespace) can emit it quoted via
44
+ // the operator-side sf-string form themselves, but this builder
45
+ // refuses raw delimiters since they would split into multiple list
46
+ // members or break the parameter grammar downstream. Token grammar
47
+ // per RFC 8941: starts with ALPHA or "*", continues with tchar / ":"
48
+ // / "/". tchar excludes `, ; " \ space and all controls.
49
+ var CACHE_NAME_RE = /^[A-Za-z*][!#$%&'*+\-.^_`|~0-9A-Za-z:/]*$/; // allow:duplicate-regex — sf-token shape per RFC 8941 §3.3.4
50
+ var CACHE_NAME_MAX = 128; // allow:raw-byte-literal — cache-name length cap, not bytes
51
+ var FWD_VALUES = Object.freeze(["bypass", "method", "uri-miss", "vary-miss", "miss", "request", "stale", "partial"]);
52
+ var BOOLEAN_PARAMS = Object.freeze(["hit", "stored", "collapsed"]);
53
+ // Reserved parameter names per RFC 9211 §2 — the framework knows their
54
+ // semantics (hit/stored/collapsed are flags, fwd is enum, ttl is number,
55
+ // fwd-status is HTTP status, key + detail are sf-strings). Operators
56
+ // passing other keys get passed-through verbatim as token=value.
57
+ var KNOWN_PARAMS = Object.freeze(["hit", "fwd", "fwd-status", "ttl", "stored", "collapsed", "key", "detail"]);
58
+
59
+ function _sfStringQuote(s) {
60
+ // RFC 8941 sf-string — quoted-string with escaping for " and \.
61
+ // Operator-supplied detail/key strings get the full quote-escape.
62
+ return "\"" + String(s).replace(/\\/g, "\\\\").replace(/"/g, "\\\"") + "\"";
63
+ }
64
+
65
+ /**
66
+ * @primitive b.cacheStatus.append
67
+ * @signature b.cacheStatus.append(prevHeader, entry)
68
+ * @since 0.8.86
69
+ * @status stable
70
+ * @related b.cacheStatus.parse, b.cacheStatus.entry
71
+ *
72
+ * Append a Cache-Status entry to an existing chain header. `prevHeader`
73
+ * is the inbound Cache-Status string (empty / undefined / null means
74
+ * "this is the first entry"). `entry` is an object describing the
75
+ * current cache's decision. Returns the combined header string.
76
+ *
77
+ * @opts
78
+ * cache: string, // required — cache identifier (e.g. "ExampleCDN")
79
+ * hit: boolean, // true if served from cache
80
+ * fwd: string, // one of: bypass | method | uri-miss | vary-miss
81
+ * // | miss | request | stale | partial
82
+ * fwdStatus: number, // HTTP status the upstream returned (when fwd)
83
+ * ttl: number, // remaining freshness lifetime in seconds
84
+ * stored: boolean, // true if the response was newly stored
85
+ * collapsed: boolean, // true if request-collapsing merged this with another
86
+ * key: string, // operator-defined cache-key shape
87
+ * detail: string, // free-form diagnostic note
88
+ *
89
+ * @example
90
+ * res.setHeader("Cache-Status",
91
+ * b.cacheStatus.append(req.headers["cache-status"], {
92
+ * cache: "blamejs",
93
+ * hit: false,
94
+ * fwd: "miss",
95
+ * stored: true,
96
+ * ttl: 3600,
97
+ * }));
98
+ * // → "ExampleCDN; hit; ttl=300, blamejs; fwd=miss; stored; ttl=3600"
99
+ */
100
+ function append(prevHeader, entry) {
101
+ var formatted = entryString(entry);
102
+ if (typeof prevHeader === "string" && prevHeader.length > 0) {
103
+ return prevHeader + ", " + formatted;
104
+ }
105
+ return formatted;
106
+ }
107
+
108
+ /**
109
+ * @primitive b.cacheStatus.entry
110
+ * @signature b.cacheStatus.entry(entry)
111
+ * @since 0.8.86
112
+ * @status stable
113
+ * @related b.cacheStatus.append, b.cacheStatus.parse
114
+ *
115
+ * Format a single Cache-Status entry without combining with a prior
116
+ * chain. Useful when the operator wants to write the header without
117
+ * regard to upstream entries (e.g. an origin-only deployment).
118
+ *
119
+ * @example
120
+ * res.setHeader("Cache-Status", b.cacheStatus.entry({
121
+ * cache: "blamejs", hit: true, ttl: 600,
122
+ * }));
123
+ * // → "blamejs; hit; ttl=600"
124
+ */
125
+ function entryString(entry) {
126
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
127
+ throw new CacheStatusError("cache-status/bad-entry",
128
+ "entry must be a non-null object", true);
129
+ }
130
+ validateOpts.requireNonEmptyString(
131
+ entry.cache, "entry.cache", CacheStatusError, "cache-status/bad-cache-name");
132
+ if (entry.cache.length > CACHE_NAME_MAX || !CACHE_NAME_RE.test(entry.cache)) {
133
+ throw new CacheStatusError("cache-status/bad-cache-name",
134
+ "entry.cache '" + entry.cache + "' must be a structured-fields token " +
135
+ "(RFC 8941 §3.3.4: starts with ALPHA or '*', uses tchar / ':' / '/' only — " +
136
+ "no comma / semicolon / quote / backslash / whitespace) and <= " +
137
+ CACHE_NAME_MAX + " chars. Quote-and-escape an operator-supplied label " +
138
+ "via b.cacheStatus.entry({ ..., key: '<label>' }) instead.");
139
+ }
140
+ var parts = [entry.cache];
141
+
142
+ // Booleans — emit as bare-token when truthy.
143
+ for (var i = 0; i < BOOLEAN_PARAMS.length; i += 1) {
144
+ if (entry[BOOLEAN_PARAMS[i]] === true) parts.push(BOOLEAN_PARAMS[i]);
145
+ }
146
+
147
+ if (entry.fwd !== undefined && entry.fwd !== null) {
148
+ if (typeof entry.fwd !== "string" || FWD_VALUES.indexOf(entry.fwd) === -1) {
149
+ throw new CacheStatusError("cache-status/bad-fwd",
150
+ "entry.fwd must be one of " + FWD_VALUES.join(", "));
151
+ }
152
+ parts.push("fwd=" + entry.fwd);
153
+ }
154
+ if (entry.fwdStatus !== undefined && entry.fwdStatus !== null) {
155
+ if (typeof entry.fwdStatus !== "number" || !Number.isInteger(entry.fwdStatus) ||
156
+ entry.fwdStatus < 100 || entry.fwdStatus > 599) { // allow:raw-byte-literal — HTTP status range
157
+ throw new CacheStatusError("cache-status/bad-fwd-status",
158
+ "entry.fwdStatus must be an integer 100..599");
159
+ }
160
+ parts.push("fwd-status=" + entry.fwdStatus);
161
+ }
162
+ if (entry.ttl !== undefined && entry.ttl !== null) {
163
+ // RFC 9211 §2.2 — ttl is a signed Integer. Negative values are
164
+ // explicitly valid: a `hit` paired with `ttl=-30` reports the
165
+ // response was served stale by 30 seconds (typically with
166
+ // `fwd=stale`). Refusing negatives would block the very scenario
167
+ // `fwd=stale` exists to surface.
168
+ if (typeof entry.ttl !== "number" || !Number.isInteger(entry.ttl)) {
169
+ throw new CacheStatusError("cache-status/bad-ttl",
170
+ "entry.ttl must be an integer (negative permitted for stale-cache hits per RFC 9211 §2.2)");
171
+ }
172
+ parts.push("ttl=" + entry.ttl);
173
+ }
174
+ if (entry.key !== undefined && entry.key !== null) {
175
+ if (typeof entry.key !== "string") {
176
+ throw new CacheStatusError("cache-status/bad-key",
177
+ "entry.key must be a string when provided");
178
+ }
179
+ parts.push("key=" + _sfStringQuote(entry.key));
180
+ }
181
+ if (entry.detail !== undefined && entry.detail !== null) {
182
+ if (typeof entry.detail !== "string") {
183
+ throw new CacheStatusError("cache-status/bad-detail",
184
+ "entry.detail must be a string when provided");
185
+ }
186
+ parts.push("detail=" + _sfStringQuote(entry.detail));
187
+ }
188
+ return parts.join("; ");
189
+ }
190
+
191
+ /**
192
+ * @primitive b.cacheStatus.parse
193
+ * @signature b.cacheStatus.parse(headerValue)
194
+ * @since 0.8.86
195
+ * @status stable
196
+ * @related b.cacheStatus.append, b.cacheStatus.entry
197
+ *
198
+ * Parse a Cache-Status header into an array of `{ cache, params }`
199
+ * records, one per cache in the chain. The params object carries the
200
+ * RFC 9211 §2 standard parameters as proper types (`hit`/`stored`/
201
+ * `collapsed` as booleans, `ttl`/`fwdStatus` as numbers, `fwd` as the
202
+ * raw enum string, `key`/`detail` as unquoted strings). Unknown
203
+ * params survive as raw string values so operators inspecting custom
204
+ * cache implementations can read them.
205
+ *
206
+ * Empty / non-string / malformed inputs return `[]` — defensive
207
+ * request-shape reader returns sane defaults rather than throwing.
208
+ *
209
+ * @example
210
+ * var chain = b.cacheStatus.parse(
211
+ * 'ExampleCDN; hit; ttl=300, blamejs; fwd=miss; stored; ttl=3600');
212
+ * // chain[0] = { cache: "ExampleCDN", params: { hit: true, ttl: 300 } }
213
+ * // chain[1] = { cache: "blamejs", params: { fwd: "miss", stored: true, ttl: 3600 } }
214
+ */
215
+ function parse(headerValue) {
216
+ if (typeof headerValue !== "string" || headerValue.length === 0) return [];
217
+ var out = [];
218
+ // Split entries on commas NOT inside quoted strings.
219
+ var entries = _splitTopLevel(headerValue, ",");
220
+ for (var i = 0; i < entries.length; i += 1) {
221
+ var raw = entries[i].trim();
222
+ if (raw.length === 0) continue;
223
+ var fields = _splitTopLevel(raw, ";").map(function (s) { return s.trim(); });
224
+ var cache = fields.shift();
225
+ if (!cache) continue;
226
+ var params = {};
227
+ for (var j = 0; j < fields.length; j += 1) {
228
+ var f = fields[j];
229
+ if (f.length === 0) continue;
230
+ var eq = f.indexOf("=");
231
+ if (eq === -1) {
232
+ // Bare token — boolean
233
+ params[f] = true;
234
+ continue;
235
+ }
236
+ var name = f.slice(0, eq).trim();
237
+ var val = f.slice(eq + 1).trim();
238
+ params[_normalizeParamName(name)] = _parseParamValue(name, val);
239
+ }
240
+ out.push({ cache: cache, params: params });
241
+ }
242
+ return out;
243
+ }
244
+
245
+ function _normalizeParamName(n) {
246
+ // RFC 9211 §2 uses fwd-status as the canonical name; surface as
247
+ // `fwdStatus` in the parsed object for JS-natural access.
248
+ if (n === "fwd-status") return "fwdStatus";
249
+ return n;
250
+ }
251
+
252
+ function _parseParamValue(name, raw) {
253
+ if (raw.length >= 2 && raw.charAt(0) === "\"" && raw.charAt(raw.length - 1) === "\"") {
254
+ // sf-string — unquote + unescape.
255
+ return raw.slice(1, -1).replace(/\\(.)/g, "$1");
256
+ }
257
+ if (name === "ttl" || name === "fwd-status" || name === "fwdStatus") {
258
+ var n = Number(raw);
259
+ return Number.isFinite(n) ? n : raw;
260
+ }
261
+ return raw;
262
+ }
263
+
264
+ function _splitTopLevel(s, sep) {
265
+ var out = [];
266
+ var buf = "";
267
+ var inQuotes = false;
268
+ var escaped = false;
269
+ for (var i = 0; i < s.length; i += 1) {
270
+ var c = s.charAt(i);
271
+ if (escaped) { buf += c; escaped = false; continue; }
272
+ if (c === "\\" && inQuotes) { buf += c; escaped = true; continue; }
273
+ if (c === "\"") { inQuotes = !inQuotes; buf += c; continue; }
274
+ if (c === sep && !inQuotes) { out.push(buf); buf = ""; continue; }
275
+ buf += c;
276
+ }
277
+ if (buf.length > 0) out.push(buf);
278
+ return out;
279
+ }
280
+
281
+ module.exports = {
282
+ append: append,
283
+ entry: entryString,
284
+ parse: parse,
285
+ FWD_VALUES: FWD_VALUES,
286
+ KNOWN_PARAMS: KNOWN_PARAMS,
287
+ CacheStatusError: CacheStatusError,
288
+ };
package/lib/compliance.js CHANGED
@@ -201,6 +201,17 @@ var KNOWN_POSTURES = Object.freeze([
201
201
  "eu-cer", // EU Critical Entities Resilience Directive (2022/2557; transposition 2024-10-17)
202
202
  "eu-cyber-sol", // EU Cyber Solidarity Act (Regulation 2025/38; effective 2025-02-04)
203
203
  "eidas-2", // eIDAS 2 / EUDI Wallet (Regulation 2024/1183; rollout 2026-2027)
204
+ // ---- v0.8.86 expansion — sectoral + cybersecurity directives ----
205
+ "cmmc-2.0", // US DoD Cybersecurity Maturity Model Certification 2.0 (effective 2025-Q1)
206
+ "cjis-v6", // FBI Criminal Justice Information Services Security Policy v6.0 (Dec 2024)
207
+ "iso-27001-2022", // ISO/IEC 27001:2022 — Information Security Management System
208
+ "iso-27002-2022", // ISO/IEC 27002:2022 — Code of practice for information security controls
209
+ "iso-27017", // ISO/IEC 27017 — Cloud-services security controls
210
+ "iso-27018", // ISO/IEC 27018 — PII protection in public-cloud processors
211
+ "iso-27701", // ISO/IEC 27701 — Privacy Information Management System
212
+ "nist-800-66-r2", // NIST SP 800-66 Rev 2 — HIPAA Security Rule implementation guidance // allow:raw-byte-literal — NIST publication number, not bytes
213
+ "ehds", // EU European Health Data Space (Regulation 2025/327; phased 2027-2029)
214
+ "circia", // US Cyber Incident Reporting for Critical Infrastructure Act (final rule pending)
204
215
  ]);
205
216
 
206
217
  var STATE = { posture: null, setAt: null };
@@ -665,6 +676,17 @@ var REGIME_MAP = Object.freeze({
665
676
  "eu-cer": { name: "EU Critical Entities Resilience Directive", citation: "Directive (EU) 2022/2557 (transposition 2024-10-17)", jurisdiction: "EU", domain: "cybersecurity" },
666
677
  "eu-cyber-sol": { name: "EU Cyber Solidarity Act", citation: "Regulation (EU) 2025/38 (effective 2025-02-04)", jurisdiction: "EU", domain: "cybersecurity" },
667
678
  "eidas-2": { name: "eIDAS 2 / EUDI Wallet", citation: "Regulation (EU) 2024/1183 (rollout 2026-2027)", jurisdiction: "EU", domain: "identity" },
679
+ // ---- v0.8.86 — sectoral + cybersecurity directives ----
680
+ "cmmc-2.0": { name: "Cybersecurity Maturity Model Certification 2.0", citation: "32 CFR Part 170 (DFARS rule effective 2025-Q1)", jurisdiction: "US", domain: "cybersecurity" },
681
+ "cjis-v6": { name: "FBI CJIS Security Policy v6.0", citation: "CJIS Security Policy v6.0 (effective 2024-12)", jurisdiction: "US", domain: "law-enforcement" },
682
+ "iso-27001-2022": { name: "ISO/IEC 27001:2022 Information Security Management System", citation: "ISO/IEC 27001:2022", jurisdiction: "international", domain: "cybersecurity" },
683
+ "iso-27002-2022": { name: "ISO/IEC 27002:2022 Information Security Controls", citation: "ISO/IEC 27002:2022", jurisdiction: "international", domain: "cybersecurity" },
684
+ "iso-27017": { name: "ISO/IEC 27017 Cloud Services Security Controls", citation: "ISO/IEC 27017:2015", jurisdiction: "international", domain: "cybersecurity" },
685
+ "iso-27018": { name: "ISO/IEC 27018 PII Protection in Public Cloud", citation: "ISO/IEC 27018:2019", jurisdiction: "international", domain: "privacy" },
686
+ "iso-27701": { name: "ISO/IEC 27701 Privacy Information Management System", citation: "ISO/IEC 27701:2019", jurisdiction: "international", domain: "privacy" },
687
+ "nist-800-66-r2": { name: "NIST SP 800-66 Rev 2 — HIPAA Security Rule Guidance", citation: "NIST SP 800-66 Rev 2 (Feb 2024)", jurisdiction: "US", domain: "health" },
688
+ "ehds": { name: "European Health Data Space", citation: "Regulation (EU) 2025/327 (phased 2027-2029)", jurisdiction: "EU", domain: "health" },
689
+ "circia": { name: "Cyber Incident Reporting for Critical Infrastructure Act", citation: "6 U.S.C. §681 et seq. (final rule pending)", jurisdiction: "US", domain: "cybersecurity" },
668
690
  });
669
691
 
670
692
  /**
@@ -928,6 +950,20 @@ var POSTURE_DEFAULTS = Object.freeze({
928
950
  "eu-cer": Object.freeze({ backupEncryptionRequired: true, auditChainSignedRequired: true, tlsMinVersion: "TLSv1.3", requireVacuumAfterErase: false }),
929
951
  "eu-cyber-sol": Object.freeze({ backupEncryptionRequired: true, auditChainSignedRequired: true, tlsMinVersion: "TLSv1.3", requireVacuumAfterErase: false }),
930
952
  "eidas-2": Object.freeze({ backupEncryptionRequired: true, auditChainSignedRequired: true, tlsMinVersion: "TLSv1.3", requireVacuumAfterErase: true }),
953
+ // v0.8.86 — sectoral + cybersecurity directives. DoD CMMC + FBI
954
+ // CJIS + healthcare regimes share an encrypted-at-rest + signed-
955
+ // audit-chain floor; ISO 27001/27002 + ISO 27017/27018/27701 are
956
+ // operator-adopted governance standards with the same baseline.
957
+ "cmmc-2.0": Object.freeze({ backupEncryptionRequired: true, auditChainSignedRequired: true, tlsMinVersion: "TLSv1.3", requireVacuumAfterErase: true }),
958
+ "cjis-v6": Object.freeze({ backupEncryptionRequired: true, auditChainSignedRequired: true, tlsMinVersion: "TLSv1.3", requireVacuumAfterErase: true }),
959
+ "iso-27001-2022": Object.freeze({ backupEncryptionRequired: true, auditChainSignedRequired: true, tlsMinVersion: "TLSv1.3", requireVacuumAfterErase: true }),
960
+ "iso-27002-2022": Object.freeze({ backupEncryptionRequired: false, auditChainSignedRequired: true, tlsMinVersion: "TLSv1.3", requireVacuumAfterErase: false }),
961
+ "iso-27017": Object.freeze({ backupEncryptionRequired: true, auditChainSignedRequired: true, tlsMinVersion: "TLSv1.3", requireVacuumAfterErase: true }),
962
+ "iso-27018": Object.freeze({ backupEncryptionRequired: true, auditChainSignedRequired: true, tlsMinVersion: "TLSv1.3", requireVacuumAfterErase: true }),
963
+ "iso-27701": Object.freeze({ backupEncryptionRequired: false, auditChainSignedRequired: true, tlsMinVersion: "TLSv1.3", requireVacuumAfterErase: true }),
964
+ "nist-800-66-r2": Object.freeze({ backupEncryptionRequired: true, auditChainSignedRequired: true, tlsMinVersion: "TLSv1.3", requireVacuumAfterErase: true }),
965
+ "ehds": Object.freeze({ backupEncryptionRequired: true, auditChainSignedRequired: true, tlsMinVersion: "TLSv1.3", requireVacuumAfterErase: true }),
966
+ "circia": Object.freeze({ backupEncryptionRequired: false, auditChainSignedRequired: true, tlsMinVersion: "TLSv1.3", requireVacuumAfterErase: false }),
931
967
  });
932
968
 
933
969
  /**
@@ -602,6 +602,23 @@ var PublicSuffixError = defineClass("PublicSuffixError", { alwaysPermane
602
602
  // alwaysPermanent — every case is operator-shape or message-shape
603
603
  // errors that retry will not recover.
604
604
  var MailMdnError = defineClass("MailMdnError", { alwaysPermanent: true });
605
+ // ProblemDetailsError — b.problemDetails (lib/problem-details.js). RFC
606
+ // 9457 Problem Details for HTTP APIs builder + validator violations:
607
+ // bad opts at create/respond/validate, type/title/status/detail/
608
+ // instance shape mismatches, reserved-field collision in extensions,
609
+ // prototype-pollution-shaped extension keys, bad response object at
610
+ // respond(), bad inbound document shape. alwaysPermanent — every case
611
+ // is operator-shape or wire-shape errors that retry will not recover.
612
+ var ProblemDetailsError = defineClass("ProblemDetailsError", { alwaysPermanent: true });
613
+ // IdempotencyError — b.middleware.idempotencyKey (lib/middleware/
614
+ // idempotency-key.js). draft-ietf-httpapi-idempotency-key middleware
615
+ // violations: bad opts at create (missing store, bad ttl, bad methods
616
+ // list), bad idempotency key shape (non-string, too long, control
617
+ // chars), store-backend transport errors that exhausted retries.
618
+ // alwaysPermanent — every operator-facing failure is config-shape;
619
+ // transient store-backend failures route through audit signals so
620
+ // they don't escape as exceptions to the middleware caller.
621
+ var IdempotencyError = defineClass("IdempotencyError", { alwaysPermanent: true });
605
622
 
606
623
  module.exports = {
607
624
  FrameworkError: FrameworkError,
@@ -696,4 +713,6 @@ module.exports = {
696
713
  FidoMds3Error: FidoMds3Error,
697
714
  PublicSuffixError: PublicSuffixError,
698
715
  MailMdnError: MailMdnError,
716
+ ProblemDetailsError: ProblemDetailsError,
717
+ IdempotencyError: IdempotencyError,
699
718
  };