@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 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 isTrustedRequest(request, targetCtx) {
172
- if (!request || !request.headers) return false;
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
- return rejection === void 0;
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
- return Boolean(remoteAddr && isLoopback(remoteAddr));
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 conn = connectionOf(targetCtx);
209
- if (conn && typeof conn.requestRejection === 'function') {
210
- const rejection = conn.requestRejection(req);
211
- if (rejection !== void 0) {
212
- res.statusCode = rejection;
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
- } else {
222
- const remoteAddr = req.socket?.remoteAddress || req.connection?.remoteAddress;
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
- for (const res of Array.from(sseClients)) {
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.clear();
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 conn = connectionOf(targetCtx);
551
- if (conn && typeof conn.requestRejection === 'function') {
552
- const rejection = conn.requestRejection(req);
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 > 100) {
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 (${String(url).slice(0, 64)}…): ${String(error)}`);
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.1",
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",