@blamejs/core 0.13.45 → 0.14.0

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
@@ -6,8 +6,14 @@ Pre-1.0 the surface is intentionally evolving — every release may
6
6
  change something operators depend on. Read each entry before
7
7
  upgrading across more than a few patches at a time.
8
8
 
9
+ ## v0.14.x
10
+
11
+ - v0.14.0 (2026-05-29) — **Operator-configurable header and field names across SSE, request-id, rate-limit, age-gate, AI-Act disclosure, GraphQL federation, and the HTTP cache.** This release makes operator-facing identifiers that were hardcoded configurable. The framework already let operators rename most names (CSRF cookie/field, cookie parser, i18n header/query/cookie, mTLS CA name, and so on); this closes the remaining gaps so a custom or framework-specific name is never frozen. Every new option defaults to the value emitted today, so upgrading changes no behavior — these are additive knobs. It also fixes a request-id asymmetry (the response header is now written on the same name the inbound id is read from) and wires an SSE proxy-buffering option whose escape hatch was documented but never implemented. **Added:** *Configurable cache-status header on the HTTP client* — The outbound HTTP client annotated every cached response with a hardcoded `x-blamejs-cache: HIT|MISS|STALE|REVALIDATED` header. `b.httpClient.cache.create` now takes `statusHeader` (default "x-blamejs-cache") — pass a custom name (e.g. "x-cache") to rename it, or null/false to suppress it entirely. The decision remains available programmatically on `res.cacheStatus`. · *Configurable rate-limit header names* — `b.middleware.rateLimit` emitted the de-facto `X-RateLimit-Limit` / `X-RateLimit-Remaining` headers (which are not RFC-pinned). It now accepts `headerPrefix` (default "X-RateLimit-") so operators can match the unprefixed IETF-draft `RateLimit-*` names or an upstream gateway's convention; the limit/remaining pair is always built from the same prefix. · *Configurable age-gate and AI-Act disclosure header names* — `b.middleware.ageGate` now takes `privacyPostureHeader` (default "X-Privacy-Posture"; null/false to suppress), and `b.middleware.aiActDisclosure` takes `headerPrefix` (default "AI-Act-") that prefixes the emitted Notice / Article / Policy headers. The EU AI Act mandates the disclosure, not the HTTP spelling, so operators matching a downstream convention can rename these. · *Configurable GraphQL-federation replay-nonce header* — `b.graphqlFederation.guardSdl` read the replay nonce from the Apollo-vendor `x-apollographql-router-nonce` header with no override. It now accepts `nonceHeader` (default unchanged) so an operator fronting the gateway with a non-Apollo router can point the replay check at their own header. · *SSE proxy-buffering opt-out* — `b.sse.create` and `b.middleware.sse` set `X-Accel-Buffering: no` (the nginx hint that disables proxy buffering). They now accept `proxyBuffer` (default true) — pass false when not behind nginx, or when buffering is controlled at the load balancer, to suppress the nginx-specific header. The opt-out was previously referenced in the documentation but not implemented. **Fixed:** *Request-id middleware reflects the configured header name* — `b.log.middleware` read the inbound request id from a configurable `headerName` but always wrote the response on the literal `X-Request-Id`. An operator who set a custom `headerName` (e.g. `X-Correlation-Id`) therefore read from one header and emitted another. The response is now written on the same configured name; the default remains `X-Request-Id`, so deployments that did not set `headerName` are unaffected.
12
+
9
13
  ## v0.13.x
10
14
 
15
+ - v0.13.46 (2026-05-29) — **`createApp` now wires the documented security middleware ON by default — CSRF, CSP nonce, cookie parser, fetch-metadata, and body parser.** The README has long described a security middleware stack as "wired by createApp", but createApp only mounted request-ID, security-headers, and bot-guard by default — CSRF protection, the CSP nonce, the threat-aware cookie parser, the fetch-metadata guard, and the body parser were documented but not actually wired. This release closes that gap: createApp now mounts all of them by default, in dependency order (cookies, CSP nonce, fetch-metadata, then body parser, then CSRF last so it can read a body-field token). This is a behavior change — apps built with createApp now enforce CSRF on state-changing requests by default. Each layer is configurable via opts.middleware.<name> (operator cookie and field names flow straight through — nothing is hardcoded) or can be turned off with false, and disabling a security default now emits an app.middleware.disabled audit event. Every layer is idempotent: an operator who also mounts one of these inside opts.routes gets a no-op second mount rather than a double-apply. The default CSRF is a double-submit cookie that auto-skips requests carrying an Authorization header or no cookies at all, which are not CSRF-able, so token-authenticated API clients are not rejected. The README middleware list is now an accurate description of what createApp wires. **Added:** *Idempotent security middleware* — The cookie parser, CSP nonce, fetch-metadata, and CSRF middleware are now idempotent within a request: if one has already run (because createApp wired it and an operator also mounted it), the second instance is a no-op rather than re-parsing, re-generating a nonce, or issuing a second CSRF cookie. This lets an application compose its own middleware order on top of createApp's defaults without double-applying. The body parser already had this behavior. **Changed:** *createApp wires CSRF, CSP nonce, cookie parser, fetch-metadata, and body parser by default (breaking)* — Applications constructed with b.createApp now mount, in order: the threat-aware cookie parser, the CSP nonce generator, the fetch-metadata resource-isolation guard, the body parser (JSON / urlencoded / text / multipart), and CSRF protection — in addition to the request-ID, security-headers, and bot-guard layers already wired. The ordering guarantees CSRF runs after the body parser so a body-field token is available. This is a behavior change: state-changing requests (POST / PUT / DELETE / PATCH) that carry a session cookie are now CSRF-validated by default. Each layer is configured through opts.middleware.<name> (an object passes operator options straight through; cookie and field names are not hardcoded) or disabled with false. Operators who were mounting these middleware themselves inside opts.routes do not need to change anything — the second mount is now a no-op (see idempotency below). · *Default CSRF auto-skips token-authenticated and cookieless requests* — The CSRF middleware gains a skipStateless option (default false; createApp's default wiring sets it true). When on, token validation is skipped for requests that carry an Authorization header or no Cookie header at all — such requests are not CSRF-able, because CSRF abuses a victim's ambient cookie credential and these have none. The token is still issued on safe methods so a later cookie-authenticated browser flow works. Cross-site form CSRF is unaffected: the browser auto-sends the victim's cookies, so an attack request always carries a Cookie header and is validated. · *Disabling a default security middleware is audited* — Passing false for one of the security-on-by-default middleware (for example middleware: { csrf: false }) now emits an app.middleware.disabled audit event naming the middleware, so a weakened posture leaves a trace in the audit chain rather than being silent.
16
+
11
17
  - v0.13.45 (2026-05-29) — **`b.cert` now fetches and staples a validated OCSP response per certificate, and validates declared compliance postures at create().** Two capabilities that b.cert documented but did not act on are now wired through. OCSP stapling: the cert manager fetches the leaf's OCSP response from the responder named in its Authority Information Access extension, validates it against the issuer (status, nonce, serial) via b.network.tls.ocsp, caches the DER, and exposes it on getContext().ocspResponse so a TLS server's OCSPRequest handler can staple it. The fetch runs in the background on a refresh timer and never blocks cert.start() — a slow or unreachable responder produces an audited per-certificate failure, not a stalled boot. Compliance postures: opts.compliance names are now validated against b.compliance.KNOWN_POSTURES at create() (an unknown name throws cert/unknown-compliance-posture instead of being silently recorded) and are surfaced on getContext().compliance for an auditor. Storage-confidentiality postures hold by construction because cert keys and certificates are always sealed at rest. The supporting composition primitive b.network.tls.ocsp.fetch (build request, POST to the responder through b.httpClient, validate the response) is now part of the public OCSP surface. **Added:** *b.network.tls.ocsp.fetch — fetch and validate an OCSP response* — The OCSP helper set previously built requests and evaluated responses but had no way to actually retrieve one. b.network.tls.ocsp.fetch({ leafPem, issuerPem, nonce?, timeoutMs? }) reads the responder URL from the leaf certificate's Authority Information Access extension, builds the request, POSTs it through b.httpClient (so the SSRF guard and pinned DNS apply), and validates the response against the issuer — returning the validated DER plus the parsed evaluation. It rejects when the leaf carries no OCSP responder URL or the response fails validation. · *b.cert staples a validated OCSP response per certificate* — With ocsp.stapling enabled (the default), the cert manager refreshes each certificate's OCSP response on a timer (ocsp.refreshMs, default 12h) and caches the validated DER. getContext(serverName).ocspResponse returns that DER for a TLS server to hand back from its OCSPRequest handler. The refresh runs in the background and is never on the path of cert.start(): an unreachable or slow responder is recorded as an audited cert.ocsp.refresh failure for that certificate and leaves the rest of the manager running. **Changed:** *opts.compliance posture names are validated at create()* — b.cert.create now checks each name in opts.compliance against b.compliance.KNOWN_POSTURES and throws cert/unknown-compliance-posture on an unrecognized name, so a typo is caught at construction rather than being silently recorded. The declared postures are surfaced on getContext().compliance. Cert keys and certificates are always sealed at rest, so storage-confidentiality postures are satisfied by construction.
