@blamejs/core 0.7.81 → 0.7.83

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.7.x
10
10
 
11
+ - **0.7.83** (2026-05-06) — `b.auth.oauth.endSessionUrl()` (OpenID Connect RP-Initiated Logout) + `b.auth.oauth.pushAuthorizationRequest()` (RFC 9126 PAR). **`endSessionUrl({ idTokenHint?, postLogoutRedirectUri?, state?, logoutHint?, uiLocales?, clientId?, extraParams? })`** builds the URL the operator's `/logout` route redirects the user-agent to so the IdP terminates the session and bounces back to the operator's app. The IdP's `end_session_endpoint` is read from the OIDC discovery document or operator-supplied at `create({ endSessionEndpoint })`. **`pushAuthorizationRequest({ state?, nonce?, prompt?, loginHint?, maxAge?, extraParams? })`** POSTs the authorization-request parameters directly to the IdP's PAR endpoint (mTLS or client-secret authenticated) and returns `{ url, state, nonce, verifier, challenge, requestUri, expiresIn }` — the browser-side redirect URL is `<authorizationEndpoint>?client_id=...&request_uri=...`. Defends against authorization-request parameter tampering by an MITM at the user-agent + against URL-length overflow on long authorization requests (request_uri reference is short). The PAR endpoint is read from the discovery doc's `pushed_authorization_request_endpoint` or operator-supplied at `create({ pushedAuthorizationRequestEndpoint })`. Both helpers throw `auth-oauth/no-end-session-endpoint` / `auth-oauth/no-par-endpoint` when neither the discovery doc nor the operator opts supply the endpoint.
12
+
13
+ - **0.7.82** (2026-05-06) — `b.mail.bimi` — BIMI record builder + verifier (RFC 9091). BIMI publishes a sender's brand logo URL in DNS so receiving MTAs can render it next to the message in supported clients (Gmail, Yahoo, Apple Mail). **`b.mail.bimi.recordShape({ logoUrl, vmcUrl?, selector? })`** produces the canonical `v=BIMI1; l=https://...; a=https://...` TXT-record string per RFC 9091 §4. Both `l=` (SVG logo URL) and `a=` (Verified Mark Certificate URL per RFC 9091 §6) are HTTPS-required (refuses `http://`). All field values are CR/LF/NUL/semicolon-screened so a hostile URL can't inject a record-separator into the published TXT. **`b.mail.bimi.fetchPolicy(domain, { selector?, dnsLookup? })`** queries `<selector>._bimi.<domain>` (default selector `"default"`) and returns the structured record `{ v, l, a }` or `null` when no policy is published / record is malformed. **`b.mail.bimi.parseRecord(text)`** parses any operator-supplied TXT body — semicolon-separated `key=value` pairs per RFC 9091 §4. The framework does NOT validate the SVG / VMC contents against the RFC §5/§6 profiles — operators feed those to their own asset pipeline; the fetch primitive is a thin DNS lookup that returns the structured record so an operator dashboard or SMTP send-time preflight can verify the publication. BIMI is layered on a passing DMARC posture (the receiver requires DMARC quarantine/reject); operators with the existing `b.mail.dmarc` posture set up benefit from BIMI rendering immediately on receivers that honor it.
14
+
11
15
  - **0.7.81** (2026-05-06) — `b.middleware.hostAllowlist` — DNS rebinding defense. Refuses requests whose `Host` header doesn't match the operator-supplied allowlist; the DNS rebinding chain (attacker DNS flips evil.com → 127.0.0.1, browser still believes the URL string says "evil.com" so same-origin policy lets the JS read the response, but the operator's localhost is what actually serves) is closed by checking the post-DNS-resolution `Host` header on the framework's side. **`b.middleware.hostAllowlist({ hosts, denyStatus?, denyBody?, audit? })`** — operators pass an allowlist of canonical Host values (with or without port). Wildcard-leading entries (`*.example.com`) match any single label; `app.sub.example.com` does NOT match `*.example.com` (multi-label rejected by design — a wildcard certificate authority issues only single-label intermediate). Entries without a port match any port; entries with a port require exact match. Default `denyStatus: 421` (RFC 7540 §9.1.2 "Misdirected Request"); default `denyBody: "Misdirected Request"`. Audit emits `network.host_allowlist.denied` with the reason (`missing-host` / `host-not-in-allowlist`) and the actual Host value for triage. Operators running explicitly-public services that accept arbitrary subdomains (multi-tenant forum shapes) skip this middleware entirely; there's no per-request opt-out.
12
16
 
