@blamejs/core 0.8.34 → 0.8.36

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.8.x
10
10
 
11
+ - **0.8.36** (2026-05-08) — HTTP/web G-class LOW cleanup + scope-aware bearer challenge. **WS handshake (RFC 6455 §4.1)** — `Sec-WebSocket-Key` validated as base64 of 16 random bytes (`/^[A-Za-z0-9+/]{22}==$/`). Pre-v0.8.36 only the presence was checked; truncated / arbitrary-token values flowed through. **Permissions-Policy default** — `fullscreen` flipped to `()` (deny) instead of `(self)`; operators wanting fullscreen pass an explicit override. **`b.middleware.bearerAuth` insufficient_scope (RFC 6750 §3)** — new `requiredScopes: ["scope1", "scope2"]` opt enforces operator-declared scopes. Token's `user.scope` (string, space-separated) or `user.scopes` (array) is checked; missing scopes refuse with HTTP 403 + `WWW-Authenticate: Bearer error="insufficient_scope", scope="..."`. **`b.requestHelpers.parseListHeader({strictToken: true})`** — RFC 9110 §5.6.2 token-grammar enforcement. Refuses non-token entries (anything outside `!#$%&'*+-.^_\\`|~` + alnum). **Multipart boundary validation (RFC 2046 §5.1.1)** — `_parseMultipart` refuses boundaries longer than 70 chars OR violating the `bcharsnospace` grammar. Closes the quadratic-match risk on pathological boundaries.
12
+
13
+ - **0.8.35** (2026-05-08) — `b.tcpa10dlc` + `b.iabMspa` primitives. **`b.tcpa10dlc`** — TCPA 10DLC consent-record audit. 47 USC §227 + 47 CFR §64.1200 + FCC 1:1 disclosure rule (effective 2025-01-27, vacated 11th Circuit IMC v. FCC 2025 but TCPA standard still applies). $500-$1,500/violation exposure. `recordConsent({phoneE164, brand, disclosureText, disclosurePartyKind, formUrl, ip, userAgent})` writes a tamper-evident audit row with the carrier-required fields; `lookup(phoneE164)` for the carrier "produce-on-demand" workflow; `revoke(phoneE164, reason)` records consumer-initiated opt-out with audit trail. **`b.iabMspa`** — IAB Multi-State Privacy Agreement / Global Privacy Platform (GPP) universal opt-out signal codec. `parseGpp(gppString)` decodes the framing (header + per-section payloads, section-id mapping for usnat / usca / usva / usco / usct / usut / usnv / usia / usde / usnj / ustx / usor / usmt / usnh). `checkOptOut(parsed, {dataUse, state})` returns `{ mustHonor, signals }` against operator-decoded section opt-outs (sale / sharing / targeted-ads / sensitive / child-data). `refuseProcessing(parsed, opts)` throws `IabMspaError` to halt the operator's data-flow at the same point a CCPA "do-not-sell" header would. `gpcFromHeaders(req)` reads the W3C `Sec-GPC: 1` browser signal — universal opt-out per CCPA / CPRA §1798.135(b)(1) and similar state laws.
14
+
11
15
  - **0.8.34** (2026-05-08) — `lib/middleware/body-parser.js` BiDi-strip regex uses Unicode-escape form (`\\u202A`-style) instead of literal codepoints to satisfy ESLint's `no-irregular-whitespace`. v0.8.33 publish workflow blocked on this; v0.8.33 tag exists on git but never reached npm — v0.8.34 cumulative includes the v0.8.33 G-class MEDIUM fixes.
12
16
 