12
18
 
13
19
  - v0.13.44 (2026-05-29) — **Error codes on the consent, compliance, and protocol namespaces now follow the namespace/kebab-case contract.** The framework's error contract is `err.code = "namespace/kebab-case"`, and the vast majority of namespaces already followed it. This release normalizes the holdouts: fifteen namespaces that threw bare UPPER_SNAKE codes with no namespace, and nine that used a camelCase namespace prefix. After this release every error these namespaces throw carries a `namespace/kebab-case` code, so an operator switching on `err.code` no longer has to special-case them. This is a breaking change for code that matches the old strings — pre-1.0, there is no compatibility shim, so update any `err.code` comparisons against the listed namespaces. A codebase check now enforces the convention so it cannot regress. A small set of older codes (the cluster, scheduler, circuit-breaker, object-store, and upload subsystems) is intentionally left for the 1.0 release, where it will carry a deprecation cycle. **Changed:** *Bare UPPER_SNAKE error codes are now namespaced (breaking)* — Fifteen namespaces threw bare UPPER_SNAKE error codes with no namespace prefix (for example `mcp` threw `BAD_JSON`, `BAD_ENVELOPE`, `BAD_METHOD`). Their `err.code` values are now `namespace/kebab-case` — `mcp/bad-json`, `mcp/bad-envelope`, and so on. The affected namespaces are `b.a2a`, `b.aiInput`, `b.aiPref`, `b.budr`, `b.contentCredentials`, `b.darkPatterns`, `b.fapi2`, `b.fdx`, `b.graphqlFederation`, `b.iabTcf`, `b.iabMspa`, `b.mcp`, `b.secCyber`, `b.sse`, and `b.tcpa10dlc`. Operators matching the old bare codes on `err.code` must update those comparisons; the error message text is unchanged. · *camelCase error-code namespaces are now kebab-case (breaking)* — Nine namespaces emitted error codes whose namespace segment was camelCase (for example `aiDp/bad-bound`, `argParser/flag-duplicate`). The namespace segment is now kebab-case to match every other code: `ai-dp/`, `ai-capability/`, `ai-quota/`, `arg-parser/`, `audit-sign/`, `auth-step-up/`, `ddl-change-control/`, `dr-runbook/`, `tenant-quota/`, and `boot-gates/`. The `b.*` API namespace keys themselves are unchanged (those remain camelCase, e.g. `b.argParser`); only the `err.code` string changed. Operators matching these `err.code` strings must update them. **Detectors:** *Error-code shape is enforced* — A codebase check now flags any error code constructed via `new XError(...)` or the per-class `factory(...)` whose value is a bare UPPER_SNAKE string or carries a camelCase namespace segment, so the `namespace/kebab-case` contract cannot silently regress. It correctly ignores native error constructors (whose first argument is the message, not a code).
package/README.md CHANGED
@@ -119,20 +119,17 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
119
119
  ### HTTP
120
120
 
121
121
  - **Router + API specs** — schema-validated routes; OpenAPI publication (`b.openapi`) + AsyncAPI publication for event/streaming (`b.asyncapi`)
122
- - **Middleware stack (wired by `createApp`)**
123
- - CSRF protection
124
- - CORS with W3C Private Network Access preflight refusal default + `allowPrivateNetwork` opt
125
- - Rate-limit
122
+ - **Middleware stack (`createApp`)** — security layers wired ON by default (Core Rule §3); each is configurable via `middleware.<name>` (operator cookie / field names flow straight through — nothing static is baked in) or opt-out with `false` (disabling a default is audited via `app.middleware.disabled`). Ordered so each layer has what it needs (cookies + CSP nonce + fetch-metadata, then body parser, then CSRF last):
123
+ - Request-ID tagging and bot-guard
126
124
  - Security headers with `Permissions-Policy` defaults denying storage-access / browsing-topics / private-aggregation / controlled-frame
127
- - CSP nonce
128
- - Body parser — JSON / urlencoded / text / multipart; multipart file parts stream to a tmp dir or buffer in memory (`storage: "memory"`) for read-only / serverless filesystems
129
- - Compression
130
- - SSE
131
- - Request log
132
125
  - Threat-aware cookie parser (`b.middleware.cookies`)