13
17
  - **0.7.80** (2026-05-06) — `b.middleware.securityTxt` (RFC 9116) + SQLite `secure_delete=ON` at DB boot. **`b.middleware.securityTxt({ contact, expires, encryption?, policy?, ack?, preferredLanguages?, hiring?, canonical?, alsoAtRoot?, audit? })`** serves a static body at `/.well-known/security.txt` (and root `/security.txt` when `alsoAtRoot: true`) per RFC 9116. Operators wire it on their app so security researchers know where to find the disclosure policy. The `Contact:` and `Expires:` fields are REQUIRED per §2.5; the framework throws at config-time when either is missing AND when `expires` is in the past (RFC 9116 §2.5.5). All field values are CR/LF/NUL-screened (header injection defense). Body is built once at create() and served with `Content-Length` + `Cache-Control: public, max-age=86400` + `X-Content-Type-Options: nosniff`. **SQLite `PRAGMA secure_delete=ON`** is now applied at `b.db.init` time alongside the existing PRAGMA block. SQLite normally just unlinks rows from the B-tree; the underlying page bytes survive on disk until a new write reuses the slot. With `secure_delete=ON`, freed pages are overwritten with zeros so a forensic recovery against the encrypted database file can't reconstruct deleted rows. The cost is one extra write per delete — already dominated by the framework's audit-chain emissions on every DSR erase / cascade fan-out.
package/lib/auth/oauth.js CHANGED
@@ -349,6 +349,10 @@ function create(opts) {
349
349
  userinfoEndpoint: opts.userinfoEndpoint || (preset && preset.userinfoEndpoint) || null,
350
350
  revocationEndpoint: opts.revocationEndpoint || (preset && preset.revocationEndpoint) || null,
351
351
  jwksUri: opts.jwksUri || (preset && preset.jwksUri) || null,
352
+ endSessionEndpoint: opts.endSessionEndpoint || (preset && preset.endSessionEndpoint) || null,
353
+ pushedAuthorizationRequestEndpoint:
354
+ opts.pushedAuthorizationRequestEndpoint ||
355
+ (preset && preset.pushedAuthorizationRequestEndpoint) || null,
352
356
  };
353
357
 
354
358
  // Discovery + JWKS caches use b.cache.create + .wrap so concurrent
@@ -422,6 +426,8 @@ function create(opts) {
422
426
  userinfoEndpoint: "userinfo_endpoint",
423
427
  revocationEndpoint: "revocation_endpoint",
424
428
  jwksUri: "jwks_uri",
429
+ endSessionEndpoint: "end_session_endpoint",
430
+ pushedAuthorizationRequestEndpoint: "pushed_authorization_request_endpoint",
425
431
  })[name];
426
432
  var endpoint = config[snake];