13
17
  - **0.8.33** (2026-05-08) — HTTP/web G-class MEDIUM cleanup. Six RFC-cited refinements against shipped WebSocket / Permissions-Policy / JWT / body-parser / OAuth surfaces. **WS close-frame validation (RFC 6455 §5.5.1 + §7.4.2)** — `_handleClose` now refuses 1-byte close-frame payloads (malformed; pre-v0.8.33 silently accepted as clean close, evading anomaly detection) and validates the close-code against the §7.4.2 vocabulary (1000-1011 + 3000-4999 valid; 1004/1005/1006/1015 reserved/forbidden; everything else refused). **Permissions-Policy default expansion** — `b.middleware.securityHeaders` deny-by-default now covers `interest-cohort`, `attribution-reporting`, `bluetooth`, `hid`, `serial`, `idle-detection`, `local-fonts`, `compute-pressure`, `window-management`, `private-state-token-issuance`, `private-state-token-redemption`. **JWT typ assertion (RFC 8725 §3.11)** — `b.auth.jwt.verify({expectedTyp})` refuses tokens whose header.typ doesn't match (typ-confusion class). Case-insensitive match per RFC 8725. Unset `expectedTyp` skips the check (legacy token compatibility). **Multipart filename BiDi/RTL strip** — `_sanitizeFilename` now drops Trojan Source (CVE-2021-42574) BiDi formatting + zero-width codepoints from uploaded filenames before the basename hits the filesystem. **OAuth redirect_uri localhost exception (RFC 9700 §4.1.1)** — `b.auth.oauth.create({redirectUri})` now accepts `http://localhost` / `http://127.0.0.1` / `http://[::1]` without requiring `allowHttp: true` (which would loosen ALL operator-supplied URLs). HTTPS still required for non-loopback hosts.
package/index.js CHANGED
@@ -107,6 +107,8 @@ var fapi2 = require("./lib/fapi2");
107
107
  var contentCredentials = require("./lib/content-credentials");
108
108
  var aiPref = require("./lib/ai-pref");
109
109
  var fdx = require("./lib/fdx");
110
+ var tcpa10dlc = require("./lib/tcpa-10dlc");
111
+ var iabMspa = require("./lib/iab-mspa");
110
112
  var safeUrl = require("./lib/safe-url");
111
113
  var safeRedirect = require("./lib/safe-redirect");
112
114
  var pick = require("./lib/pick");