133
- - Request-time DB role binding (`b.middleware.dbRoleFor`)
134
- - In-process CIDR fence (`b.middleware.networkAllowlist`)
126
+ - CSP nonce generated per request, merged into the CSP (`b.middleware.cspNonce`)
127
+ - Fetch-metadata resource-isolation guard (`b.middleware.fetchMetadata`)
128
+ - Body parser — JSON / urlencoded / text / multipart; multipart file parts stream to a tmp dir or buffer in memory (`storage: "memory"`) for read-only / serverless filesystems
129
+ - CSRF protection — double-submit cookie + Origin/Referer cross-check; auto-skips Authorization-header / cookieless requests, which are not CSRF-able (`b.middleware.csrfProtect`)
130
+ - CORS (W3C Private Network Access preflight refusal default + `allowPrivateNetwork` opt) and rate-limit are wired when configured via `middleware.cors` / `middleware.rateLimit`
135
131
  - `Cache-Control: no-store` on every 401 from `requireAuth` / `requireAal` / `requireStepUp` per RFC 9111 §5.2.2.5
132
+ - **Additional middleware** to mount in your `routes` callback: compression, SSE, request logging, request-time DB role binding (`b.middleware.dbRoleFor`), in-process CIDR fence (`b.middleware.networkAllowlist`)
136
133
  - **Outbound HTTP client** — HTTP/1.1 + HTTP/2 with SSRF gate (cloud-metadata IPs hard-denied; private / loopback / link-local overridable per call); scheme + userinfo + per-host destination allowlist; redirects, multipart, interceptors, progress, encrypted cookie jar (`b.httpClient`, `b.ssrfGuard`, `b.safeUrl`)
137
134
  - **Network configurability (`b.network`)** — env-driven NTP / NTS (RFC 8915), IPv4/IPv6 NTP, DNS with IPv6 / DoH / DoT (private-CA pinning) / cache / lookup timeout; local DNSSEC signature verification (RFC 4035 — `b.network.dns.dnssec.verifyRrset` over a canonicalised RRset against RSA / ECDSA P-256·P-384 / Ed25519 DNSKEYs, plus DS-digest + key-tag, plus `verifyDenial` for NSEC / NSEC3 (RFC 5155) NXDOMAIN / NODATA proofs with iteration caps + Opt-Out handling, plus `verifyChain` to validate a full root→TLD→zone delegation chain against the pinned IANA root anchors) so a resolver client can verify both positive and negative answers instead of trusting the upstream AD bit; DANE / TLSA certificate matching (RFC 6698/7671 — `b.network.dns.dane.matchCertificate`) to pin a service's key through DNSSEC instead of a public CA; TSIG transaction signatures (RFC 8945 — `b.network.dns.tsig.sign` / `verify`) for shared-key HMAC authentication of zone transfers, dynamic updates, and query/response pairs, with constant-time MAC compare + fudge-window check (verified against dnspython); outbound HTTP proxy (`HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`); runtime DPI trust-store CA additions; application-level heartbeats; TCP socket defaults
138
135
  - **Error pages** — operator-rendered, no app-frame leakage (`b.errorPage`)
package/lib/app.js CHANGED
@@ -92,6 +92,7 @@
92
92
  var nodeFs = require("node:fs");
93
93
  var nodePath = require("node:path");
94
94
  var appShutdown = require("./app-shutdown");
95
+ var audit = require("./audit");
95
96
  var C = require("./constants");
96
97
  var cluster = require("./cluster");
97
98
  var db = require("./db");
@@ -103,13 +104,28 @@ var queue = require("./queue");
103
104
  var routerMod = require("./router");
104
105
  var vault = require("./vault");
105
106
 
