@blamejs/core 0.8.13 → 0.8.16
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 +6 -0
- package/README.md +3 -1
- package/index.js +12 -1
- package/lib/a2a.js +272 -0
- package/lib/ai-input.js +151 -0
- package/lib/audit.js +6 -0
- package/lib/dark-patterns.js +357 -0
- package/lib/framework-error.js +34 -0
- package/lib/graphql-federation.js +176 -0
- package/lib/http-client.js +16 -0
- package/lib/mail-auth.js +33 -10
- package/lib/mail-dkim.js +44 -2
- package/lib/mcp.js +301 -0
- package/lib/middleware/sse.js +18 -20
- package/lib/network-smtp-policy.js +57 -5
- package/lib/network-tls.js +33 -0
- package/lib/request-helpers.js +34 -0
- package/lib/router.js +28 -0
- package/lib/sse.js +349 -0
- package/lib/vault/index.js +4 -0
- package/lib/vault/seal-pem-file.js +283 -0
- package/lib/websocket.js +15 -0
- package/package.json +2 -2
- package/sbom.cyclonedx.json +6 -6
package/lib/request-helpers.js
CHANGED
|
@@ -325,6 +325,38 @@ function parseQualityList(headerValue, opts) {
|
|
|
325
325
|
return out;
|
|
326
326
|
}
|
|
327
327
|
|
|
328
|
+
// safeHeadersDistinct(req) — defensive accessor for req.headersDistinct.
|
|
329
|
+
//
|
|
330
|
+
// Node CVE-2026-21710: req.headersDistinct is a getter; reading
|
|
331
|
+
// __proto__ on the underlying header bag throws synchronously inside
|
|
332
|
+
// the getter, so a request bearing a __proto__: header escapes any
|
|
333
|
+
// handler-level try/catch (the throw happens at property-access time,
|
|
334
|
+
// not later). This helper computes the same shape (lowercased header-
|
|
335
|
+
// name → array of values) directly from req.rawHeaders, bypassing the
|
|
336
|
+
// faulty getter entirely.
|
|
337
|
+
//
|
|
338
|
+
// Returns a null-prototype object so framework code can iterate its
|
|
339
|
+
// keys without inheriting Object.prototype properties — the same shape
|
|
340
|
+
// Node's headersDistinct produces, minus the throwing getter.
|
|
341
|
+
function safeHeadersDistinct(req) {
|
|
342
|
+
var out = Object.create(null);
|
|
343
|
+
if (!req || !Array.isArray(req.rawHeaders)) return out;
|
|
344
|
+
var raw = req.rawHeaders;
|
|
345
|
+
for (var i = 0; i + 1 < raw.length; i += 2) {
|
|
346
|
+
var name = raw[i];
|
|
347
|
+
var value = raw[i + 1];
|
|
348
|
+
if (typeof name !== "string" || typeof value !== "string") continue;
|
|
349
|
+
var lower = name.toLowerCase();
|
|
350
|
+
// skip __proto__ / constructor / prototype as keys — they are the
|
|
351
|
+
// exact strings that triggered the upstream getter throw, and we
|
|
352
|
+
// refuse to surface them as accessible header names.
|
|
353
|
+
if (lower === "__proto__" || lower === "constructor" || lower === "prototype") continue;
|
|
354
|
+
if (out[lower]) out[lower].push(value);
|
|
355
|
+
else out[lower] = [value];
|
|
356
|
+
}
|
|
357
|
+
return out;
|
|
358
|
+
}
|
|
359
|
+
|
|
328
360
|
module.exports = {
|
|
329
361
|
resolveRoute: resolveRoute,
|
|
330
362
|
captureResponseStatus: captureResponseStatus,
|
|
@@ -336,5 +368,7 @@ module.exports = {
|
|
|
336
368
|
clientIp: clientIp,
|
|
337
369
|
requestProtocol: requestProtocol,
|
|
338
370
|
appendVary: appendVary,
|
|
371
|
+
// CVE-2026-21710 wrap — safe alternative to req.headersDistinct
|
|
372
|
+
safeHeadersDistinct: safeHeadersDistinct,
|
|
339
373
|
HTTP_STATUS: HTTP_STATUS,
|
|
340
374
|
};
|
package/lib/router.js
CHANGED
|
@@ -620,6 +620,34 @@ class Router {
|
|
|
620
620
|
};
|
|
621
621
|
var server;
|
|
622
622
|
if (tlsOptions) {
|
|
623
|
+
// CVE-2026-21637 — Node propagates synchronous throws from a
|
|
624
|
+
// user-supplied SNICallback up through the TLS handshake
|
|
625
|
+
// listener; an unhandled throw on an unexpected servername
|
|
626
|
+
// crashes the listener. Wrap the operator's SNICallback so any
|
|
627
|
+
// synchronous error becomes a clean async (err, null) callback.
|
|
628
|
+
// RFC 6066 §3 expects the server to abort the handshake on a
|
|
629
|
+
// failed callback, not crash the process.
|
|
630
|
+
if (tlsOptions.SNICallback && typeof tlsOptions.SNICallback === "function") {
|
|
631
|
+
var operatorSniCallback = tlsOptions.SNICallback;
|
|
632
|
+
tlsOptions = Object.assign({}, tlsOptions, {
|
|
633
|
+
SNICallback: function (servername, cb) {
|
|
634
|
+
try {
|
|
635
|
+
operatorSniCallback(servername, cb);
|
|
636
|
+
} catch (err) {
|
|
637
|
+
log.error("SNICallback threw for servername=" +
|
|
638
|
+
JSON.stringify(servername) + ": " + (err && err.message));
|
|
639
|
+
try { cb(err, null); } catch (_e) { /* cb already invoked */ }
|
|
640
|
+
}
|
|
641
|
+
},
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
// TLS 1.3 minimum — operator can override but the framework's
|
|
645
|
+
// default refuses pre-1.3 negotiation. Without this set the
|
|
646
|
+
// bare {key, cert} path inherits Node's TLSv1.2 default; the
|
|
647
|
+
// outbound httpClient already pins TLS 1.3.
|
|
648
|
+
if (!tlsOptions.minVersion) {
|
|
649
|
+
tlsOptions = Object.assign({ minVersion: "TLSv1.3" }, tlsOptions);
|
|
650
|
+
}
|
|
623
651
|
// h2-capable server with h1 fallback via ALPN. ["h2", "http/1.1"]
|
|
624
652
|
// means modern clients negotiate h2 (preferred); legacy clients
|
|
625
653
|
// fall back to h1. allowHTTP1: true is what makes the same server
|
package/lib/sse.js
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Server-Sent Events primitive — text/event-stream transport with
|
|
4
|
+
* newline-injection refusal in event:/id:/data: fields.
|
|
5
|
+
*
|
|
6
|
+
* The SSE wire format is line-oriented (W3C HTML Living Standard
|
|
7
|
+
* §server-sent-events-spec): each field is a line of the form
|
|
8
|
+
* "<name>: <value>" terminated by a single LF, and an empty line
|
|
9
|
+
* separates events. Any LF/CR/NUL inside a value silently splits the
|
|
10
|
+
* field, letting an attacker forge subsequent events, the event id
|
|
11
|
+
* (which the client echoes back as Last-Event-ID on reconnect), or
|
|
12
|
+
* the message data. Three CVEs in one quarter — CVE-2026-33128 (h3),
|
|
13
|
+
* CVE-2026-29085 (Hono), CVE-2026-44217 (sse-channel) — published in
|
|
14
|
+
* the same vulnerability class.
|
|
15
|
+
*
|
|
16
|
+
* Public API:
|
|
17
|
+
*
|
|
18
|
+
* sse.create(req, res, opts) → channel
|
|
19
|
+
* Wires the response stream as text/event-stream, sets the
|
|
20
|
+
* SSE-required headers, and returns a channel object. opts:
|
|
21
|
+
* heartbeatMs — interval for `:keepalive` comment frames
|
|
22
|
+
* (default 15 s; pass 0 to disable)
|
|
23
|
+
* retryMs — initial reconnection-time advisory sent on
|
|
24
|
+
* stream open (sets the `retry:` field once;
|
|
25
|
+
* omitted when null/undefined)
|
|
26
|
+
* errorClass — FrameworkError subclass to throw on bad
|
|
27
|
+
* input (default SseError)
|
|
28
|
+
* audit — bool, default true. Emit SSE lifecycle audit
|
|
29
|
+
* events.
|
|
30
|
+
*
|
|
31
|
+
* channel.send({ event, id, data, retry })
|
|
32
|
+
* Writes a single SSE event. Each field is validated; LF/CR/NUL
|
|
33
|
+
* anywhere in event/id is refused via `errorClass`. data is
|
|
34
|
+
* allowed to contain LF — the framework splits it into multiple
|
|
35
|
+
* `data:` lines per the spec — but CR and NUL are refused. retry
|
|
36
|
+
* must be a non-negative finite integer.
|
|
37
|
+
*
|
|
38
|
+
* channel.comment(text)
|
|
39
|
+
* Writes a `:<text>` comment line (used for keepalive). LF/CR/NUL
|
|
40
|
+
* in `text` are refused.
|
|
41
|
+
*
|
|
42
|
+
* channel.close()
|
|
43
|
+
* Ends the underlying response stream and stops the heartbeat
|
|
44
|
+
* timer. Idempotent.
|
|
45
|
+
*
|
|
46
|
+
* channel.lastEventId
|
|
47
|
+
* The Last-Event-ID header value from the initial request, or
|
|
48
|
+
* null. Sanitized — any LF/CR/NUL renders the header null
|
|
49
|
+
* (refuse-on-bad-input rather than passing through to handlers).
|
|
50
|
+
*
|
|
51
|
+
* sse.serializeEvent({ event, id, data, retry })
|
|
52
|
+
* Returns the SSE-encoded string for a single event. Same
|
|
53
|
+
* validation rules as channel.send. Exposed for operators that
|
|
54
|
+
* buffer events through their own queue before writing.
|
|
55
|
+
*
|
|
56
|
+
* Error discipline:
|
|
57
|
+
* channel.send and serializeEvent THROW errorClass on bad input.
|
|
58
|
+
* SSE is not a drop-silent surface — a refused event is a
|
|
59
|
+
* programming bug, and silently dropping would mask the injection
|
|
60
|
+
* attempt the refusal exists to flag. close() is idempotent and
|
|
61
|
+
* never throws.
|
|
62
|
+
*
|
|
63
|
+
* Composition:
|
|
64
|
+
* - Composes with router via raw req/res — no router-specific
|
|
65
|
+
* coupling. Works under h1 and h2 (h2 keeps the stream open
|
|
66
|
+
* identically; the response is just chunked-transfer at h1 and
|
|
67
|
+
* a long-running DATA-frame stream at h2).
|
|
68
|
+
* - Audit emissions go through audit.safeEmit so SSE doesn't
|
|
69
|
+
* escape audit-bus failures back to the caller.
|
|
70
|
+
*/
|
|
71
|
+
|
|
72
|
+
var C = require("./constants");
|
|
73
|
+
var audit = require("./audit");
|
|
74
|
+
var { SseError } = require("./framework-error");
|
|
75
|
+
|
|
76
|
+
// Per W3C SSE — the wire format uses LF as terminator. A single LF
|
|
77
|
+
// inside any field splits the value at the parser. CR is canonicalized
|
|
78
|
+
// to LF by the parser (CR-only and CRLF terminators are also valid),
|
|
79
|
+
// so CR is equally injection-shaped. NUL is refused universally — it
|
|
80
|
+
// has no place in an event-stream wire-form and any presence is
|
|
81
|
+
// suspicious.
|
|
82
|
+
// eslint-disable-next-line no-control-regex
|
|
83
|
+
var INJECTION_RE = /[\r\n\u0000]/;
|
|
84
|
+
|
|
85
|
+
// retry: must be a non-negative finite integer. Browsers floor /
|
|
86
|
+
// reject non-integer or negative values; refuse them at the source so
|
|
87
|
+
// downstream behavior is uniform.
|
|
88
|
+
function _validateRetry(retry, errorClass) {
|
|
89
|
+
if (retry === undefined || retry === null) return null;
|
|
90
|
+
if (typeof retry !== "number" || !isFinite(retry) || retry < 0 ||
|
|
91
|
+
Math.floor(retry) !== retry) {
|
|
92
|
+
throw errorClass.factory("BAD_RETRY",
|
|
93
|
+
"sse.send: retry must be a non-negative finite integer (got " +
|
|
94
|
+
JSON.stringify(retry) + ")");
|
|
95
|
+
}
|
|
96
|
+
return retry;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function _refuseInjection(field, value, errorClass) {
|
|
100
|
+
if (typeof value !== "string") {
|
|
101
|
+
throw errorClass.factory("BAD_FIELD",
|
|
102
|
+
"sse.send: " + field + " must be a string");
|
|
103
|
+
}
|
|
104
|
+
// Length-bound BEFORE the regex test — _capField applies a tighter
|
|
105
|
+
// cap further along, but the regex itself runs against the full
|
|
106
|
+
// value so we bound here too.
|
|
107
|
+
if (value.length > MAX_DATA_BYTES) {
|
|
108
|
+
throw errorClass.factory("FIELD_TOO_LARGE",
|
|
109
|
+
"sse.send: " + field + " too large for injection scan");
|
|
110
|
+
}
|
|
111
|
+
if (INJECTION_RE.test(value)) { // allow:regex-no-length-cap — value length capped above
|
|
112
|
+
audit.safeEmit({
|
|
113
|
+
action: "sse.injection_refused",
|
|
114
|
+
outcome: "denied",
|
|
115
|
+
metadata: { field: field, length: value.length },
|
|
116
|
+
});
|
|
117
|
+
throw errorClass.factory("INJECTION",
|
|
118
|
+
"sse.send: " + field + " contains LF/CR/NUL — refused " +
|
|
119
|
+
"(CVE-2026-33128 / 29085 / 44217 class)");
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Field caps. Values aren't open-ended — a 100 MiB `id:` is an abuse
|
|
124
|
+
// shape. Operators who need larger bodies use the chunked binary
|
|
125
|
+
// transports (websocket / file-upload). SSE is for text events.
|
|
126
|
+
var MAX_EVENT_BYTES = C.BYTES.kib(8);
|
|
127
|
+
var MAX_ID_BYTES = C.BYTES.kib(8);
|
|
128
|
+
var MAX_DATA_BYTES = C.BYTES.mib(1);
|
|
129
|
+
|
|
130
|
+
function _capField(field, value, capBytes, errorClass) {
|
|
131
|
+
var len = Buffer.byteLength(value, "utf8");
|
|
132
|
+
if (len > capBytes) {
|
|
133
|
+
throw errorClass.factory("FIELD_TOO_LARGE",
|
|
134
|
+
"sse.send: " + field + " exceeds cap (" + len + " > " +
|
|
135
|
+
capBytes + " bytes)");
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function serializeEvent(opts, errorClass) {
|
|
140
|
+
errorClass = errorClass || SseError;
|
|
141
|
+
if (!opts || typeof opts !== "object") {
|
|
142
|
+
throw errorClass.factory("BAD_OPTS", "sse.serializeEvent: opts required");
|
|
143
|
+
}
|
|
144
|
+
var out = "";
|
|
145
|
+
// Field order: id, event, retry, data — matches the framework's
|
|
146
|
+
// historical b.middleware.sse layout. The W3C SSE spec is order-
|
|
147
|
+
// agnostic, but consumers (incl. the existing wiki test fixtures)
|
|
148
|
+
// pin this order.
|
|
149
|
+
if (opts.id !== undefined && opts.id !== null) {
|
|
150
|
+
_refuseInjection("id", opts.id, errorClass);
|
|
151
|
+
_capField("id", opts.id, MAX_ID_BYTES, errorClass);
|
|
152
|
+
out += "id: " + opts.id + "\n";
|
|
153
|
+
}
|
|
154
|
+
if (opts.event !== undefined && opts.event !== null) {
|
|
155
|
+
_refuseInjection("event", opts.event, errorClass);
|
|
156
|
+
_capField("event", opts.event, MAX_EVENT_BYTES, errorClass);
|
|
157
|
+
out += "event: " + opts.event + "\n";
|
|
158
|
+
}
|
|
159
|
+
var retry = _validateRetry(opts.retry, errorClass);
|
|
160
|
+
if (retry !== null) {
|
|
161
|
+
out += "retry: " + retry + "\n";
|
|
162
|
+
}
|
|
163
|
+
if (opts.data !== undefined && opts.data !== null) {
|
|
164
|
+
if (typeof opts.data !== "string") {
|
|
165
|
+
throw errorClass.factory("BAD_FIELD",
|
|
166
|
+
"sse.send: data must be a string");
|
|
167
|
+
}
|
|
168
|
+
_capField("data", opts.data, MAX_DATA_BYTES, errorClass);
|
|
169
|
+
// CR / NUL refused; LF allowed (split into multiple data: lines).
|
|
170
|
+
// eslint-disable-next-line no-control-regex
|
|
171
|
+
if (/[\r\u0000]/.test(opts.data)) {
|
|
172
|
+
audit.safeEmit({
|
|
173
|
+
action: "sse.injection_refused",
|
|
174
|
+
outcome: "denied",
|
|
175
|
+
metadata: { field: "data", length: opts.data.length, char: "cr-or-nul" },
|
|
176
|
+
});
|
|
177
|
+
throw errorClass.factory("INJECTION",
|
|
178
|
+
"sse.send: data contains CR or NUL — refused");
|
|
179
|
+
}
|
|
180
|
+
var lines = opts.data.split("\n");
|
|
181
|
+
for (var i = 0; i < lines.length; i += 1) {
|
|
182
|
+
out += "data: " + lines[i] + "\n";
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// Empty line separator.
|
|
186
|
+
out += "\n";
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function _validateComment(text, errorClass) {
|
|
191
|
+
if (typeof text !== "string") {
|
|
192
|
+
throw errorClass.factory("BAD_FIELD",
|
|
193
|
+
"sse.comment: text must be a string");
|
|
194
|
+
}
|
|
195
|
+
if (text.length > MAX_DATA_BYTES) {
|
|
196
|
+
throw errorClass.factory("FIELD_TOO_LARGE",
|
|
197
|
+
"sse.comment: text too large for injection scan");
|
|
198
|
+
}
|
|
199
|
+
if (INJECTION_RE.test(text)) { // allow:regex-no-length-cap — text length capped above
|
|
200
|
+
audit.safeEmit({
|
|
201
|
+
action: "sse.injection_refused",
|
|
202
|
+
outcome: "denied",
|
|
203
|
+
metadata: { field: "comment", length: text.length },
|
|
204
|
+
});
|
|
205
|
+
throw errorClass.factory("INJECTION",
|
|
206
|
+
"sse.comment: text contains LF/CR/NUL — refused");
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Sanitize the Last-Event-ID header value the client echoed on
|
|
211
|
+
// reconnect. Per the spec the client SHOULD send the most recent id,
|
|
212
|
+
// but we receive raw header bytes — refuse the value entirely (return
|
|
213
|
+
// null) if it carries any injection-shaped char.
|
|
214
|
+
function _readLastEventId(req) {
|
|
215
|
+
if (!req || !req.headers) return null;
|
|
216
|
+
var raw = req.headers["last-event-id"];
|
|
217
|
+
if (typeof raw !== "string" || raw.length === 0) return null;
|
|
218
|
+
if (INJECTION_RE.test(raw)) return null;
|
|
219
|
+
if (Buffer.byteLength(raw, "utf8") > MAX_ID_BYTES) return null;
|
|
220
|
+
return raw;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function create(req, res, opts) {
|
|
224
|
+
opts = opts || {};
|
|
225
|
+
var errorClass = opts.errorClass || SseError;
|
|
226
|
+
if (!res || typeof res.write !== "function" || typeof res.end !== "function") {
|
|
227
|
+
throw errorClass.factory("BAD_RES",
|
|
228
|
+
"sse.create: res must be a writable response stream");
|
|
229
|
+
}
|
|
230
|
+
var heartbeatMs = opts.heartbeatMs;
|
|
231
|
+
if (heartbeatMs === undefined) heartbeatMs = C.TIME.seconds(15);
|
|
232
|
+
if (typeof heartbeatMs !== "number" || !isFinite(heartbeatMs) ||
|
|
233
|
+
heartbeatMs < 0 || Math.floor(heartbeatMs) !== heartbeatMs) {
|
|
234
|
+
throw errorClass.factory("BAD_OPTS",
|
|
235
|
+
"sse.create: heartbeatMs must be a non-negative integer ms (got " +
|
|
236
|
+
JSON.stringify(heartbeatMs) + ")");
|
|
237
|
+
}
|
|
238
|
+
var auditOn = opts.audit !== false;
|
|
239
|
+
|
|
240
|
+
var lastEventId = _readLastEventId(req);
|
|
241
|
+
|
|
242
|
+
// 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.
|
|
247
|
+
if (typeof res.setHeader === "function") {
|
|
248
|
+
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
|
|
249
|
+
res.setHeader("Cache-Control", "no-cache, no-transform");
|
|
250
|
+
res.setHeader("X-Accel-Buffering", "no");
|
|
251
|
+
// Connection: keep-alive only meaningful on h1; h2 streams stay
|
|
252
|
+
// open until either side closes. node:http2 surfaces res.stream
|
|
253
|
+
// (h2 ServerHttp2Stream) where setHeader works the same.
|
|
254
|
+
if (req && req.httpVersionMajor !== 2) {
|
|
255
|
+
res.setHeader("Connection", "keep-alive");
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (typeof res.flushHeaders === "function") {
|
|
259
|
+
try { res.flushHeaders(); } catch (_e) { /* response may have flushed already */ }
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
var closed = false;
|
|
263
|
+
var heartbeatTimer = null;
|
|
264
|
+
|
|
265
|
+
function _writeRaw(s) {
|
|
266
|
+
if (closed) {
|
|
267
|
+
throw errorClass.factory("CLOSED",
|
|
268
|
+
"sse.send: channel closed");
|
|
269
|
+
}
|
|
270
|
+
res.write(s);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function send(eventOpts) {
|
|
274
|
+
var encoded = serializeEvent(eventOpts || {}, errorClass);
|
|
275
|
+
_writeRaw(encoded);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function comment(text) {
|
|
279
|
+
_validateComment(text, errorClass);
|
|
280
|
+
_writeRaw(":" + text + "\n\n");
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function close() {
|
|
284
|
+
if (closed) return;
|
|
285
|
+
closed = true;
|
|
286
|
+
if (heartbeatTimer) {
|
|
287
|
+
clearInterval(heartbeatTimer);
|
|
288
|
+
heartbeatTimer = null;
|
|
289
|
+
}
|
|
290
|
+
try { res.end(); } catch (_e) { /* already destroyed */ }
|
|
291
|
+
if (auditOn) {
|
|
292
|
+
audit.safeEmit({
|
|
293
|
+
action: "sse.channel_closed",
|
|
294
|
+
outcome: "success",
|
|
295
|
+
metadata: { lastEventId: lastEventId },
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Stream-side close detection — when the client disconnects, free
|
|
301
|
+
// the heartbeat timer.
|
|
302
|
+
if (typeof res.on === "function") {
|
|
303
|
+
res.on("close", close);
|
|
304
|
+
res.on("error", function (_e) { close(); });
|
|
305
|
+
res.on("finish", function () { closed = true; if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; } });
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Optional retry: advisory on open.
|
|
309
|
+
if (opts.retryMs !== undefined && opts.retryMs !== null) {
|
|
310
|
+
var validatedRetry = _validateRetry(opts.retryMs, errorClass);
|
|
311
|
+
_writeRaw("retry: " + validatedRetry + "\n\n");
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Heartbeat keeps intermediaries from idle-timing out the stream and
|
|
315
|
+
// gives the client a reliable progress signal. Timer is unref'd so a
|
|
316
|
+
// single live SSE channel doesn't pin the event loop on shutdown.
|
|
317
|
+
if (heartbeatMs > 0) {
|
|
318
|
+
heartbeatTimer = setInterval(function () {
|
|
319
|
+
if (closed) return;
|
|
320
|
+
try { _writeRaw(":keepalive\n\n"); }
|
|
321
|
+
catch (_e) { close(); }
|
|
322
|
+
}, heartbeatMs).unref();
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (auditOn) {
|
|
326
|
+
audit.safeEmit({
|
|
327
|
+
action: "sse.channel_opened",
|
|
328
|
+
outcome: "success",
|
|
329
|
+
metadata: { lastEventId: lastEventId, heartbeatMs: heartbeatMs },
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return {
|
|
334
|
+
send: send,
|
|
335
|
+
comment: comment,
|
|
336
|
+
close: close,
|
|
337
|
+
get lastEventId() { return lastEventId; },
|
|
338
|
+
get closed() { return closed; },
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
module.exports = {
|
|
343
|
+
create: create,
|
|
344
|
+
serializeEvent: serializeEvent,
|
|
345
|
+
// Cap exposure for operators wiring their own framing.
|
|
346
|
+
MAX_EVENT_BYTES: MAX_EVENT_BYTES,
|
|
347
|
+
MAX_ID_BYTES: MAX_ID_BYTES,
|
|
348
|
+
MAX_DATA_BYTES: MAX_DATA_BYTES,
|
|
349
|
+
};
|
package/lib/vault/index.js
CHANGED
|
@@ -292,6 +292,8 @@ function getMode() {
|
|
|
292
292
|
|
|
293
293
|
var vaultAad = require("../vault-aad");
|
|
294
294
|
|
|
295
|
+
var sealPemFileModule = require("./seal-pem-file");
|
|
296
|
+
|
|
295
297
|
module.exports = {
|
|
296
298
|
init: init,
|
|
297
299
|
seal: seal,
|
|
@@ -301,6 +303,8 @@ module.exports = {
|
|
|
301
303
|
getCurrentPassphrase: getCurrentPassphrase,
|
|
302
304
|
getMode: getMode,
|
|
303
305
|
VaultError: VaultError,
|
|
306
|
+
sealPemFile: sealPemFileModule.sealPemFile,
|
|
307
|
+
SealPemFileError: sealPemFileModule.SealPemFileError,
|
|
304
308
|
// Testing helpers — not part of the public contract
|
|
305
309
|
_resetForTest: function () {
|
|
306
310
|
if (currentPassphrase) safeBuffer.secureZero(currentPassphrase);
|