@@ -301,6 +303,8 @@ module.exports = {
301
303
  contentCredentials: contentCredentials,
302
304
  aiPref: aiPref,
303
305
  fdx: fdx,
306
+ tcpa10dlc: tcpa10dlc,
307
+ iabMspa: iabMspa,
304
308
  safeUrl: safeUrl,
305
309
  safeRedirect: safeRedirect,
306
310
  pick: pick,
package/lib/audit.js CHANGED
@@ -245,6 +245,8 @@ var FRAMEWORK_NAMESPACES = [
245
245
  "contentcredentials", // b.contentCredentials (contentcredentials.signed / verified)
246
246
  "aipref", // b.aiPref (aipref.paid_crawl_refused)
247
247
  "fdx", // b.fdx (fdx.bound / fdx.consent_receipt_issued)
248
+ "tcpa10dlc", // b.tcpa10dlc (tcpa10dlc.consent_recorded / consent_revoked)
249
+ "iabmspa", // b.iabMspa (iabmspa.processing_refused)
248
250
  ];
249
251
  var registeredNamespaces = new Set(FRAMEWORK_NAMESPACES);
250
252
 
@@ -0,0 +1,177 @@
1
+ "use strict";
2
+ /**
3
+ * b.iabMspa — IAB Multi-State Privacy Agreement / Global Privacy
4
+ * Platform (GPP) universal opt-out signal codec.
5
+ *
6
+ * GPP (https://github.com/InteractiveAdvertisingBureau/Global-
7
+ * Privacy-Platform) is the IAB's successor to the patchwork of
8
+ * per-state US privacy strings. A GPP string carries multiple
9
+ * sections separated by `~`, each tagged with a section ID. The
10
+ * MSPA-relevant sections are the US national + state sections
11
+ * (USNAT, USCA, USVA, USCO, USCT, USUT) carrying:
12
+ *
13
+ * - SaleOptOut, SharingOptOut, TargetedAdvertisingOptOut, Sensitive-
14
+ * DataProcessingOptOuts, KnownChildSensitiveData
15
+ * - GPC (Global Privacy Control browser signal mirror)
16
+ * - MSPA service-provider / opted-in flags
17
+ *
18
+ * Public API:
19
+ *
20
+ * b.iabMspa.parseGpp(gppString) -> { header, sections }
21
+ * header: { version, sectionIds[], gpcSignal? }
22
+ * sections: [{ id, optOuts: { sale, sharing, targetedAds, ... } }]
23
+ *
24
+ * b.iabMspa.checkOptOut(parsed, opts) -> { mustHonor, signals }
25
+ * opts: { dataUse: "sale" | "sharing" | "targeted-ads" |
26
+ * "sensitive" | "child-data", state? }
27
+ * Returns mustHonor=true when ANY in-scope section signals an
28
+ * opt-out for the requested use; signals lists which section IDs
29
+ * produced the verdict.
30
+ *
31
+ * b.iabMspa.refuseProcessing(parsed, opts)
32
+ * Throws IabMspaError when mustHonor → operator's data-flow code
33
+ * halts at the same point a CCPA "do-not-sell" header would.
34
+ *
35
+ * b.iabMspa.gpcFromHeaders(req) -> bool
36
+ * Reads the W3C `Sec-GPC: 1` browser header (RFC draft-davidson-
37
+ * httpbis-gpc-00). Universal opt-out per California CCPA / CPRA
38
+ * §1798.135(b)(1) and Colorado, Connecticut, etc.
39
+ */
40
+
41
+ var audit = require("./audit");
42
+ var { defineClass } = require("./framework-error");
43
+ var IabMspaError = defineClass("IabMspaError", { alwaysPermanent: true });
44
+
45
+ // GPP section IDs we recognize (subset — the full registry is at
46
+ // https://iabtechlab.com/standards/global-privacy-platform/sections).
47
+ var SECTION_IDS = {
48
+ 7: "usnat", // US National Privacy
49
+ 8: "usca", // California (CCPA / CPRA) // allow:raw-byte-literal — IAB GPP section ID, not bytes
50
+ 9: "usva", // Virginia
51
+ 10: "usco", // Colorado
52
+ 11: "usut", // Utah
53
+ 12: "usct", // Connecticut
54
+ 13: "usnv", // Nevada
55
+ 14: "usia", // Iowa
56
+ 15: "usde", // Delaware
57
+ 16: "usnj", // New Jersey // allow:raw-byte-literal — IAB GPP section ID, not bytes
58
+ 17: "ustx", // Texas (TDPSA)
59
+ 18: "usor", // Oregon
60
+ 19: "usmt", // Montana
61
+ 20: "usnh", // New Hampshire
62
+ };
63
+ var ALL_SECTIONS = Object.keys(SECTION_IDS).map(Number);
64
+ var DATA_USES = ["sale", "sharing", "targeted-ads", "sensitive", "child-data"];
65
+
66
+ function parseGpp(gppString) {
67
+ if (typeof gppString !== "string" || gppString.length === 0) {
68
+ throw IabMspaError.factory("BAD_INPUT",
69
+ "iabMspa.parseGpp: gppString required");
70
+ }
71
+ if (gppString.length > 8192) { // allow:raw-byte-literal — GPP string cap, not bytes
72
+ throw IabMspaError.factory("INPUT_TOO_LARGE",
73
+ "iabMspa.parseGpp: gppString exceeds 8192 chars");
74
+ }
75
+ // GPP framing: <header>~<section1>~<section2>...
76
+ // The header carries a 6-bit version + an int-list of section IDs.
77
+ // A full GPP decoder is substantial — for the framework's
78
+ // refuse-on-opt-out usage we only need the header's section ID
79
+ // list + per-section opt-out flags. The decoder below is the
80
+ // simplest correct partial parse: it splits sections, identifies
81
+ // each by the leading section-ID claim in the header, and exposes
82
+ // per-section raw payloads for downstream operator-specific
83
+ // decoding.
84
+ var parts = gppString.split("~");
85
+ if (parts.length === 0) {
86
+ return { header: { version: null, sectionIds: [] }, sections: [] };
87
+ }
88
+ // The first segment is the header. We don't fully decode it here;
89
+ // operator-side libraries (iabtcf-core / @iabtechlab/gpp-cmp) own
90
+ // the binary tag layout. We do extract the trailing claim list
91
+ // when present (some GPP strings encode the list as a comma-
92
+ // separated trailer like `header,7,8,9`).
93
+ var header = { raw: parts[0], version: null, sectionIds: [] };
94
+ var sectionPayloads = parts.slice(1);
95
+ // Try to find a numeric-list tail in the header (heuristic).
96
+ var tailMatch = parts[0].match(/[A-Za-z0-9_-]+\.([0-9.]+)$/);
97
+ if (tailMatch) {
98
+ var ids = tailMatch[1].split(".").map(function (s) { return parseInt(s, 10); });
99
+ header.sectionIds = ids.filter(function (n) { return isFinite(n) && n > 0; });
100
+ }
101
+ // Build sections — pair sectionPayloads with sectionIds positionally.
102
+ var sections = [];
103
+ for (var i = 0; i < sectionPayloads.length; i += 1) {
104
+ var sid = header.sectionIds[i] || null;
105
+ sections.push({
106
+ id: sid,
107
+ idLabel: sid && SECTION_IDS[sid] || null,
108
+ raw: sectionPayloads[i],
109
+ // Operator decodes the per-section payload. The framework
110
+ // surfaces a header-only `optOuts` shape that operators can
111
+ // override by fully decoding their binary section.
112
+ optOuts: null,
113
+ });
114
+ }
115
+ return { header: header, sections: sections };
116
+ }
117
+
118
+ function checkOptOut(parsed, opts) {
119
+ if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.sections)) {
120
+ throw IabMspaError.factory("BAD_PARSED",
121
+ "iabMspa.checkOptOut: parsed object required (call parseGpp first)");
122
+ }
123
+ if (!opts || DATA_USES.indexOf(opts.dataUse) === -1) {
124
+ throw IabMspaError.factory("BAD_DATA_USE",
125
+ "iabMspa.checkOptOut: opts.dataUse must be one of " + DATA_USES.join(", "));
126
+ }
127
+ var signals = [];
128
+ for (var i = 0; i < parsed.sections.length; i += 1) {
129
+ var s = parsed.sections[i];
130
+ if (opts.state && s.idLabel !== opts.state.toLowerCase()) continue;
131
+ if (!s.optOuts) continue; // operator hasn't decoded the section
132
+ var hit = false;
133
+ if (opts.dataUse === "sale" && s.optOuts.sale === true) hit = true;
134
+ else if (opts.dataUse === "sharing" && s.optOuts.sharing === true) hit = true;
135
+ else if (opts.dataUse === "targeted-ads" && s.optOuts.targetedAds === true) hit = true;
136
+ else if (opts.dataUse === "sensitive" && s.optOuts.sensitive === true) hit = true;
137
+ else if (opts.dataUse === "child-data" && s.optOuts.childData === true) hit = true;
138
+ if (hit) signals.push(s.idLabel || ("section-" + s.id));
139
+ }
140
+ return { mustHonor: signals.length > 0, signals: signals };
141
+ }
142
+
143
+ function refuseProcessing(parsed, opts) {
144
+ var rv = checkOptOut(parsed, opts);
145
+ if (rv.mustHonor) {
146
+ audit.safeEmit({
147
+ action: "iabmspa.processing_refused",
148
+ outcome: "denied",
149
+ metadata: {
150
+ dataUse: opts.dataUse,
151
+ state: opts.state || null,
152
+ signals: rv.signals,
153
+ },
154
+ });
155
+ throw IabMspaError.factory("OPT_OUT_HONORED",
156
+ "iabMspa: opt-out signal must be honored for dataUse='" + opts.dataUse +
157
+ "' (signals: " + rv.signals.join(", ") + ")");
158
+ }
159
+ return rv;
160
+ }
161
+
162
+ function gpcFromHeaders(req) {
163
+ if (!req || !req.headers) return false;
164
+ var h = req.headers["sec-gpc"];
165
+ return h === "1" || h === 1;
166
+ }
167
+
168
+ module.exports = {
169
+ parseGpp: parseGpp,
170
+ checkOptOut: checkOptOut,
171
+ refuseProcessing: refuseProcessing,
172
+ gpcFromHeaders: gpcFromHeaders,
173
+ SECTION_IDS: Object.assign({}, SECTION_IDS),
174
+ ALL_SECTIONS: ALL_SECTIONS.slice(),
175
+ DATA_USES: DATA_USES.slice(),
176
+ IabMspaError: IabMspaError,
177
+ };
@@ -199,6 +199,42 @@ function create(opts) {
199
199
  return;
200
200
  }
