@goodandready/dsh-plugin-notify 0.4.1 → 0.4.2
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 +8 -0
- package/lib/index.js +60 -57
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
5
5
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.4.2] - 2026-10-10
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
- **Request trust enforcement on HTTP/SSE routes** (#46): Route SSE and Push subscriptions through `isTrustedRequest` / `getRequestRejection`, ensuring origin and session checks with loopback fallback.
|
|
11
|
+
- **Webhook error logging diagnostic** (#49): Log human-readable channel name instead of truncated webhook URL on POST failure, preventing credential token leakage.
|
|
12
|
+
- **Multi-instance SSE client isolation** (#50): Isolate SSE client sets per plugin lifecycle context on dispose, avoiding cross-instance connection drops on reload.
|
|
13
|
+
- **Deterministic memory bound on turnStarts map** (#51): Enforce deterministic FIFO eviction bound (`maxSize = 100`) on `turnStarts` map under session bursts.
|
|
14
|
+
|
|
7
15
|
## [0.4.1] - 2026-10-10
|
|
8
16
|
|
|
9
17
|
### Fixed
|
package/lib/index.js
CHANGED
|
@@ -94,7 +94,7 @@ const warnedLegacyUrls = new Set();
|
|
|
94
94
|
* Purge turnStarts entries older than maxAgeMs (default 2 hours) to prevent
|
|
95
95
|
* memory leaks from abandoned or cancelled sessions.
|
|
96
96
|
*/
|
|
97
|
-
export function cleanupTurnStarts(maxAgeMs = 7200000, now = Date.now()) {
|
|
97
|
+
export function cleanupTurnStarts(maxAgeMs = 7200000, now = Date.now(), maxSize = 100) {
|
|
98
98
|
let deleted = 0;
|
|
99
99
|
for (const [sid, start] of turnStarts.entries()) {
|
|
100
100
|
if (now - start > maxAgeMs) {
|
|
@@ -102,6 +102,13 @@ export function cleanupTurnStarts(maxAgeMs = 7200000, now = Date.now()) {
|
|
|
102
102
|
deleted++;
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
|
+
if (typeof maxSize === 'number' && maxSize > 0 && turnStarts.size >= maxSize) {
|
|
106
|
+
for (const [sid] of turnStarts.entries()) {
|
|
107
|
+
if (turnStarts.size < maxSize) break;
|
|
108
|
+
turnStarts.delete(sid);
|
|
109
|
+
deleted++;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
105
112
|
return deleted;
|
|
106
113
|
}
|
|
107
114
|
|
|
@@ -168,17 +175,23 @@ function connectionOf(ctx) {
|
|
|
168
175
|
* Delegates to DSH connection service (Host/Origin fence + browser authentication session),
|
|
169
176
|
* with loopback-only fallback when connection service is absent (e.g. standalone tests).
|
|
170
177
|
*/
|
|
171
|
-
export function
|
|
172
|
-
if (!request || !request.headers) return
|
|
178
|
+
export function getRequestRejection(request, targetCtx) {
|
|
179
|
+
if (!request || !request.headers) return 403;
|
|
173
180
|
|
|
174
181
|
const conn = connectionOf(targetCtx);
|
|
175
182
|
if (conn && typeof conn.requestRejection === 'function') {
|
|
176
183
|
const rejection = conn.requestRejection(request);
|
|
177
|
-
|
|
184
|
+
if (rejection !== void 0) return rejection;
|
|
185
|
+
return void 0;
|
|
178
186
|
}
|
|
179
187
|
|
|
180
188
|
const remoteAddr = request.socket?.remoteAddress || request.connection?.remoteAddress;
|
|
181
|
-
|
|
189
|
+
if (!remoteAddr || !isLoopback(remoteAddr)) return 403;
|
|
190
|
+
return void 0;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function isTrustedRequest(request, targetCtx) {
|
|
194
|
+
return getRequestRejection(request, targetCtx) === void 0;
|
|
182
195
|
}
|
|
183
196
|
|
|
184
197
|
export function broadcastSse(eventData) {
|
|
@@ -196,7 +209,7 @@ export function broadcastSse(eventData) {
|
|
|
196
209
|
return sent;
|
|
197
210
|
}
|
|
198
211
|
|
|
199
|
-
export function handleSseConnection(req, res, targetCtx) {
|
|
212
|
+
export function handleSseConnection(req, res, targetCtx, instanceClients = null) {
|
|
200
213
|
if (req.method !== 'GET') {
|
|
201
214
|
res.statusCode = 405;
|
|
202
215
|
if (typeof res.setHeader === 'function') res.setHeader('allow', 'GET');
|
|
@@ -205,31 +218,16 @@ export function handleSseConnection(req, res, targetCtx) {
|
|
|
205
218
|
return;
|
|
206
219
|
}
|
|
207
220
|
|
|
208
|
-
const
|
|
209
|
-
if (
|
|
210
|
-
|
|
211
|
-
if (
|
|
212
|
-
res.
|
|
213
|
-
if (typeof res.writeHead === 'function') {
|
|
214
|
-
res.writeHead(rejection, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
215
|
-
}
|
|
216
|
-
if (typeof res.end === 'function') {
|
|
217
|
-
res.end(rejection === 401 ? 'Unauthorized' : 'Forbidden');
|
|
218
|
-
}
|
|
219
|
-
return;
|
|
221
|
+
const rejection = getRequestRejection(req, targetCtx);
|
|
222
|
+
if (rejection !== void 0) {
|
|
223
|
+
res.statusCode = rejection;
|
|
224
|
+
if (typeof res.writeHead === 'function') {
|
|
225
|
+
res.writeHead(rejection, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
220
226
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
if (!remoteAddr || !isLoopback(remoteAddr)) {
|
|
224
|
-
res.statusCode = 403;
|
|
225
|
-
if (typeof res.writeHead === 'function') {
|
|
226
|
-
res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
227
|
-
}
|
|
228
|
-
if (typeof res.end === 'function') {
|
|
229
|
-
res.end('Forbidden');
|
|
230
|
-
}
|
|
231
|
-
return;
|
|
227
|
+
if (typeof res.end === 'function') {
|
|
228
|
+
res.end(rejection === 401 ? 'Unauthorized' : 'Forbidden');
|
|
232
229
|
}
|
|
230
|
+
return;
|
|
233
231
|
}
|
|
234
232
|
|
|
235
233
|
if (typeof res.writeHead === 'function') {
|
|
@@ -243,9 +241,15 @@ export function handleSseConnection(req, res, targetCtx) {
|
|
|
243
241
|
res.write(': connected\n\n');
|
|
244
242
|
}
|
|
245
243
|
sseClients.add(res);
|
|
244
|
+
if (instanceClients) {
|
|
245
|
+
instanceClients.add(res);
|
|
246
|
+
}
|
|
246
247
|
|
|
247
248
|
const onEnd = () => {
|
|
248
249
|
sseClients.delete(res);
|
|
250
|
+
if (instanceClients) {
|
|
251
|
+
instanceClients.delete(res);
|
|
252
|
+
}
|
|
249
253
|
};
|
|
250
254
|
if (typeof req.on === 'function') req.on('close', onEnd);
|
|
251
255
|
if (typeof res.on === 'function') {
|
|
@@ -254,8 +258,9 @@ export function handleSseConnection(req, res, targetCtx) {
|
|
|
254
258
|
}
|
|
255
259
|
}
|
|
256
260
|
|
|
257
|
-
export function closeAllSseClients() {
|
|
258
|
-
|
|
261
|
+
export function closeAllSseClients(clientsToClose = sseClients) {
|
|
262
|
+
const list = Array.from(clientsToClose);
|
|
263
|
+
for (const res of list) {
|
|
259
264
|
try {
|
|
260
265
|
if (!res.writableEnded) {
|
|
261
266
|
if (typeof res.write === 'function' && res.headersSent) {
|
|
@@ -273,9 +278,16 @@ export function closeAllSseClients() {
|
|
|
273
278
|
// ignore close error
|
|
274
279
|
} finally {
|
|
275
280
|
sseClients.delete(res);
|
|
281
|
+
if (clientsToClose !== sseClients) {
|
|
282
|
+
clientsToClose.delete(res);
|
|
283
|
+
}
|
|
276
284
|
}
|
|
277
285
|
}
|
|
278
|
-
sseClients
|
|
286
|
+
if (clientsToClose !== sseClients) {
|
|
287
|
+
clientsToClose.clear();
|
|
288
|
+
} else {
|
|
289
|
+
sseClients.clear();
|
|
290
|
+
}
|
|
279
291
|
}
|
|
280
292
|
|
|
281
293
|
// ---------------------------------------------------------------------------
|
|
@@ -547,18 +559,9 @@ function writeText(res, status, text, headers = {}) {
|
|
|
547
559
|
|
|
548
560
|
/** Shared auth for push routes: same fence as the SSE stream. Returns true when accepted. */
|
|
549
561
|
export function authorizePushRequest(req, res, targetCtx) {
|
|
550
|
-
const
|
|
551
|
-
if (
|
|
552
|
-
|
|
553
|
-
if (rejection !== void 0) {
|
|
554
|
-
writeText(res, rejection, rejection === 401 ? 'Unauthorized' : 'Forbidden');
|
|
555
|
-
return false;
|
|
556
|
-
}
|
|
557
|
-
return true;
|
|
558
|
-
}
|
|
559
|
-
const remoteAddr = req.socket?.remoteAddress || req.connection?.remoteAddress;
|
|
560
|
-
if (!remoteAddr || !isLoopback(remoteAddr)) {
|
|
561
|
-
writeText(res, 403, 'Forbidden');
|
|
562
|
+
const rejection = getRequestRejection(req, targetCtx);
|
|
563
|
+
if (rejection !== void 0) {
|
|
564
|
+
writeText(res, rejection, rejection === 401 ? 'Unauthorized' : 'Forbidden');
|
|
562
565
|
return false;
|
|
563
566
|
}
|
|
564
567
|
return true;
|
|
@@ -649,6 +652,7 @@ export async function handlePushTest(req, res, targetCtx, getConfig) {
|
|
|
649
652
|
|
|
650
653
|
function mountWebServer(targetCtx, hostCtx, getConfig) {
|
|
651
654
|
if (!targetCtx?.webServer?.register) return () => {};
|
|
655
|
+
const instanceClients = new Set();
|
|
652
656
|
// Async route wrapper: an unexpected rejection becomes a 500 instead of an
|
|
653
657
|
// unhandled rejection; authorized handlers have already written their response.
|
|
654
658
|
const asyncRoute = (handler) => (req, res) => {
|
|
@@ -660,7 +664,7 @@ function mountWebServer(targetCtx, hostCtx, getConfig) {
|
|
|
660
664
|
{
|
|
661
665
|
kind: 'exact',
|
|
662
666
|
path: '/dsh-plugin-notify/events',
|
|
663
|
-
handler: (req, res) => handleSseConnection(req, res, targetCtx),
|
|
667
|
+
handler: (req, res) => handleSseConnection(req, res, targetCtx, instanceClients),
|
|
664
668
|
},
|
|
665
669
|
{
|
|
666
670
|
kind: 'exact',
|
|
@@ -716,7 +720,7 @@ function mountWebServer(targetCtx, hostCtx, getConfig) {
|
|
|
716
720
|
}
|
|
717
721
|
unregister = null;
|
|
718
722
|
}
|
|
719
|
-
closeAllSseClients();
|
|
723
|
+
closeAllSseClients(instanceClients);
|
|
720
724
|
};
|
|
721
725
|
|
|
722
726
|
if (typeof targetCtx.effect === 'function') {
|
|
@@ -755,7 +759,6 @@ export function apply(ctx, config = {}) {
|
|
|
755
759
|
try { unmountWebServer(); } catch { /* ignore */ }
|
|
756
760
|
unmountWebServer = null;
|
|
757
761
|
}
|
|
758
|
-
closeAllSseClients();
|
|
759
762
|
turnStarts.clear();
|
|
760
763
|
webServerMounted = false;
|
|
761
764
|
};
|
|
@@ -870,8 +873,8 @@ export function apply(ctx, config = {}) {
|
|
|
870
873
|
const excludeSessionPrefixes = cfg.excludeSessionPrefixes ?? [];
|
|
871
874
|
|
|
872
875
|
if (event.type === 'turn/start') {
|
|
873
|
-
if (turnStarts.size
|
|
874
|
-
cleanupTurnStarts();
|
|
876
|
+
if (turnStarts.size >= 100) {
|
|
877
|
+
cleanupTurnStarts(7200000, Date.now(), 100);
|
|
875
878
|
}
|
|
876
879
|
turnStarts.set(String(session.id), Date.now());
|
|
877
880
|
return;
|
|
@@ -923,28 +926,28 @@ function send(ctx, n, webhooks, timeoutMs, local, includeSession, includeDuratio
|
|
|
923
926
|
const signal = AbortSignal.timeout(timeoutMs);
|
|
924
927
|
const channels = Object.keys(webhooks);
|
|
925
928
|
(ctx?.logger?.debug ?? ctx?.logger?.info)?.(`[plugin-notify] ${n.kind} · ${n.title} · session ${n.sessionId} · channels ${channels.join(',') || 'none'} · local ${local}`);
|
|
926
|
-
const post = (url, body) => {
|
|
929
|
+
const post = (channel, url, body) => {
|
|
927
930
|
fetch(url, {
|
|
928
931
|
method: 'POST',
|
|
929
932
|
headers: { 'content-type': 'application/json' },
|
|
930
933
|
body: JSON.stringify(body),
|
|
931
934
|
signal,
|
|
932
935
|
}).catch(error => {
|
|
933
|
-
ctx?.logger?.warn?.(`[plugin-notify] webhook POST failed
|
|
936
|
+
ctx?.logger?.warn?.(`[plugin-notify] ${channel} webhook POST failed: ${String(error)}`);
|
|
934
937
|
});
|
|
935
938
|
};
|
|
936
939
|
if (webhooks.feishu)
|
|
937
|
-
post(webhooks.feishu, { msg_type: 'text', content: { text } });
|
|
940
|
+
post('feishu', webhooks.feishu, { msg_type: 'text', content: { text } });
|
|
938
941
|
if (webhooks.wecom)
|
|
939
|
-
post(webhooks.wecom, { msgtype: 'text', text: { content: text } });
|
|
942
|
+
post('wecom', webhooks.wecom, { msgtype: 'text', text: { content: text } });
|
|
940
943
|
if (webhooks.dingtalk)
|
|
941
|
-
post(webhooks.dingtalk, { msgtype: 'text', text: { content: text } });
|
|
944
|
+
post('dingtalk', webhooks.dingtalk, { msgtype: 'text', text: { content: text } });
|
|
942
945
|
if (webhooks.slack)
|
|
943
|
-
post(webhooks.slack, { text });
|
|
946
|
+
post('slack', webhooks.slack, { text });
|
|
944
947
|
if (webhooks.discord)
|
|
945
|
-
post(webhooks.discord, { content: text });
|
|
948
|
+
post('discord', webhooks.discord, { content: text });
|
|
946
949
|
if (webhooks.custom) {
|
|
947
|
-
post(webhooks.custom, {
|
|
950
|
+
post('custom', webhooks.custom, {
|
|
948
951
|
text,
|
|
949
952
|
kind: n.kind,
|
|
950
953
|
title: n.title,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-plugin-notify",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"description": "DSH plugin: audio chimes, cross-session toasts, desktop push, IM webhooks, and web push (PWA) for turn completion, errors, and approvals.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|