427
433
  if (!endpoint) {
@@ -697,6 +703,105 @@ function create(opts) {
697
703
  return { header: header, claims: payload };
698
704
  }
699
705
 
706
+ // ---- OIDC RP-Initiated Logout (OpenID Connect Session Mgmt 1.0) ----
707
+ //
708
+ // The IdP exposes an `end_session_endpoint` in its discovery doc;
709
+ // the RP-initiated logout flow redirects the user to that endpoint
710
+ // with the id_token_hint + post_logout_redirect_uri so the IdP
711
+ // terminates the IdP session and bounces the user back to the
712
+ // operator's app. Operators wire this on their /logout route.
713
+ async function endSessionUrl(uopts) {
714
+ uopts = uopts || {};
715
+ var endpoint;
716
+ try { endpoint = await _resolveEndpoint("endSessionEndpoint"); }
717
+ catch (_e) {
718
+ throw new OAuthError("auth-oauth/no-end-session-endpoint",
719
+ "endSessionUrl: IdP discovery doc has no end_session_endpoint " +
720
+ "(set opts.endSessionEndpoint on create() if the IdP doesn't publish it)");
721
+ }
722
+ var params = new URLSearchParams();
723
+ if (uopts.idTokenHint) params.set("id_token_hint", uopts.idTokenHint);
724
+ if (uopts.postLogoutRedirectUri) {
725
+ params.set("post_logout_redirect_uri", uopts.postLogoutRedirectUri);
726
+ }
727
+ if (uopts.state) params.set("state", uopts.state);
728
+ if (uopts.logoutHint) params.set("logout_hint", uopts.logoutHint);
729
+ if (uopts.uiLocales) params.set("ui_locales", uopts.uiLocales);
730
+ if (uopts.clientId !== false) params.set("client_id", clientId);
731
+ if (uopts.extraParams && typeof uopts.extraParams === "object") {
732
+ var ek = Object.keys(uopts.extraParams);
733
+ for (var i = 0; i < ek.length; i++) params.set(ek[i], String(uopts.extraParams[ek[i]]));
734
+ }
735
+ var qs = params.toString();
736
+ if (qs.length === 0) return endpoint;
737
+ var sep = endpoint.indexOf("?") === -1 ? "?" : "&";
738
+ return endpoint + sep + qs;
739
+ }
740
+
741
+ // ---- OAuth 2.0 Pushed Authorization Requests (RFC 9126) ----
742
+ //
743
+ // PAR: the client POSTs the authorization-request parameters
744
+ // directly to the IdP's PAR endpoint (mTLS or client-secret
745
+ // authenticated) and gets back a `request_uri` it then puts in the
746
+ // browser-side redirect to /authorize. Defends against parameter
747
+ // tampering by an MITM at the user-agent + against URL-length
748
+ // overflow on long authorization requests.
749
+ async function pushAuthorizationRequest(uopts) {
750
+ uopts = uopts || {};
751
+ var endpoint;
752
+ try { endpoint = await _resolveEndpoint("pushedAuthorizationRequestEndpoint"); }
753
+ catch (_e) {
754
+ throw new OAuthError("auth-oauth/no-par-endpoint",
755
+ "pushAuthorizationRequest: IdP discovery doc has no " +
756
+ "pushed_authorization_request_endpoint (set opts.pushedAuthorizationRequestEndpoint " +
757
+ "on create() if the IdP doesn't publish it)");
758
+ }
759
+ // Build the same param set authorizationUrl would emit, then POST
760
+ // it to PAR instead of putting it in the redirect URL.
761
+ var state = uopts.state || _generateRandomToken(STATE_NONCE_BYTES);
762
+ var nonce = uopts.nonce || (isOidc ? _generateRandomToken(STATE_NONCE_BYTES) : null);
763
+ var pkceVals = _generatePkce();
764
+ var body = new URLSearchParams();
765
+ body.set("response_type", "code");
766
+ body.set("client_id", clientId);
767
+ body.set("redirect_uri", redirectUri);
768
+ body.set("scope", scope.join(" "));
769
+ body.set("state", state);
770
+ if (nonce) body.set("nonce", nonce);
771
+ body.set("code_challenge", pkceVals.challenge);
772
+ body.set("code_challenge_method", "S256");
773
+ if (responseMode) body.set("response_mode", responseMode);
774
+ if (uopts.prompt) body.set("prompt", uopts.prompt);
775
+ if (uopts.loginHint) body.set("login_hint", uopts.loginHint);
776
+ if (uopts.maxAge != null) body.set("max_age", String(uopts.maxAge));
777
+ if (clientSecret) body.set("client_secret", clientSecret);
778
+ if (uopts.extraParams && typeof uopts.extraParams === "object") {
779
+ var ek = Object.keys(uopts.extraParams);
780
+ for (var i = 0; i < ek.length; i++) body.set(ek[i], String(uopts.extraParams[ek[i]]));
781
+ }
782
+ var rv = await _postForm(endpoint, body);
783
+ if (!rv || typeof rv.request_uri !== "string" || rv.request_uri.length === 0) {
784
+ throw new OAuthError("auth-oauth/par-bad-response",
785
+ "pushAuthorizationRequest: IdP did not return a request_uri (got " +
786
+ JSON.stringify(rv).slice(0, 200) + ")"); // allow:raw-byte-literal — error-message snippet length
787
+ }
788
+ // Build the browser-side redirect URL: /authorize?client_id=...&request_uri=...
789
+ var authzEndpoint = await _resolveEndpoint("authorizationEndpoint");
790
+ var qs = new URLSearchParams();
791
+ qs.set("client_id", clientId);
792
+ qs.set("request_uri", rv.request_uri);
793
+ var sep = authzEndpoint.indexOf("?") === -1 ? "?" : "&";
794
+ return {
795
+ url: authzEndpoint + sep + qs.toString(),
796
+ state: state,
797
+ nonce: nonce,
798
+ verifier: pkceVals.verifier,
799
+ challenge: pkceVals.challenge,
800
+ requestUri: rv.request_uri,
801
+ expiresIn: typeof rv.expires_in === "number" ? rv.expires_in : null,
802
+ };
803
+ }
804
+
700
805
  return {
701
806
  authorizationUrl: authorizationUrl,
702
807
  exchangeCode: exchangeCode,
@@ -705,6 +810,8 @@ function create(opts) {
705
810
  revokeToken: revokeToken,
706
811
  verifyIdToken: verifyIdToken,
707
812
  discover: _discover,
813
+ endSessionUrl: endSessionUrl,
814
+ pushAuthorizationRequest: pushAuthorizationRequest,
708
815
  // Diagnostic / power-user surface
709
816
  issuer: issuer,
710
817
  clientId: clientId,
@@ -0,0 +1,133 @@
1
+ "use strict";
2
+ /**
3
+ * BIMI — Brand Indicators for Message Identification (RFC 9091).
4
+ *
5
+ * BIMI records publish a sender's brand logo URL in DNS so receiving
6
+ * MTAs can render it next to the message in supported clients
7
+ * (Gmail, Yahoo, Apple Mail). The record format is:
8
+ *
9
+ * default._bimi.<domain> IN TXT "v=BIMI1; l=https://...; a=https://..."
10
+ *
11
+ * - `l=` URL to the SVG logo file (Tiny PS Profile per RFC 9091 §5)
12
+ * - `a=` URL to the Verified Mark Certificate (VMC) — RFC 9091 §6
13
+ *
14
+ * BIMI is layered on a passing DMARC posture (the receiver requires
15
+ * DMARC to be at quarantine or reject). No-op for senders without
16
+ * DMARC enforcement.
17
+ *
18
+ * Surface:
19
+ * b.mail.bimi.recordShape({ logoUrl, vmcUrl?, selector? }) → string
20
+ * b.mail.bimi.fetchPolicy(domain, opts?) → { v, l, a } | null
21
+ * b.mail.bimi.parseRecord(text) → { v, l, a } | null
22
+ *
23
+ * The framework does NOT validate the SVG / VMC contents against the
24
+ * RFC 9091 §5/§6 profiles — operators feed those to their own asset
25
+ * pipeline. The fetch primitive is a thin DNS lookup that returns
26
+ * the structured record so an operator dashboard or SMTP send-time
27
+ * preflight can verify the publication.
28
+ */
29
+
30
+ var dns = require("node:dns");
31
+ var dnsPromises = dns.promises;
32
+ var validateOpts = require("./validate-opts");
33
+ var safeUrl = require("./safe-url");
34
+ var C = require("./constants");
35
+ var { defineClass } = require("./framework-error");
36
+
37
+ var BimiError = defineClass("BimiError", { alwaysPermanent: true });
38
+
39
+ var BIMI_VERSION = "BIMI1";
40
+ var BIMI_DEFAULT_SELECTOR = "default";
41
+ var BIMI_RECORD_MAX_BYTES = C.BYTES.kib(2);
42
+
43
+ function _validateUrl(url, label) {
44
+ // RFC 9091 §4.2 — `l=` and `a=` MUST be HTTPS URLs.
45
+ try {
46
+ safeUrl.parse(url, { allowedProtocols: ["https:"] });
47
+ } catch (e) {
48
+ throw new BimiError("mail-bimi/bad-" + label,
49
+ "bimi: " + label + " must be an https:// URL — got '" + url + "': " +
50
+ ((e && e.message) || String(e)));
51
+ }
52
+ }
53
+
54
+ function recordShape(opts) {
55
+ validateOpts.requireObject(opts, "bimi.recordShape", BimiError);
56
+ validateOpts(opts, ["logoUrl", "vmcUrl", "selector"], "bimi.recordShape");
57
+ validateOpts.requireNonEmptyString(opts.logoUrl,
58
+ "bimi.recordShape: logoUrl", BimiError, "mail-bimi/no-logo");
59
+ _validateUrl(opts.logoUrl, "logoUrl");
60
+ if (opts.vmcUrl !== undefined && opts.vmcUrl !== null) {
61
+ validateOpts.requireNonEmptyString(opts.vmcUrl,
62
+ "bimi.recordShape: vmcUrl", BimiError, "mail-bimi/bad-vmc");
63
+ _validateUrl(opts.vmcUrl, "vmcUrl");
64
+ }
65
+ // No CR/LF/NUL/semicolon — defense-in-depth so a hostile URL can't
66
+ // inject a record-separator sequence into the published TXT.
67
+ if (/[\r\n\0;]/.test(opts.logoUrl)) {
68
+ throw new BimiError("mail-bimi/bad-logo",
69
+ "bimi.recordShape: logoUrl contains forbidden control / record-separator characters");
70
+ }
71
+ if (opts.vmcUrl && /[\r\n\0;]/.test(opts.vmcUrl)) {
72
+ throw new BimiError("mail-bimi/bad-vmc",
73
+ "bimi.recordShape: vmcUrl contains forbidden control / record-separator characters");
74
+ }
75
+
76
+ var fields = ["v=" + BIMI_VERSION, "l=" + opts.logoUrl];
77
+ if (opts.vmcUrl) fields.push("a=" + opts.vmcUrl);
78
+ return fields.join("; ");
79
+ }
80
+
81
+ function parseRecord(text) {
82
+ if (typeof text !== "string" || text.length === 0) return null;
83
+ if (text.length > BIMI_RECORD_MAX_BYTES) return null; // bound BEFORE parse — TXT-record sanity cap
84
+ // RFC 9091 §4 — semicolon-separated, key=value, leading "v=BIMI1".
85
+ var parts = text.split(";");
86
+ var rv = { v: null, l: null, a: null };
87
+ for (var i = 0; i < parts.length; i += 1) {
88
+ var p = parts[i].trim();
89
+ if (p.length === 0) continue;
90
+ var eq = p.indexOf("=");
91
+ if (eq === -1) continue;
92
+ var k = p.slice(0, eq).trim().toLowerCase();
93
+ var v = p.slice(eq + 1).trim();
94
+ if (k === "v" || k === "l" || k === "a") rv[k] = v;
95
+ }
96
+ if (rv.v !== BIMI_VERSION || !rv.l) return null;
97
+ return rv;
98
+ }
99
+
100
+ async function fetchPolicy(domain, opts) {
101
+ validateOpts.requireNonEmptyString(domain,
102
+ "bimi.fetchPolicy: domain", BimiError, "mail-bimi/bad-domain");
103
+ opts = opts || {};
104
+ var selector = opts.selector || BIMI_DEFAULT_SELECTOR;
105
+ var qname = selector + "._bimi." + domain;
106
+ var records;
107
+ try {
108
+ if (opts.dnsLookup) records = await opts.dnsLookup(qname, "TXT");
109
+ else records = await dnsPromises.resolveTxt(qname);
110
+ } catch (e) {
111
+ if (e && (e.code === "ENOTFOUND" || e.code === "ENODATA")) return null;
112
+ throw new BimiError("mail-bimi/lookup-failed",
113
+ "bimi.fetchPolicy: TXT lookup for " + qname + " failed: " +
114
+ ((e && e.message) || String(e)));
115
+ }
116
+ // RFC 9091 §4.1 — a TXT lookup may return multiple chunks; pick
117
+ // the first record that begins with v=BIMI1.
118
+ for (var i = 0; i < (records || []).length; i += 1) {
119
+ var rec = records[i];
120
+ var s = Array.isArray(rec) ? rec.join("") : String(rec);
121
+ var parsed = parseRecord(s);
122
+ if (parsed) return parsed;
123
+ }
124
+ return null;
125
+ }
126
+
127
+ module.exports = {
128
+ recordShape: recordShape,
129
+ parseRecord: parseRecord,
130
+ fetchPolicy: fetchPolicy,
131
+ BIMI_VERSION: BIMI_VERSION,
132
+ BimiError: BimiError,
133
+ };
package/lib/mail.js CHANGED
@@ -71,6 +71,7 @@ var httpClient = lazyRequire(function () { return require("./http-client"); });
71
71
  var guardEmail = lazyRequire(function () { return require("./guard-email"); });
72
72
  var mailDkim = require("./mail-dkim");
73
73
  var mailAuth = require("./mail-auth");
74
+ var mailBimi = require("./mail-bimi");
74
75
  var mailUnsubscribe = require("./mail-unsubscribe");
75
76
  var net = lazyRequire(function () { return require("net"); });
76
77
  var nodeUrl = require("url");
@@ -1102,6 +1103,7 @@ module.exports = {
1102
1103
  dmarc: mailAuth.dmarc,
1103
1104
  arc: mailAuth.arc,
1104
1105
  authResults: mailAuth.authResults,
1106
+ bimi: mailBimi,
1105
1107
  // Test-only export: lets unit tests inspect the wire format without
1106
1108
  // standing up a TLS-capable SMTP fixture. Operators don't call this.
1107
1109
  _buildRfc822ForTest: _buildRfc822,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.7.81",
3
+ "version": "0.7.83",
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:cff2cdad-8420-45be-b8ae-a4b9ec79c49a",
5
+ "serialNumber": "urn:uuid:2bb408b6-50d7-4fb3-acf9-332f7472bd70",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-06T05:43:30.523Z",
8
+ "timestamp": "2026-05-06T06:03:44.811Z",
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.7.81",
22
+ "bom-ref": "@blamejs/core@0.7.83",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.7.81",
25
+ "version": "0.7.83",
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.7.81",
29
+ "purl": "pkg:npm/%40blamejs/core@0.7.83",
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.7.81",
57
+ "ref": "@blamejs/core@0.7.83",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]