201
201
 
202
+ // RFC 6750 §3 — `insufficient_scope` challenge with `scope=` when
203
+ // the verified token is missing one or more required scopes.
204
+ // Operators pass `requiredScopes: ["write", "admin"]` to enforce.
205
+ // The verifier returns the user's scope list at `user.scope`
206
+ // (string, space-separated) OR `user.scopes` (array). When the
207
+ // request lacks a required scope, refuse with 403 + the standard
208
+ // challenge (NOT 401 — token was valid).
209
+ if (Array.isArray(opts.requiredScopes) && opts.requiredScopes.length > 0) {
210
+ var userScopes = Array.isArray(user.scopes) ? user.scopes :
211
+ typeof user.scope === "string" ? user.scope.split(/\s+/).filter(function (s) { return s.length > 0; }) :
212
+ [];
213
+ var missing = opts.requiredScopes.filter(function (s) {
214
+ return userScopes.indexOf(s) === -1;
215
+ });
216
+ if (missing.length > 0) {
217
+ _emitAudit("auth.bearer.failure", "failure", req, "insufficient-scope:" + missing.join(","));
218
+ _emitObs("auth.bearer.rejected", 1, { reason: "insufficient-scope" });
219
+ if (!res.headersSent) {
220
+ var scopeChallenge = scheme + ' error="insufficient_scope"' +
221
+ ', scope="' + opts.requiredScopes.join(" ") + '"' +
222
+ (realm ? ', realm="' + realm + '"' : "");
223
+ var scopeBody = JSON.stringify({
224
+ error: "insufficient_scope",
225
+ required: opts.requiredScopes.slice(),
226
+ });
227
+ res.writeHead(403, { // allow:raw-byte-literal — HTTP 403 status
228
+ "Content-Type": "application/json; charset=utf-8",
229
+ "Content-Length": Buffer.byteLength(scopeBody),
230
+ "WWW-Authenticate": scopeChallenge,
231
+ });
232
+ res.end(scopeBody);
233
+ }
234
+ return;
235
+ }
236
+ }
237
+
202
238
  req[tokenAttach] = token;
