@goodandready/dsh-plugin-notify 0.4.1 → 0.4.3

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,21 @@ 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.3] - 2026-10-15
8
+
9
+ ### Fixed
10
+ - **Host event cleanup** (#59): Prune dead listener on non-existent host event `settings/updated` in `lib/index.js` (active settings updates remain reliably monitored via `settings/document-updated` and `loader/volatile-update`).
11
+ - **Client slot registration** (#59): Remove dead registration of non-existent client seat `settings.plugin.item` in `lib/client.js` (official plugin list page seat `plugins.item` and row config seat `plugins.row.config` handle settings cards).
12
+ - **Web push state path containment** (#59): Constrain custom `webPush.stateFile` paths by resolving relative paths against the DSH home directory instead of returning raw unconstrained strings.
13
+
14
+ ## [0.4.2] - 2026-10-10
15
+
16
+ ### Fixed
17
+ - **Request trust enforcement on HTTP/SSE routes** (#46): Route SSE and Push subscriptions through `isTrustedRequest` / `getRequestRejection`, ensuring origin and session checks with loopback fallback.
18
+ - **Webhook error logging diagnostic** (#49): Log human-readable channel name instead of truncated webhook URL on POST failure, preventing credential token leakage.
19
+ - **Multi-instance SSE client isolation** (#50): Isolate SSE client sets per plugin lifecycle context on dispose, avoiding cross-instance connection drops on reload.
20
+ - **Deterministic memory bound on turnStarts map** (#51): Enforce deterministic FIFO eviction bound (`maxSize = 100`) on `turnStarts` map under session bursts.
21
+
7
22
  ## [0.4.1] - 2026-10-10
8
23
 
9
24
  ### Fixed
package/lib/client.js CHANGED
@@ -895,19 +895,13 @@ window.__ModuleLoader__.load({
895
895
  (props) => React.createElement(NotifyCard, { ...props, ctx }),
896
896
  )
897
897
  })
898
- // Row seat and the legacy seat kept as fallbacks for older cores.
898
+ // Row seat fallback for older cores.
899
899
  ctx.slots.inject('plugins.row.config', () => {
900
900
  ctx.slots.register(
901
901
  { name: 'plugins.row.config', key: ROW_CONFIG_KEY, locale: NS, order: 40, inject: () => ({ ctx }) },
902
902
  (props) => React.createElement(NotifyCard, { ...props, ctx }),
903
903
  )
904
904
  })
905
- ctx.slots.inject('settings.plugin.item', () => {
906
- ctx.slots.register(
907
- { name: 'settings.plugin.item', key: ROW_ID, locale: NS, order: 40, inject: () => ({ ctx }) },
908
- (props) => React.createElement(NotifyCard, { ...props, ctx }),
909
- )
910
- })
911
905
  } catch (e) {
912
906
  console.warn('[dsh-plugin-notify] settings seats failed', e)
913
907
  }
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
  // ---------------------------------------------------------------------------
@@ -332,10 +344,12 @@ export function generateVapidKeys() {
332
344
  }
333
345
 
334
346
  export function resolveStatePath(webPushConfig = {}) {
335
- if (webPushConfig && typeof webPushConfig.stateFile === 'string' && webPushConfig.stateFile.trim())
336
- return webPushConfig.stateFile.trim();
337
347
  const home = (process.env.DSH_HOME && process.env.DSH_HOME.trim())
338
348
  || path.join(os.homedir(), '.dsh');
349
+ if (webPushConfig && typeof webPushConfig.stateFile === 'string' && webPushConfig.stateFile.trim()) {
350
+ const custom = webPushConfig.stateFile.trim();
351
+ return path.isAbsolute(custom) ? path.normalize(custom) : path.resolve(home, custom);
352
+ }
339
353
  return path.join(home, 'plugin-notify-state.json');
340
354
  }
341
355
 
@@ -547,18 +561,9 @@ function writeText(res, status, text, headers = {}) {
547
561
 
548
562
  /** Shared auth for push routes: same fence as the SSE stream. Returns true when accepted. */
549
563
  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');
564
+ const rejection = getRequestRejection(req, targetCtx);
565
+ if (rejection !== void 0) {
566
+ writeText(res, rejection, rejection === 401 ? 'Unauthorized' : 'Forbidden');
562
567
  return false;
563
568
  }
564
569
  return true;
@@ -649,6 +654,7 @@ export async function handlePushTest(req, res, targetCtx, getConfig) {
649
654
 
650
655
  function mountWebServer(targetCtx, hostCtx, getConfig) {
651
656
  if (!targetCtx?.webServer?.register) return () => {};
657
+ const instanceClients = new Set();
652
658
  // Async route wrapper: an unexpected rejection becomes a 500 instead of an
653
659
  // unhandled rejection; authorized handlers have already written their response.
654
660
  const asyncRoute = (handler) => (req, res) => {
@@ -660,7 +666,7 @@ function mountWebServer(targetCtx, hostCtx, getConfig) {
660
666
  {
661
667
  kind: 'exact',
662
668
  path: '/dsh-plugin-notify/events',
663
- handler: (req, res) => handleSseConnection(req, res, targetCtx),
669
+ handler: (req, res) => handleSseConnection(req, res, targetCtx, instanceClients),
664
670
  },
665
671
  {
666
672
  kind: 'exact',
@@ -716,7 +722,7 @@ function mountWebServer(targetCtx, hostCtx, getConfig) {
716
722
  }
717
723
  unregister = null;
718
724
  }
719
- closeAllSseClients();
725
+ closeAllSseClients(instanceClients);
720
726
  };
721
727
 
722
728
  if (typeof targetCtx.effect === 'function') {
@@ -755,7 +761,6 @@ export function apply(ctx, config = {}) {
755
761
  try { unmountWebServer(); } catch { /* ignore */ }
756
762
  unmountWebServer = null;
757
763
  }
758
- closeAllSseClients();
759
764
  turnStarts.clear();
760
765
  webServerMounted = false;
761
766
  };
@@ -790,7 +795,6 @@ export function apply(ctx, config = {}) {
790
795
  if (typeof ctx.on === 'function') {
791
796
  ctx.on('settings/document-updated', refreshSettings);
792
797
  ctx.on('loader/volatile-update', refreshSettings);
793
- ctx.on('settings/updated', refreshSettings);
794
798
  }
795
799
  if (typeof sctx.settings?.on === 'function') {
796
800
  try { sctx.settings.on('update', refreshSettings); } catch { /* ignore */ }
@@ -870,8 +874,8 @@ export function apply(ctx, config = {}) {
870
874
  const excludeSessionPrefixes = cfg.excludeSessionPrefixes ?? [];
871
875
 
872
876
  if (event.type === 'turn/start') {
873
- if (turnStarts.size > 100) {
874
- cleanupTurnStarts();
877
+ if (turnStarts.size >= 100) {
878
+ cleanupTurnStarts(7200000, Date.now(), 100);
875
879
  }
876
880
  turnStarts.set(String(session.id), Date.now());
877
881
  return;
@@ -923,28 +927,28 @@ function send(ctx, n, webhooks, timeoutMs, local, includeSession, includeDuratio
923
927
  const signal = AbortSignal.timeout(timeoutMs);
924
928
  const channels = Object.keys(webhooks);
925
929
  (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) => {
930
+ const post = (channel, url, body) => {
927
931
  fetch(url, {
928
932
  method: 'POST',
929
933
  headers: { 'content-type': 'application/json' },
930
934
  body: JSON.stringify(body),
931
935
  signal,
932
936
  }).catch(error => {
933
- ctx?.logger?.warn?.(`[plugin-notify] webhook POST failed (${String(url).slice(0, 64)}…): ${String(error)}`);
937
+ ctx?.logger?.warn?.(`[plugin-notify] ${channel} webhook POST failed: ${String(error)}`);
934
938
  });
935
939
  };
936
940
  if (webhooks.feishu)
937
- post(webhooks.feishu, { msg_type: 'text', content: { text } });
941
+ post('feishu', webhooks.feishu, { msg_type: 'text', content: { text } });
938
942
  if (webhooks.wecom)
939
- post(webhooks.wecom, { msgtype: 'text', text: { content: text } });
943
+ post('wecom', webhooks.wecom, { msgtype: 'text', text: { content: text } });
940
944
  if (webhooks.dingtalk)
941
- post(webhooks.dingtalk, { msgtype: 'text', text: { content: text } });
945
+ post('dingtalk', webhooks.dingtalk, { msgtype: 'text', text: { content: text } });
942
946
  if (webhooks.slack)
943
- post(webhooks.slack, { text });
947
+ post('slack', webhooks.slack, { text });
944
948
  if (webhooks.discord)
945
- post(webhooks.discord, { content: text });
949
+ post('discord', webhooks.discord, { content: text });
946
950
  if (webhooks.custom) {
947
- post(webhooks.custom, {
951
+ post('custom', webhooks.custom, {
948
952
  text,
949
953
  kind: n.kind,
950
954
  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.3",
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",