106
- function _resolveMiddlewareOpt(value, allowDefault) {
107
+ function _resolveMiddlewareOpt(value, allowDefault, name) {
107
108
  // value can be:
108
109
  // false — operator opted out
109
110
  // undefined — fall back to allowDefault (mount with empty opts)
110
111
  // true — explicit opt-in with default opts
111
112
  // object — explicit opts
112
- if (value === false) return null;
113
+ if (value === false) {
114
+ // Operator explicitly disabled this middleware. When it's one of the
115
+ // security-on-by-default layers (allowDefault), leave an audit trace
116
+ // so the weakened posture is visible — Core Rule §3 security defaults
117
+ // shouldn't be silently opt-out-able. Drop-silent observability sink.
118
+ if (allowDefault && name) {
119
+ try {
120
+ audit.safeEmit({
121
+ action: "app.middleware.disabled",
122
+ outcome: "success",
123
+ metadata: { middleware: name },
124
+ });
125
+ } catch (_e) { /* drop-silent — by design */ }
126
+ }
127
+ return null;
128
+ }
113
129
  if (value === undefined) return allowDefault ? {} : null;
114
130
  if (value === true) return {};
115
131
  if (value && typeof value === "object") return value;
@@ -196,21 +212,55 @@ async function createApp(opts) {
196
212
  });
197
213
  router.use(orchestrator.middleware());
198
214
 
199
- var requestIdOpts = _resolveMiddlewareOpt(mwConfig.requestId, true);
215
+ var requestIdOpts = _resolveMiddlewareOpt(mwConfig.requestId, true, "requestId");
200
216
  if (requestIdOpts) router.use(middleware.requestId(requestIdOpts));
201
217
 
202
- var securityHeadersOpts = _resolveMiddlewareOpt(mwConfig.securityHeaders, true);
218
+ var securityHeadersOpts = _resolveMiddlewareOpt(mwConfig.securityHeaders, true, "securityHeaders");
203
219
  if (securityHeadersOpts) router.use(middleware.securityHeaders(securityHeadersOpts));
204
220
 
205
- var corsOpts = _resolveMiddlewareOpt(mwConfig.cors, false);
221
+ var corsOpts = _resolveMiddlewareOpt(mwConfig.cors, false, "cors");
206
222
  if (corsOpts) router.use(middleware.cors(corsOpts));
207
223
 
208
- var botGuardOpts = _resolveMiddlewareOpt(mwConfig.botGuard, true);
224
+ var botGuardOpts = _resolveMiddlewareOpt(mwConfig.botGuard, true, "botGuard");
209
225
  if (botGuardOpts) router.use(middleware.botGuard(botGuardOpts));
210
226
 
211
- var rateLimitOpts = _resolveMiddlewareOpt(mwConfig.rateLimit, false);
227
+ var rateLimitOpts = _resolveMiddlewareOpt(mwConfig.rateLimit, false, "rateLimit");
212
228
  if (rateLimitOpts) router.use(middleware.rateLimit(rateLimitOpts));
213
229
 
230
+ // Security middleware wired ON by default (Core Rule §3). Each reads its
231
+ // config from opts.middleware.<name>: pass `false` to opt out (audited
232
+ // via _resolveMiddlewareOpt), or an object to customize — operator cookie
233
+ // / field names flow straight through, nothing static is baked in.
234
+ // Ordered so each layer has what it needs: cookies + cspNonce +
235
+ // fetchMetadata first, then bodyParser (so csrf can read a body-field
236
+ // token), then csrfProtect last. Every layer is idempotent — if an
237
+ // operator also mounts one of these inside opts.routes, the second mount
238
+ // is a no-op rather than a double-apply.
239
+ var cookiesOpts = _resolveMiddlewareOpt(mwConfig.cookies, true, "cookies");
240
+ if (cookiesOpts) router.use(middleware.cookies(cookiesOpts));
241
+
242
+ var cspNonceOpts = _resolveMiddlewareOpt(mwConfig.cspNonce, true, "cspNonce");
243
+ if (cspNonceOpts) router.use(middleware.cspNonce(cspNonceOpts));
244
+
245
+ var fetchMetadataOpts = _resolveMiddlewareOpt(mwConfig.fetchMetadata, true, "fetchMetadata");
246
+ if (fetchMetadataOpts) router.use(middleware.fetchMetadata(fetchMetadataOpts));
247
+
248
+ var bodyParserOpts = _resolveMiddlewareOpt(mwConfig.bodyParser, true, "bodyParser");
249
+ if (bodyParserOpts) router.use(middleware.bodyParser(bodyParserOpts));
250
+
251
+ var csrfOpts = _resolveMiddlewareOpt(mwConfig.csrf, true, "csrf");
252
+ if (csrfOpts) {
253
+ // Defaults: double-submit cookie (unless the operator chose a token
254
+ // lookup or their own cookie config) + skip validation for stateless
255
+ // token-API / cookieless requests. Operator config overrides both.
256
+ var csrfDefaults = { skipStateless: true };
257
+ if (csrfOpts.tokenLookup === undefined && csrfOpts.cookie === undefined) {
258
+ csrfDefaults.cookie = true;
259
+ }
260
+ csrfOpts = Object.assign(csrfDefaults, csrfOpts);
261
+ router.use(middleware.csrfProtect(csrfOpts));
262
+ }
263
+
214
264
  // ---- 6. Operator routes ----
215
265
  if (typeof opts.routes === "function") {
216
266
  opts.routes(router);
package/lib/audit.js CHANGED
@@ -243,6 +243,7 @@ var FRAMEWORK_NAMESPACES = [
243
243
  "auth", "system", "audit", "consent", "subject",
244
244
  // Per-primitive namespaces — keep alphabetical
245
245
  "apikey", // b.apiKey
246
+ "app", // b.createApp (app.middleware.disabled — a security-default middleware was opted out at construction)
246
247
  "backup", // b.backup
247
248
  "breakglass", // b.breakGlass — column-policy / row-enforcement step-up auth (audit namespace lowercased per the validator's `namespace.verb` rule, same convention as b.apiKey → apikey.*)
248
249
  "cache", // b.cache
@@ -116,6 +116,7 @@ function _readBody(req, errorClass) {
116
116
  * publicSchemaOk: boolean, // default false — explicit override to publish the SDL
117
117
  * routerToken: string, // required unless publicSchemaOk; 32+ chars
118
118
  * nonceStore: { has(nonce): bool, remember(nonce, ttlMs) }, // optional — replay protection
119
+ * nonceHeader: string, // default "x-apollographql-router-nonce" — request header carrying the replay nonce
119
120
  * nonceTtlMs: number, // default 5 minutes
120
121
  * errorClass: Function, // default GraphqlFederationError
121
122
  * audit: boolean, // default true
@@ -141,6 +142,12 @@ function guardSdl(opts) {
141
142
  numericBounds.requirePositiveFiniteIntIfPresent(opts.nonceTtlMs, "graphqlFederation.guardSdl: opts.nonceTtlMs", errorClass, "BAD_TTL");
142
143
  var nonceTtlMs = opts.nonceTtlMs || C.TIME.minutes(5);
143
144
  var auditOn = opts.audit !== false;
145
+ // The replay nonce is read from the Apollo-router convention header by
146
+ // default, but `x-apollographql-router-nonce` is a vendor name, not a
147
+ // spec one — operators fronting the gateway with a different router send
148
+ // the nonce under their own header. Lowercased to match Node's header keys.
149
+ var nonceHeader = (typeof opts.nonceHeader === "string" && opts.nonceHeader.length > 0)
150
+ ? opts.nonceHeader.toLowerCase() : "x-apollographql-router-nonce";
144
151
 
145
152
  function _emitDenied(req, reason, metadata) {
146
153
  if (!auditOn) return;
@@ -190,7 +197,7 @@ function guardSdl(opts) {
190
197
  }
191
198
 
192
199
  if (nonceStore) {
193
- var nonce = req.headers && req.headers["x-apollographql-router-nonce"];
200
+ var nonce = req.headers && req.headers[nonceHeader];
194
201
  if (typeof nonce !== "string" || nonce.length < NONCE_MIN_LEN || nonce.length > NONCE_MAX_LEN) {
195
202
  _emitDenied(req, "missing nonce", {});
196
203
  return _refuse(res, 401, "graphql federation: nonce required");
@@ -545,6 +545,7 @@ function memoryStore(opts) {
545
545
  * revalidateInBackground: true, // s-w-r kicks off background revalidation
546
546
  * audit: undefined, // audit sink with safeEmit({...})
547
547
  * observability: undefined, // optional { event, safeEvent }
548
+ * statusHeader: "x-blamejs-cache", // response header carrying the cache decision; null/false to suppress, or a custom name (e.g. "x-cache")
548
549
  *
549
550
  * @example
550
551
  * var cache = b.httpClient.cache.create({
@@ -585,6 +586,21 @@ function create(opts) {
585
586
  var revalidateBackground = opts.revalidateInBackground !== false; // default true
586
587
  var audit = opts.audit || null;
587
588
  var obs = opts.observability || null;
589
+ // statusHeader (default "x-blamejs-cache") names the response header that
590
+ // carries the cache decision (MISS/HIT/STALE/REVALIDATED). The decision is
591
+ // also on res.cacheStatus programmatically. Pass null/false to suppress the
592
+ // header, or a string to rename it (e.g. "x-cache"). Lowercased for the wire.
593
+ var statusHeader;
594
+ if (opts.statusHeader === null || opts.statusHeader === false) {
595
+ statusHeader = null;
596
+ } else if (opts.statusHeader === undefined) {
597
+ statusHeader = "x-blamejs-cache";
598
+ } else if (typeof opts.statusHeader === "string" && opts.statusHeader.length > 0) {
599
+ statusHeader = opts.statusHeader.toLowerCase();
600
+ } else {
601
+ throw _hcErr("httpclient/cache-bad-opts",
602
+ "cache.create: statusHeader must be a non-empty string, or null/false to suppress");
603
+ }
588
604
 
589
605
  function _emit(action, outcome, metadata) {
590
606
  if (!audit || typeof audit.safeEmit !== "function") return;
@@ -825,6 +841,7 @@ function create(opts) {
825
841
  store: store,
826
842
  audit: audit,
827
843
  observability: obs,
844
+ statusHeader: statusHeader,
828
845
 
829
846
  // ---- Lookup / store / revalidation flow ----
830
847
  //
@@ -849,9 +849,12 @@ function _cacheEligibleMethod(method) {
849
849
 
850
850
  // Wrap an outbound headers object with the framework's cache-decision
851
851
  // markers. Mutates a copy; never the original.
852
- function _withCacheHeaders(res, status, ageSeconds) {
852
+ function _withCacheHeaders(res, status, ageSeconds, statusHeader) {
853
853
  var headers = Object.assign({}, res.headers || {});
854
- headers["x-blamejs-cache"] = status;
854
+ // statusHeader defaults to "x-blamejs-cache"; the cache instance can rename
855
+ // it or set it null to suppress (the decision is also on res.cacheStatus).
856
+ var name = (statusHeader === undefined) ? "x-blamejs-cache" : statusHeader;
857
+ if (name) headers[name] = status;
855
858
  if (typeof ageSeconds === "number" && ageSeconds >= 0) {
856
859
  headers["age"] = String(Math.floor(ageSeconds));
857
860
  }
@@ -893,7 +896,7 @@ function _runWithCache(opts, maxRedirects, runAfter) {
893
896
  catch (_e) { /* drop-silent */ }
894
897
  return _doNetwork(null).then(function (boxed) {
895
898
  _maybeStore(cache, method, opts.url, requestHeaders, boxed.res);
896
- return runAfter(boxed.finalOpts, _withCacheHeaders(boxed.res, "MISS"));
899
+ return runAfter(boxed.finalOpts, _withCacheHeaders(boxed.res, "MISS", undefined, cache.statusHeader));
897
900
  });
898
901
  }
899
902
 
@@ -907,7 +910,7 @@ function _runWithCache(opts, maxRedirects, runAfter) {
907
910
  catch (_e2) { /* drop-silent */ }
908
911
  return _doNetwork(null).then(function (boxed) {
909
912
  _maybeStore(cache, method, opts.url, requestHeaders, boxed.res);
910
- return runAfter(boxed.finalOpts, _withCacheHeaders(boxed.res, "MISS"));
913
+ return runAfter(boxed.finalOpts, _withCacheHeaders(boxed.res, "MISS", undefined, cache.statusHeader));
911
914
  });
912
915
  }
913
916
 
@@ -923,7 +926,7 @@ function _runWithCache(opts, maxRedirects, runAfter) {
923
926
  body: Buffer.isBuffer(entry.body) ? Buffer.from(entry.body) : entry.body,
924
927
  cacheStatus: "HIT",
925
928
  };
926
- return Promise.resolve(runAfter(opts, _withCacheHeaders(hitRes, "HIT", age)));
929
+ return Promise.resolve(runAfter(opts, _withCacheHeaders(hitRes, "HIT", age, cache.statusHeader)));
927
930
  }
928
931
 
929
932
  // 4. Stale or must-revalidate. Within stale-while-revalidate or
@@ -957,7 +960,7 @@ function _runWithCache(opts, maxRedirects, runAfter) {
957
960
  /* background revalidation best-effort; swallow */
958
961
  });
959
962
  });
960
- return Promise.resolve(runAfter(opts, _withCacheHeaders(staleRes, "STALE", ageStale)));
963
+ return Promise.resolve(runAfter(opts, _withCacheHeaders(staleRes, "STALE", ageStale, cache.statusHeader)));
961
964
  }
962
965
 
963
966
  // 5. Inline conditional revalidation. Build If-None-Match /
@@ -974,11 +977,11 @@ function _runWithCache(opts, maxRedirects, runAfter) {
974
977
  : (rev.refreshed || entry).body,
975
978
  cacheStatus: "REVALIDATED",
976
979
  };
977
- return runAfter(opts, _withCacheHeaders(revRes, "REVALIDATED", ageRev));
980
+ return runAfter(opts, _withCacheHeaders(revRes, "REVALIDATED", ageRev, cache.statusHeader));
978
981
  }
979
982
  if (rev.kind === "fresh-response") {
980
983
  _maybeStore(cache, method, opts.url, requestHeaders, rev.res);
981
- return runAfter(rev.finalOpts || opts, _withCacheHeaders(rev.res, "MISS"));
984
+ return runAfter(rev.finalOpts || opts, _withCacheHeaders(rev.res, "MISS", undefined, cache.statusHeader));
982
985
  }
983
986
  // rev.kind === "error" — try stale-if-error.
984
987
  var sieMs = (evaluation.sieWindowMs || 0);
@@ -994,7 +997,7 @@ function _runWithCache(opts, maxRedirects, runAfter) {
994
997
  body: Buffer.isBuffer(entry.body) ? Buffer.from(entry.body) : entry.body,
995
998
  cacheStatus: "STALE",
996
999
  };
997
- return runAfter(opts, _withCacheHeaders(sieRes, "STALE", ageErr));
1000
+ return runAfter(opts, _withCacheHeaders(sieRes, "STALE", ageErr, cache.statusHeader));
998
1001
  }
999
1002
  return Promise.reject(rev.error);
1000
1003
  });
package/lib/log.js CHANGED
@@ -376,7 +376,12 @@ function create(opts) {
376
376
 
377
377
  function middleware(mwOpts) {
378
378
  mwOpts = mwOpts || {};
379
- var headerName = (mwOpts.headerName || "x-request-id").toLowerCase();
379
+ // Read and write the SAME header. The raw form keeps the operator's
380
+ // casing (or the canonical "X-Request-Id" default) for the response;
381
+ // the lowercased form matches Node's request-header keys for the read.
382
+ var rawHeaderName = (typeof mwOpts.headerName === "string" && mwOpts.headerName.length > 0)
383
+ ? mwOpts.headerName : "X-Request-Id";
384
+ var headerName = rawHeaderName.toLowerCase();
380
385
  var setOnRes = mwOpts.setHeader !== false;
381
386
  var generate = typeof mwOpts.generate === "function"
382
387
  ? mwOpts.generate
@@ -395,7 +400,7 @@ function create(opts) {
395
400
  id = safeBuffer.stripCrlf(String(id));
396
401
  req.id = id;
397
402
  if (setOnRes && typeof res.setHeader === "function") {
398
- try { res.setHeader("X-Request-Id", id); } catch (_e) { /* header may be locked */ }
403
+ try { res.setHeader(rawHeaderName, id); } catch (_e) { /* header may be locked */ }
399
404
  }
400
405
  runWithRequestId(id, function () { next(); });
401
406
  };
@@ -70,6 +70,7 @@ var AgeGateError = defineClass("AgeGateError", { alwaysPermanent: true });
70
70
  * hasParentalConsent: function(req): boolean,
71
71
  * skipPaths: string[],
72
72
  * errorMessage: string,
73
+ * privacyPostureHeader: string, // default "X-Privacy-Posture"; null/false to suppress
73
74
  * audit: boolean, // default true
74
75
  * }
75
76
  *
@@ -87,7 +88,7 @@ function create(opts) {
87
88
  opts = opts || {};
88
89
  validateOpts(opts, [
89
90
  "audit", "getAge", "requireAge", "consentRequired",
90
- "hasParentalConsent", "skipPaths", "errorMessage",
91
+ "hasParentalConsent", "skipPaths", "errorMessage", "privacyPostureHeader",
91
92
  ], "middleware.ageGate");
92
93
 
93
94
  if (typeof opts.getAge !== "function") {
@@ -104,6 +105,17 @@ function create(opts) {
104
105
  var auditOn = opts.audit !== false;
105
106
  var errorMessage = typeof opts.errorMessage === "string" && opts.errorMessage.length > 0
106
107
  ? opts.errorMessage : "service unavailable without parental consent";
108
+ // privacyPostureHeader (default "X-Privacy-Posture") names the response
109
+ // header carrying the below-threshold classification. Pass null/false to
110
+ // suppress it, or a string to rename it for a downstream convention.
111
+ var privacyPostureHeader;
112
+ if (opts.privacyPostureHeader === null || opts.privacyPostureHeader === false) {
113
+ privacyPostureHeader = null;
114
+ } else if (typeof opts.privacyPostureHeader === "string" && opts.privacyPostureHeader.length > 0) {
115
+ privacyPostureHeader = opts.privacyPostureHeader;
116
+ } else {
117
+ privacyPostureHeader = "X-Privacy-Posture";
118
+ }
107
119
 
108
120
  function _shouldSkip(req) {
109
121
  if (skipPaths.length === 0) return false;
@@ -148,7 +160,7 @@ function create(opts) {
148
160
  if (typeof res.setHeader === "function") {
149
161
  res.setHeader("Cache-Control", "private, no-store");
150
162
  res.setHeader("Referrer-Policy", "no-referrer");
151
- res.setHeader("X-Privacy-Posture", classification);
163
+ if (privacyPostureHeader) res.setHeader(privacyPostureHeader, classification);
152
164
  }
153
165
  }
154
166
 
@@ -66,6 +66,7 @@ var audit = lazyRequire(function () { return require("../audit"); });
66
66
  * mode: "header"|"html", // default "header"
67
67
  * lang: string, // default "en"
68
68
  * skipHeader: string, // default "x-skip-ai-act"
69
+ * headerPrefix: string, // default "AI-Act-" — prefixes the Notice/Article/Policy disclosure headers
69
70
  * audit: boolean, // default true
70
71
  * }
71
72
  *
@@ -83,7 +84,7 @@ function create(opts) {
83
84
  opts = opts || {};
84
85
  validateOpts(opts, [
85
86
  "kind", "deployerName", "policyUri", "mode",
86
- "audit", "lang", "skipHeader",
87
+ "audit", "lang", "skipHeader", "headerPrefix",
87
88
  ], "middleware.aiActDisclosure");
88
89
 
89
90
  var mode = (opts.mode === "html") ? "html" : "header";
@@ -99,6 +100,12 @@ function create(opts) {
99
100
  var skipHeader = (typeof opts.skipHeader === "string" && opts.skipHeader.length > 0)
100
101
  ? opts.skipHeader.toLowerCase()
101
102
  : "x-skip-ai-act";
103
+ // headerPrefix (default "AI-Act-") names the emitted disclosure headers as
104
+ // <prefix>Notice / <prefix>Article / <prefix>Policy. The EU AI Act mandates
105
+ // the disclosure, not the HTTP spelling — operators matching a downstream
106
+ // convention pass their own prefix (e.g. "X-AI-").
107
+ var headerPrefix = (typeof opts.headerPrefix === "string" && opts.headerPrefix.length > 0)
108
+ ? opts.headerPrefix : "AI-Act-";
102
109
 
103
110
  return function aiActDisclosureMiddleware(req, res, next) {
104
111
  var headers = req.headers || {};
@@ -118,10 +125,10 @@ function create(opts) {
118
125
  return origWriteHead.apply(res, arguments);
119
126
  }
120
127
  var article = _articleFor(opts.kind || "ai-interaction");
121
- _setHeader(res, "AI-Act-Notice", opts.kind || "ai-interaction");
122
- _setHeader(res, "AI-Act-Article", article);
128
+ _setHeader(res, headerPrefix + "Notice", opts.kind || "ai-interaction");
129
+ _setHeader(res, headerPrefix + "Article", article);
123
130
  if (typeof opts.policyUri === "string" && opts.policyUri.length > 0) {
124
- _setHeader(res, "AI-Act-Policy", opts.policyUri);
131
+ _setHeader(res, headerPrefix + "Policy", opts.policyUri);
125
132
  }
126
133
  injected = true;
127
134
  return origWriteHead.apply(res, arguments);
@@ -89,6 +89,10 @@ function create(opts) {
89
89
  var audit = opts.audit || null;
90
90
 
91
91
  return function cookiesMiddleware(req, res, next) {
92
+ // Idempotent: if an earlier cookies middleware already parsed the jar
93
+ // this request (e.g. createApp wired it AND an operator mounted it
94
+ // again), don't re-parse — keep the first jar.
95
+ if (req.cookieJar !== undefined) return next();
92
96
  var header = req && req.headers ? req.headers.cookie : "";
93
97
  var rv = cookies.parseSafe(header || "", {
94
98
  maxHeaderBytes: maxHeaderBytes,
@@ -333,6 +333,10 @@ function create(opts) {
333
333
  }
334
334
 
335
335
  function cspNonce(req, res, next) {
336
+ // Idempotent: if an earlier cspNonce middleware already set a nonce on
337
+ // this request, keep it — re-generating would desync the header nonce
338
+ // from the one templates already rendered against.
339
+ if (req[property] !== undefined) return next();
336
340
  // Generate the nonce. Cheap (16 bytes from getrandom → SHAKE256 →
337
341
  // base64 encode); do it always for consistency unless `always:
338
342
  // false` was set explicitly.
@@ -261,6 +261,7 @@ function _writeReject(res, message) {
261
261
  * requireJsonContentType: boolean,
262
262
  * trustProxy: boolean|number,
263
263
  * audit: boolean,
264
+ * skipStateless: boolean, // default false — skip validation for Authorization-header / cookieless (not-CSRF-able) requests
264
265
  * }
265
266
  *
266
267
  * @example
@@ -278,7 +279,7 @@ function create(opts) {
278
279
  validateOpts(opts, [
279
280
  "cookie", "tokenLookup", "fieldName", "headerName", "methods", "audit",
280
281
  "trustProxy", "checkOrigin", "allowedOrigins", "requireJsonContentType",
281
- "requireOrigin",
282
+ "requireOrigin", "skipStateless",
282
283
  ], "middleware.csrfProtect");
283
284
  var trustProxy = opts.trustProxy === true || typeof opts.trustProxy === "number"
284
285
  ? opts.trustProxy : false;
@@ -335,6 +336,19 @@ function create(opts) {
335
336
  // is opt-in rather than silent.
336
337
  var requireOriginOpt = opts.requireOrigin === true;
337
338
 
339
+ // skipStateless — skip token VALIDATION for requests that carry an
340
+ // Authorization header (bearer / token auth) or no Cookie header at
341
+ // all. Such requests are not CSRF-able: CSRF abuses a victim's ambient
342
+ // cookie credential, and a token-authenticated or cookieless request
343
+ // has none to abuse. The token is still ISSUED on safe methods so a
344
+ // later cookie-authenticated browser flow on the same app works. Default
345
+ // false (strict — every state-changing request is validated). createApp
346
+ // wires its default csrf with this on so mixed browser-form + token-API
347
+ // surfaces don't reject legitimate API clients. Cross-site form CSRF is
348
+ // unaffected: the browser auto-sends the victim's cookies, so the attack
349
+ // request always carries a Cookie header and is validated.
350
+ var skipStateless = opts.skipStateless === true;
351
+
338
352
  // Cookie issuance config (only when opts.cookie is set).
339
353
  var cookieCfg = null;
340
354
  if (hasCookie) {
@@ -436,6 +450,12 @@ function create(opts) {
436
450
  }
437
451
 
438
452
  return function csrfProtect(req, res, next) {
453
+ // Idempotent: a second csrf mount this request (e.g. createApp wired
454
+ // it AND an operator mounted it again) is a no-op — the first instance
455
+ // already issued + validated.
456
+ if (req._csrfApplied) return next();
457
+ req._csrfApplied = true;
458
+
439
459
  // Issue/refresh the token on EVERY request (safe + state-changing)
440
460
  // when running in cookie mode — templates rendered after a POST
441
461
  // (e.g. error response) still need req.csrfToken populated.
@@ -443,6 +463,14 @@ function create(opts) {
443
463
 
444
464
  if (methods.indexOf(req.method) === -1) return next();
445
465
 
466
+ // Stateless / token-authenticated requests are not CSRF-able — the
467
+ // token was still issued above for any later browser flow.
468
+ if (skipStateless) {
469
+ var hasAuthHeader = !!(req.headers && req.headers.authorization);
470
+ var hasCookieHeader = !!(req.headers && req.headers.cookie);
471
+ if (hasAuthHeader || !hasCookieHeader) return next();
472
+ }
473
+
446
474
  // requireJsonContentType — refuse before the token check.
447
475
  if (requireJsonCt) {
448
476
  var ct = req.headers && req.headers["content-type"];
@@ -120,6 +120,9 @@ function create(opts) {
120
120
  }
121
121
 
122
122
  return function fetchMetadata(req, res, next) {
123
+ // Idempotent: a second fetch-metadata mount this request is a no-op.
124
+ if (req._fetchMetadataChecked) return next();
125
+ req._fetchMetadataChecked = true;
123
126
  if (methods.indexOf(req.method) === -1) return next();
124
127
 
125
128
  var headers = req.headers || {};
@@ -364,6 +364,7 @@ function _resolveBackend(opts) {
364
364
  * statusOnLimit: number, // default 429
365
365
  * bodyOnLimit: string, // default "Too Many Requests"
366
366
  * header: boolean, // default true
367
+ * headerPrefix: string, // default "X-RateLimit-" — builds <prefix>Limit / <prefix>Remaining (e.g. "RateLimit-" for the IETF draft names)
367
368
  * skipPaths: Array<string|RegExp>,
368
369
  * scope: "global"|"per-route",
369
370
  * backend: "memory"|"cluster"|{ take, reset },
@@ -390,7 +391,7 @@ function _resolveBackend(opts) {
390
391
  function create(opts) {
391
392
  opts = opts || {};
392
393
  validateOpts(opts, [
393
- "keyFn", "statusOnLimit", "bodyOnLimit", "header", "skipPaths", "scope",
394
+ "keyFn", "statusOnLimit", "bodyOnLimit", "header", "headerPrefix", "skipPaths", "scope",
394
395
  "backend", "trustProxy", "algorithm",
395
396
  // memory backend (token-bucket)
396
397
  "burst", "refillPerSecond",
@@ -404,6 +405,14 @@ function create(opts) {
404
405
  var statusOnLimit = opts.statusOnLimit || 429;
405
406
  var bodyOnLimit = opts.bodyOnLimit !== undefined ? opts.bodyOnLimit : "Too Many Requests";
406
407
  var emitHeaders = opts.header !== false;
408
+ // headerPrefix (default "X-RateLimit-") builds the limit/remaining header
409
+ // names as <prefix>Limit / <prefix>Remaining. The X-RateLimit-* family is a
410
+ // de-facto convention, not RFC-pinned — operators matching the IETF draft
411
+ // pass "RateLimit-", or a gateway's own prefix. Kept as a matched pair.
412
+ var headerPrefix = (typeof opts.headerPrefix === "string" && opts.headerPrefix.length > 0)
413
+ ? opts.headerPrefix : "X-RateLimit-";
414
+ var limitHeader = headerPrefix + "Limit";
415
+ var remainingHeader = headerPrefix + "Remaining";
407
416
  var skipPaths = opts.skipPaths || [];
408
417
  // Throw at create(): each entry must be a string prefix or a RegExp.
409
418
  // Anything else would crash _shouldSkip with TypeError on the first request.
@@ -429,8 +438,8 @@ function create(opts) {
429
438
 
430
439
  function _writeBlocked(req, res, k, verdict) {
431
440
  if (emitHeaders && typeof res.setHeader === "function") {
432
- res.setHeader("X-RateLimit-Limit", String(verdict.limit));
433
- res.setHeader("X-RateLimit-Remaining", String(verdict.remaining));
441
+ res.setHeader(limitHeader, String(verdict.limit));
442
+ res.setHeader(remainingHeader, String(verdict.remaining));
434
443
  if (verdict.retryAfter > 0) res.setHeader("Retry-After", String(verdict.retryAfter));
435
444
  }
436
445
  try {
@@ -459,8 +468,8 @@ function create(opts) {
459
468
 
460
469
  function _handle(verdict) {
461
470
  if (emitHeaders && typeof res.setHeader === "function") {
462
- res.setHeader("X-RateLimit-Limit", String(verdict.limit));
463
- res.setHeader("X-RateLimit-Remaining", String(verdict.remaining));
471
+ res.setHeader(limitHeader, String(verdict.limit));
472
+ res.setHeader(remainingHeader, String(verdict.remaining));
464
473
  }
465
474
  if (!verdict.allowed) return _writeBlocked(req, res, k, verdict);
466
475
  next();
@@ -90,6 +90,7 @@ function _formatEvent(msg) {
90
90
  * {
91
91
  * heartbeatMs: number|false, // default 15000
92
92
  * headers: object, // extra response headers
93
+ * proxyBuffer: boolean, // default true — sets X-Accel-Buffering: no; false to suppress
93
94
  * }
94
95
  *
95
96
  * @example
@@ -105,13 +106,17 @@ function create(handler, opts) {
105
106
  throw new Error("middleware.sse: handler must be a function (channel, req) => ...");
106
107
  }
107
108
  opts = opts || {};
108
- validateOpts(opts, ["heartbeatMs", "headers"], "middleware.sse");
109
+ validateOpts(opts, ["heartbeatMs", "headers", "proxyBuffer"], "middleware.sse");
109
110
  var heartbeatMs = opts.heartbeatMs === false ? 0
110
111
  : (opts.heartbeatMs != null ? opts.heartbeatMs : DEFAULT_HEARTBEAT_MS);
111
112
  if (heartbeatMs !== 0 && (typeof heartbeatMs !== "number" || !isFinite(heartbeatMs) || heartbeatMs <= 0)) {
112
113
  throw new Error("middleware.sse: heartbeatMs must be a positive finite number or false");
113
114
  }
114
115
  var extraHeaders = opts.headers || {};
116
+ // proxyBuffer (default true) sets `X-Accel-Buffering: no` (the nginx hint
117
+ // that disables proxy buffering). Pass false when not behind nginx, or
118
+ // when buffering is controlled at the load balancer, to suppress it.
119
+ var proxyBuffer = opts.proxyBuffer !== false;
115
120
 
116
121
  return async function sseMiddleware(req, res) {
117
122
  if (typeof res.writeHead !== "function" || typeof res.write !== "function") {
@@ -119,13 +124,14 @@ function create(handler, opts) {
119
124
  // unusual. Fail closed rather than silently dropping the handler.
120
125
  throw new Error("middleware.sse: res does not support writeHead/write — wire SSE only on HTTP routes");
121
126
  }
122
- var headers = Object.assign({
123
- "Content-Type": "text/event-stream; charset=utf-8",
124
- "Cache-Control": "no-cache, no-transform",
125
- "Connection": "keep-alive",
126
- // Disable nginx response buffering when terminating behind it.
127
- "X-Accel-Buffering": "no",
128
- }, extraHeaders);
127
+ var baseHeaders = {
128
+ "Content-Type": "text/event-stream; charset=utf-8",
129
+ "Cache-Control": "no-cache, no-transform",
130
+ "Connection": "keep-alive",
131
+ };
132
+ // Disable nginx response buffering when terminating behind it.
133
+ if (proxyBuffer) baseHeaders["X-Accel-Buffering"] = "no";
134
+ var headers = Object.assign(baseHeaders, extraHeaders);
129
135
  // Append Vary: Accept so a proxy doesn't serve a cached non-SSE
130
136
  // response on the same URL to a future client.
131
137
  res.writeHead(requestHelpers.HTTP_STATUS.OK, headers);
package/lib/sse.js CHANGED
@@ -27,6 +27,11 @@
27
27
  * input (default SseError)
28
28
  * audit — bool, default true. Emit SSE lifecycle audit
29
29
  * events.
30
+ * proxyBuffer — bool, default true. Sets `X-Accel-Buffering:
31
+ * no` (the nginx hint that disables proxy
32
+ * buffering of the stream). Pass false when not
33
+ * behind nginx, or when buffering is handled at
34
+ * the load balancer, to suppress the header.
30
35
  *
31
36
  * channel.send({ event, id, data, retry })
32
37
  * Writes a single SSE event. Each field is validated; LF/CR/NUL
@@ -236,18 +241,20 @@ function create(req, res, opts) {
236
241
  JSON.stringify(heartbeatMs) + ")");
237
242
  }
238
243
  var auditOn = opts.audit !== false;
244
+ // proxyBuffer (default true) sets `X-Accel-Buffering: no` — the nginx hint
245
+ // that defeats proxy buffering of the event stream. Operators not behind
246
+ // nginx, or whose buffering is controlled at the load balancer, pass
247
+ // proxyBuffer: false to suppress the nginx-specific header.
248
+ var proxyBuffer = opts.proxyBuffer !== false;
239
249
 
240
250
  var lastEventId = _readLastEventId(req);
241
251
 
242
252
  // Headers. text/event-stream is the contract; Cache-Control: no-cache
243
- // and Connection: keep-alive (h1) are the operationally required
244
- // pair. X-Accel-Buffering: no defeats nginx-style proxy buffering;
245
- // operators behind a proxy that doesn't honor this set proxyBuffer:
246
- // false on their LB.
253
+ // and Connection: keep-alive (h1) are the operationally required pair.
247
254
  if (typeof res.setHeader === "function") {
248
255
  res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
249
256
  res.setHeader("Cache-Control", "no-cache, no-transform");
250
- res.setHeader("X-Accel-Buffering", "no");
257
+ if (proxyBuffer) res.setHeader("X-Accel-Buffering", "no");
251
258
  // Connection: keep-alive only meaningful on h1; h2 streams stay
252
259
  // open until either side closes. node:http2 surfaces res.stream
253
260
  // (h2 ServerHttp2Stream) where setHeader works the same.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.13.45",
3
+ "version": "0.14.0",
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:4f2a624b-9183-4e8a-8807-eb827d32fb2b",
5
+ "serialNumber": "urn:uuid:e6a01e68-c8b4-4c27-b3b5-f1ddf1aad212",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-30T01:55:57.060Z",
8
+ "timestamp": "2026-05-30T04:02:38.743Z",
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.13.45",
22
+ "bom-ref": "@blamejs/core@0.14.0",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.13.45",
25
+ "version": "0.14.0",
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.13.45",
29
+ "purl": "pkg:npm/%40blamejs/core@0.14.0",
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.13.45",
57
+ "ref": "@blamejs/core@0.14.0",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]