203
239
  req[userAttach] = user;
204
240
  // Signal to attach-user (and any other downstream auth middleware)
@@ -568,6 +568,19 @@ async function _parseMultipart(req, opts, ctParams) {
568
568
  true, HTTP_STATUS.BAD_REQUEST
569
569
  );
570
570
  }
571
+ // RFC 2046 §5.1.1 — boundary length 1-70 chars, bcharsnospace
572
+ // grammar. Pathological boundaries (zero-length / very long /
573
+ // newlines) drive quadratic match cost in scanners. Refuse at
574
+ // the parse boundary so the rest of the engine doesn't have to
575
+ // defend against them.
576
+ if (boundary.length > 70 || // allow:raw-byte-literal — RFC 2046 §5.1.1 boundary length cap
577
+ !/^[A-Za-z0-9'()+_,\-./:=?]{1,70}$/.test(boundary)) { // allow:raw-byte-literal — RFC 2046 §5.1.1 bchars + cap
578
+ throw new BodyParserError(
579
+ "body-parser/multipart-bad-boundary",
580
+ "multipart boundary violates RFC 2046 §5.1.1 (1-70 chars, bcharsnospace grammar)",
581
+ true, HTTP_STATUS.BAD_REQUEST
582
+ );
583
+ }
571
584
  // Resolve tmpDir per-request so directory-creation failure surfaces as a
572
585
  // structured error rather than a deferred fs throw.
573
586
  var tmpDir = opts.tmpDir || path.join(os.tmpdir(), "blamejs-uploads");
@@ -40,7 +40,7 @@ var validateOpts = require("../validate-opts");
40
40
 
41
41
  var DEFAULT_PERMISSIONS = [
42
42
  "accelerometer=()", "ambient-light-sensor=()", "autoplay=()",
43
- "camera=()", "display-capture=()", "encrypted-media=()", "fullscreen=(self)",
43
+ "camera=()", "display-capture=()", "encrypted-media=()", "fullscreen=()",
44
44
  "geolocation=()", "gyroscope=()", "magnetometer=()", "microphone=()",
45
45
  "midi=()", "payment=()", "picture-in-picture=()", "publickey-credentials-get=()",
46
46
  "screen-wake-lock=()", "sync-xhr=()", "usb=()", "web-share=()", "xr-spatial-tracking=()",
@@ -212,6 +212,13 @@ function requestProtocol(req, opts) {
212
212
  // Tolerant read: non-string input returns [] — these are read from
213
213
  // request headers that the network might omit. Callers needing stricter
214
214
  // checks layer their own validation on the result.
215
+ // RFC 9110 §5.6.2 token grammar — letters, digits, and the
216
+ // punctuation set `!#$%&'*+-.^_`|~`. Used by header-list parsers
217
+ // that consume protocol tokens (Connection, Sec-WebSocket-
218
+ // Protocol, etc.). Operator handlers parsing comma-separated
219
+ // human-supplied values (Origin lists, etc.) opt out by passing
220
+ // `lax: true`.
221
+ var RFC_9110_TOKEN_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
215
222
  function parseListHeader(value, opts) {
216
223
  if (value == null) return [];
217
224
  opts = opts || {};
@@ -222,6 +229,13 @@ function parseListHeader(value, opts) {
222
229
  for (var i = 0; i < parts.length; i++) {
223
230
  var t = parts[i].trim();
224
231
  if (t.length === 0) continue;
232
+ if (opts.strictToken && !RFC_9110_TOKEN_RE.test(t)) {
233
+ // Refuse non-token entries when caller asked for strict-token
234
+ // grammar (RFC 9110 §5.6.2). Used by ws subprotocol negotiation
235
+ // and other places where only token-shaped values are valid.
236
+ throw new TypeError("parseListHeader: '" + t +
237
+ "' is not a valid RFC 9110 token");
238
+ }
225
239
  out.push(opts.lowercase ? t.toLowerCase() : t);
226
240
  }
227
241
  return out;
@@ -0,0 +1,175 @@
1
+ "use strict";
2
+ /**
3
+ * b.tcpa10dlc — TCPA 10DLC (10-Digit Long Code) consent-record audit
4
+ * primitive + FCC 1:1 prior-express-written-consent disclosure
5
+ * snapshot.
6
+ *
7
+ * Background: the Telephone Consumer Protection Act (47 USC §227) +
8
+ * its FCC implementing rules (47 CFR §64.1200) require carrier-
9
+ * shaped consent records before sending marketing or
10
+ * automated-dialing-system text messages. Penalties: $500-$1,500
11
+ * per violation. The 10DLC ecosystem (carrier-vetted A2P SMS
12
+ * routes) layers an additional consent-record requirement: every
13
+ * carrier-registered campaign must produce, on demand, the consent
14
+ * record for any phone number it texted.
15
+ *
16
+ * FCC 1:1 rule (effective 2025-01-27 — reaffirmed 2025-12) caps
17
+ * disclosed parties to 1 per consent — operators that previously
18
+ * collected blanket consent for "trusted partner network" must
19
+ * record an individual consent per third-party recipient. The rule
20
+ * was vacated by the 11th Circuit IMC v. FCC 2025 ruling but the
21
+ * underlying TCPA standard still requires "prior express written
22
+ * consent" for marketing autodial; FCC enforcement priorities still
23
+ * favor 1:1.
24
+ *
25
+ * The framework can't be the operator's SMS provider or campaign
26
+ * registrar. What it CAN do:
27
+ *
28
+ * - Capture the consent record in a tamper-evident audit-chain row
29
+ * with the carrier-required fields (consumer phone, opt-in
30
+ * timestamp, brand name, opt-in language verbatim, IP +
31
+ * user-agent + form URL).
32
+ * - Snapshot the 1:1 disclosure (single brand the consumer is
33
+ * consenting to receive messages from).
34
+ * - Support the carrier "produce on demand" workflow via
35
+ * `b.tcpa10dlc.lookup(phoneE164)` — returns the consent record
36
+ * for an audit response.
37
+ *
38
+ * Public API:
39
+ *
40
+ * b.tcpa10dlc.recordConsent(opts) -> consentRecord
41
+ * opts:
42
+ * phoneE164: "+15551234567" (E.164 format).
43
+ * brand: operator's registered brand name.
44
+ * disclosureText: the verbatim opt-in language shown to the
45
+ * consumer (regulator-facing record).
46
+ * disclosurePartyKind: "first-party" | "carrier-affiliate" |
47
+ * "campaign-registrar" — the role the brand
48
+ * plays per the 1:1 rule.
49
+ * formUrl: operator's URL where consent was captured.
50
+ * ip + userAgent: consumer's network identifiers.
51
+ * optInTimestamp: Unix-ms (default Date.now()).
52
+ * additional: arbitrary operator-supplied metadata
53
+ * (campaign-id, traffic source, A/B test cell).
54
+ *
55
+ * b.tcpa10dlc.lookup(phoneE164) -> consentRecord | null
56
+ *
57
+ * b.tcpa10dlc.revoke(phoneE164, reason) -> { revoked, at }
58
+ * Records the consumer-initiated opt-out. Carriers require
59
+ * revocation traceability — the audit row is the regulator-
60
+ * facing record.
61
+ */
62
+
63
+ var validateOpts = require("./validate-opts");
64
+ var audit = require("./audit");
65
+ var { defineClass } = require("./framework-error");
66
+ var Tcpa10dlcError = defineClass("Tcpa10dlcError", { alwaysPermanent: true });
67
+
68
+ var E164_RE = /^\+[1-9][0-9]{6,14}$/; // allow:raw-byte-literal — E.164 length range, not bytes
69
+ var DISCLOSURE_PARTIES = ["first-party", "carrier-affiliate", "campaign-registrar"];
70
+
71
+ var records = new Map(); // phoneE164 → record
72
+
73
+ function recordConsent(opts) {
74
+ if (!opts || typeof opts !== "object") {
75
+ throw Tcpa10dlcError.factory("BAD_OPTS",
76
+ "tcpa10dlc.recordConsent: opts required");
77
+ }
78
+ if (typeof opts.phoneE164 !== "string" || !E164_RE.test(opts.phoneE164)) {
79
+ throw Tcpa10dlcError.factory("BAD_PHONE",
80
+ "tcpa10dlc.recordConsent: phoneE164 must match " + E164_RE);
81
+ }
82
+ validateOpts.requireNonEmptyString(opts.brand,
83
+ "tcpa10dlc.recordConsent: brand", Tcpa10dlcError, "BAD_BRAND");
84
+ validateOpts.requireNonEmptyString(opts.disclosureText,
85
+ "tcpa10dlc.recordConsent: disclosureText", Tcpa10dlcError, "BAD_DISCLOSURE_TEXT");
86
+ validateOpts.requireNonEmptyString(opts.formUrl,
87
+ "tcpa10dlc.recordConsent: formUrl", Tcpa10dlcError, "BAD_FORM_URL");
88
+ if (DISCLOSURE_PARTIES.indexOf(opts.disclosurePartyKind) === -1) {
89
+ throw Tcpa10dlcError.factory("BAD_DISCLOSURE_PARTY",
90
+ "tcpa10dlc.recordConsent: disclosurePartyKind must be one of " +
91
+ DISCLOSURE_PARTIES.join(", "));
92
+ }
93
+
94
+ var optInAt = typeof opts.optInTimestamp === "number" ? opts.optInTimestamp : Date.now();
95
+ var record = Object.freeze({
96
+ phoneE164: opts.phoneE164,
97
+ brand: opts.brand,
98
+ disclosureText: opts.disclosureText,
99
+ disclosurePartyKind: opts.disclosurePartyKind,
100
+ formUrl: opts.formUrl,
101
+ ip: opts.ip || null,
102
+ userAgent: opts.userAgent || null,
103
+ optInTimestamp: optInAt,
104
+ optInTimestampIso: new Date(optInAt).toISOString(),
105
+ revoked: false,
106
+ revokedAt: null,
107
+ revokedReason: null,
108
+ additional: opts.additional || null,
109
+ citations: ["47-usc-227", "47-cfr-64.1200", "fcc-2024-1-1"],
110
+ });
111
+ records.set(opts.phoneE164, record);
112
+
113
+ if (opts.audit !== false) {
114
+ audit.safeEmit({
115
+ action: "tcpa10dlc.consent_recorded",
116
+ outcome: "success",
117
+ metadata: {
118
+ phoneE164: opts.phoneE164,
119
+ brand: opts.brand,
120
+ disclosurePartyKind: opts.disclosurePartyKind,
121
+ formUrl: opts.formUrl,
122
+ ip: opts.ip || null,
123
+ },
124
+ });
125
+ }
126
+ return record;
127
+ }
128
+
129
+ function lookup(phoneE164) {
130
+ if (typeof phoneE164 !== "string") return null;
131
+ return records.get(phoneE164) || null;
132
+ }
133
+
134
+ function revoke(phoneE164, reason) {
135
+ if (typeof phoneE164 !== "string" || !E164_RE.test(phoneE164)) {
136
+ throw Tcpa10dlcError.factory("BAD_PHONE",
137
+ "tcpa10dlc.revoke: phoneE164 must match " + E164_RE);
138
+ }
139
+ var existing = records.get(phoneE164);
140
+ if (!existing) {
141
+ throw Tcpa10dlcError.factory("NO_RECORD",
142
+ "tcpa10dlc.revoke: no consent record for " + phoneE164);
143
+ }
144
+ if (existing.revoked) {
145
+ return { revoked: true, at: existing.revokedAt };
146
+ }
147
+ var revokedAt = Date.now();
148
+ var updated = Object.freeze(Object.assign({}, existing, {
149
+ revoked: true,
150
+ revokedAt: revokedAt,
151
+ revokedAtIso: new Date(revokedAt).toISOString(),
152
+ revokedReason: typeof reason === "string" ? reason : null,
153
+ }));
154
+ records.set(phoneE164, updated);
155
+ audit.safeEmit({
156
+ action: "tcpa10dlc.consent_revoked",
157
+ outcome: "success",
158
+ metadata: {
159
+ phoneE164: phoneE164,
160
+ reason: reason || null,
161
+ },
162
+ });
163
+ return { revoked: true, at: revokedAt };
164
+ }
165
+
166
+ function _resetForTest() { records.clear(); }
167
+
168
+ module.exports = {
169
+ recordConsent: recordConsent,
170
+ lookup: lookup,
171
+ revoke: revoke,
172
+ DISCLOSURE_PARTIES: DISCLOSURE_PARTIES.slice(),
173
+ Tcpa10dlcError: Tcpa10dlcError,
174
+ _resetForTest: _resetForTest,
175
+ };
package/lib/websocket.js CHANGED
@@ -245,6 +245,16 @@ function validateUpgradeRequest(req, opts) {
245
245
  if (!h["sec-websocket-key"]) {
246
246
  return { ok: false, status: HTTP.BAD_REQUEST, reason: "missing Sec-WebSocket-Key" };
247
247
  }
248
+ // RFC 6455 §4.1 — Sec-WebSocket-Key MUST be a base64-encoded
249
+ // 16-byte nonce. Encoded length is 24 chars including the
250
+ // `==` padding. Strict check refuses malformed values that
251
+ // some clients send (truncated or arbitrary token); lets
252
+ // server-side anomaly detection see the malformation rather
253
+ // than passing through.
254
+ if (!/^[A-Za-z0-9+/]{22}==$/.test(h["sec-websocket-key"])) {
255
+ return { ok: false, status: HTTP.BAD_REQUEST,
256
+ reason: "Sec-WebSocket-Key must be base64 of 16 random bytes (RFC 6455 §4.1)" };
257
+ }
248
258
  if (h["sec-websocket-version"] !== "13") {
249
259
  return { ok: false, status: HTTP.BAD_REQUEST, reason: "Sec-WebSocket-Version must be 13" };
250
260
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.8.34",
3
+ "version": "0.8.36",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
@@ -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:3be80324-29fb-478d-819e-0b32ffd604d7",
5
+ "serialNumber": "urn:uuid:bc456840-498c-445d-b9a6-d087f3ab5715",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-07T15:22:30.612Z",
8
+ "timestamp": "2026-05-07T15:39:09.436Z",
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.8.34",
22
+ "bom-ref": "@blamejs/core@0.8.36",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.8.34",
25
+ "version": "0.8.36",
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.8.34",
29
+ "purl": "pkg:npm/%40blamejs/core@0.8.36",
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.8.34",
57
+ "ref": "@blamejs/core@0.8.36",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]