@goodandready/dsh-plugin-notify 0.4.0 → 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,23 @@ 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
+
15
+ ## [0.4.1] - 2026-10-10
16
+
17
+ ### Fixed
18
+ - **Settings registration namespace & Desktop crash protection** (#55): Use canonical `ENTRY_ID = 'plugin-notify'` instead of scoped package name in `settings.register`; wrap call with `try / catch` logging warning instead of throwing `TypeError` in DSH Desktop.
19
+ - **Settings form slot binding** (#54): Bind UI `settings.plugin.item` slot `key` to `ROW_ID` (`'plugin-notify'`), and prioritize `ROW_ID` in `configForms` candidates over legacy `NS`.
20
+ - **Dynamic settings observation** (#48): Make describe settings fallback reactive: observe `loader/volatile-update`, `settings/document-updated`, and `settings/updated` to dynamically refresh settings when edited in UI or external config.
21
+ - **Config unboxing guarantee** (#47): Guarantee `getConfig()` always unwraps volatile boxes via `unwrapConfig(config ?? {})`, preventing `TypeError` during `session/event` event unboxing.
22
+ - **DSH 0.2.0-rc.1 clean activation & volatile schema** (#42): Guarantee clean activation on DSH 0.2.0-rc.1 when `settings.register` is absent; confirm all user-facing fields in `Config` have `.volatile()` markers.
23
+
7
24
  ## [0.4.0] - 2026-10-05
8
25
 
9
26
  ### Added
package/lib/client.js CHANGED
@@ -675,11 +675,10 @@ window.__ModuleLoader__.load({
675
675
  }
676
676
  }
677
677
 
678
- // dsh 0.1 keys the settings scope by package name; 0.2 keys it by the
679
- // composition row id. get() always returns a controller, so hold BOTH and
680
- // use whichever reports ready (the other stays permanently unavailable).
681
- const [scopeNS, scopeRow] = React.useMemo(
682
- () => (ctx && ctx.configForms ? [ctx.configForms.get(NS), ctx.configForms.get(ROW_ID)] : [undefined, undefined]),
678
+ // In DSH 0.2.x configForms is keyed strictly by entry id (ROW_ID = 'plugin-notify').
679
+ // Hold both candidates and prefer ROW_ID whenever it is ready or NS is unavailable.
680
+ const [scopeRow, scopeNS] = React.useMemo(
681
+ () => (ctx && ctx.configForms ? [ctx.configForms.get(ROW_ID), ctx.configForms.get(NS)] : [undefined, undefined]),
683
682
  [ctx],
684
683
  )
685
684
  const useSnap = (scope) => React.useSyncExternalStore(
@@ -687,11 +686,11 @@ window.__ModuleLoader__.load({
687
686
  React.useCallback(() => (scope ? scope.getSnapshot() : { status: 'loading' }), [scope]),
688
687
  React.useCallback(() => ({ status: 'loading' }), []),
689
688
  )
690
- const snapNS = useSnap(scopeNS)
691
689
  const snapRow = useSnap(scopeRow)
692
- const rowWins = scopeRow && snapNS.status !== 'ready' && snapRow.status === 'ready'
693
- const scope = rowWins ? scopeRow : scopeNS
694
- const snapshot = rowWins ? snapRow : snapNS
690
+ const snapNS = useSnap(scopeNS)
691
+ const useRow = scopeRow && (snapRow.status === 'ready' || snapNS.status !== 'ready')
692
+ const scope = useRow ? scopeRow : (scopeNS || scopeRow)
693
+ const snapshot = useRow ? snapRow : (scopeNS ? snapNS : snapRow)
695
694
 
696
695
  React.useEffect(() => { ensureStyles() }, [])
697
696
 
@@ -905,7 +904,7 @@ window.__ModuleLoader__.load({
905
904
  })
906
905
  ctx.slots.inject('settings.plugin.item', () => {
907
906
  ctx.slots.register(
908
- { name: 'settings.plugin.item', key: NS, locale: NS, order: 40, inject: () => ({ ctx }) },
907
+ { name: 'settings.plugin.item', key: ROW_ID, locale: NS, order: 40, inject: () => ({ ctx }) },
909
908
  (props) => React.createElement(NotifyCard, { ...props, ctx }),
910
909
  )
911
910
  })
@@ -916,7 +915,7 @@ window.__ModuleLoader__.load({
916
915
  let currentSettings = {}
917
916
  if (ctx.configForms) {
918
917
  try {
919
- const candidates = [ctx.configForms.get(NS), ctx.configForms.get(ROW_ID)]
918
+ const candidates = [ctx.configForms.get(ROW_ID), ctx.configForms.get(NS)]
920
919
  const ready = candidates.find((s) => s && s.getSnapshot && s.getSnapshot().status === 'ready')
921
920
  const scope = ready || candidates[0]
922
921
  if (scope && typeof scope.subscribe === 'function') {
package/lib/index.js CHANGED
@@ -10,6 +10,8 @@ import { credentialRef } from '@deepseek-ai/dsh-credentials';
10
10
  export const name = '@goodandready/dsh-plugin-notify';
11
11
  /** Settings namespace shared with the Web settings card. */
12
12
  export const NS = '@goodandready/dsh-plugin-notify';
13
+ /** Canonical entry id matching cordis.patch.yml */
14
+ export const ENTRY_ID = 'plugin-notify';
13
15
 
14
16
  // Session firehose + credentials (webhook URL refs) + settings scope for the card + webServer for SSE events + connection for authentication.
15
17
  export const inject = ['sessions', 'credentials', 'settings', 'webServer', 'connection'];
@@ -92,7 +94,7 @@ const warnedLegacyUrls = new Set();
92
94
  * Purge turnStarts entries older than maxAgeMs (default 2 hours) to prevent
93
95
  * memory leaks from abandoned or cancelled sessions.
94
96
  */
95
- export function cleanupTurnStarts(maxAgeMs = 7200000, now = Date.now()) {
97
+ export function cleanupTurnStarts(maxAgeMs = 7200000, now = Date.now(), maxSize = 100) {
96
98
  let deleted = 0;
97
99
  for (const [sid, start] of turnStarts.entries()) {
98
100
  if (now - start > maxAgeMs) {
@@ -100,6 +102,13 @@ export function cleanupTurnStarts(maxAgeMs = 7200000, now = Date.now()) {
100
102
  deleted++;
101
103
  }
102
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
+ }
103
112
  return deleted;
104
113
  }
105
114
 
@@ -166,17 +175,23 @@ function connectionOf(ctx) {
166
175
  * Delegates to DSH connection service (Host/Origin fence + browser authentication session),
167
176
  * with loopback-only fallback when connection service is absent (e.g. standalone tests).
168
177
  */
169
- export function isTrustedRequest(request, targetCtx) {
170
- if (!request || !request.headers) return false;
178
+ export function getRequestRejection(request, targetCtx) {
179
+ if (!request || !request.headers) return 403;
171
180
 
172
181
  const conn = connectionOf(targetCtx);
173
182
  if (conn && typeof conn.requestRejection === 'function') {
174
183
  const rejection = conn.requestRejection(request);
175
- return rejection === void 0;
184
+ if (rejection !== void 0) return rejection;
185
+ return void 0;
176
186
  }
177
187
 
178
188
  const remoteAddr = request.socket?.remoteAddress || request.connection?.remoteAddress;
179
- 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;
180
195
  }
181
196
 
182
197
  export function broadcastSse(eventData) {
@@ -194,7 +209,7 @@ export function broadcastSse(eventData) {
194
209
  return sent;
195
210
  }
196
211
 
197
- export function handleSseConnection(req, res, targetCtx) {
212
+ export function handleSseConnection(req, res, targetCtx, instanceClients = null) {
198
213
  if (req.method !== 'GET') {
199
214
  res.statusCode = 405;
200
215
  if (typeof res.setHeader === 'function') res.setHeader('allow', 'GET');
@@ -203,31 +218,16 @@ export function handleSseConnection(req, res, targetCtx) {
203
218
  return;
204
219
  }
205
220
 
206
- const conn = connectionOf(targetCtx);
207
- if (conn && typeof conn.requestRejection === 'function') {
208
- const rejection = conn.requestRejection(req);
209
- if (rejection !== void 0) {
210
- res.statusCode = rejection;
211
- if (typeof res.writeHead === 'function') {
212
- res.writeHead(rejection, { 'Content-Type': 'text/plain; charset=utf-8' });
213
- }
214
- if (typeof res.end === 'function') {
215
- res.end(rejection === 401 ? 'Unauthorized' : 'Forbidden');
216
- }
217
- 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' });
218
226
  }
219
- } else {
220
- const remoteAddr = req.socket?.remoteAddress || req.connection?.remoteAddress;
221
- if (!remoteAddr || !isLoopback(remoteAddr)) {
222
- res.statusCode = 403;
223
- if (typeof res.writeHead === 'function') {
224
- res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
225
- }
226
- if (typeof res.end === 'function') {
227
- res.end('Forbidden');
228
- }
229
- return;
227
+ if (typeof res.end === 'function') {
228
+ res.end(rejection === 401 ? 'Unauthorized' : 'Forbidden');
230
229
  }
230
+ return;
231
231
  }
232
232
 
233
233
  if (typeof res.writeHead === 'function') {
@@ -241,9 +241,15 @@ export function handleSseConnection(req, res, targetCtx) {
241
241
  res.write(': connected\n\n');
242
242
  }
243
243
  sseClients.add(res);
244
+ if (instanceClients) {
245
+ instanceClients.add(res);
246
+ }
244
247
 
245
248
  const onEnd = () => {
246
249
  sseClients.delete(res);
250
+ if (instanceClients) {
251
+ instanceClients.delete(res);
252
+ }
247
253
  };
248
254
  if (typeof req.on === 'function') req.on('close', onEnd);
249
255
  if (typeof res.on === 'function') {
@@ -252,8 +258,9 @@ export function handleSseConnection(req, res, targetCtx) {
252
258
  }
253
259
  }
254
260
 
255
- export function closeAllSseClients() {
256
- 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) {
257
264
  try {
258
265
  if (!res.writableEnded) {
259
266
  if (typeof res.write === 'function' && res.headersSent) {
@@ -271,9 +278,16 @@ export function closeAllSseClients() {
271
278
  // ignore close error
272
279
  } finally {
273
280
  sseClients.delete(res);
281
+ if (clientsToClose !== sseClients) {
282
+ clientsToClose.delete(res);
283
+ }
274
284
  }
275
285
  }
276
- sseClients.clear();
286
+ if (clientsToClose !== sseClients) {
287
+ clientsToClose.clear();
288
+ } else {
289
+ sseClients.clear();
290
+ }
277
291
  }
278
292
 
279
293
  // ---------------------------------------------------------------------------
@@ -545,18 +559,9 @@ function writeText(res, status, text, headers = {}) {
545
559
 
546
560
  /** Shared auth for push routes: same fence as the SSE stream. Returns true when accepted. */
547
561
  export function authorizePushRequest(req, res, targetCtx) {
548
- const conn = connectionOf(targetCtx);
549
- if (conn && typeof conn.requestRejection === 'function') {
550
- const rejection = conn.requestRejection(req);
551
- if (rejection !== void 0) {
552
- writeText(res, rejection, rejection === 401 ? 'Unauthorized' : 'Forbidden');
553
- return false;
554
- }
555
- return true;
556
- }
557
- const remoteAddr = req.socket?.remoteAddress || req.connection?.remoteAddress;
558
- if (!remoteAddr || !isLoopback(remoteAddr)) {
559
- writeText(res, 403, 'Forbidden');
562
+ const rejection = getRequestRejection(req, targetCtx);
563
+ if (rejection !== void 0) {
564
+ writeText(res, rejection, rejection === 401 ? 'Unauthorized' : 'Forbidden');
560
565
  return false;
561
566
  }
562
567
  return true;
@@ -647,6 +652,7 @@ export async function handlePushTest(req, res, targetCtx, getConfig) {
647
652
 
648
653
  function mountWebServer(targetCtx, hostCtx, getConfig) {
649
654
  if (!targetCtx?.webServer?.register) return () => {};
655
+ const instanceClients = new Set();
650
656
  // Async route wrapper: an unexpected rejection becomes a 500 instead of an
651
657
  // unhandled rejection; authorized handlers have already written their response.
652
658
  const asyncRoute = (handler) => (req, res) => {
@@ -658,7 +664,7 @@ function mountWebServer(targetCtx, hostCtx, getConfig) {
658
664
  {
659
665
  kind: 'exact',
660
666
  path: '/dsh-plugin-notify/events',
661
- handler: (req, res) => handleSseConnection(req, res, targetCtx),
667
+ handler: (req, res) => handleSseConnection(req, res, targetCtx, instanceClients),
662
668
  },
663
669
  {
664
670
  kind: 'exact',
@@ -714,7 +720,7 @@ function mountWebServer(targetCtx, hostCtx, getConfig) {
714
720
  }
715
721
  unregister = null;
716
722
  }
717
- closeAllSseClients();
723
+ closeAllSseClients(instanceClients);
718
724
  };
719
725
 
720
726
  if (typeof targetCtx.effect === 'function') {
@@ -734,7 +740,7 @@ function mountWebServer(targetCtx, hostCtx, getConfig) {
734
740
  }
735
741
 
736
742
  export function apply(ctx, config = {}) {
737
- let getConfig = () => config ?? {};
743
+ let getConfig = () => unwrapConfig(config ?? {});
738
744
 
739
745
  let webServerMounted = false;
740
746
  let unmountWebServer = null;
@@ -753,7 +759,6 @@ export function apply(ctx, config = {}) {
753
759
  try { unmountWebServer(); } catch { /* ignore */ }
754
760
  unmountWebServer = null;
755
761
  }
756
- closeAllSseClients();
757
762
  turnStarts.clear();
758
763
  webServerMounted = false;
759
764
  };
@@ -768,22 +773,48 @@ export function apply(ctx, config = {}) {
768
773
  if (typeof ctx.inject === 'function') {
769
774
  ctx.inject(['settings'], (sctx) => {
770
775
  if (typeof sctx.settings?.register === 'function') {
771
- const scope = sctx.settings.register(NS, Config, { base: config ?? {} });
772
- getConfig = () => scope.get() ?? config ?? {};
773
- return;
776
+ try {
777
+ const scope = sctx.settings.register(ENTRY_ID, Config, { base: config ?? {} });
778
+ getConfig = () => (scope ? unwrapConfig(scope.get() ?? config ?? {}) : unwrapConfig(config ?? {}));
779
+ return;
780
+ } catch (err) {
781
+ ctx?.logger?.warn?.(`[plugin-notify] settings.register('${ENTRY_ID}') failed, falling back to describe: ${String(err)}`);
782
+ }
783
+ }
784
+ let currentRow = (() => { try { return sctx.settings?.describe?.().find((r) => r && (r.ns === ENTRY_ID || r.ns === NS)); } catch { return null; } })();
785
+ const refreshSettings = () => {
786
+ try {
787
+ const next = sctx.settings?.describe?.().find((r) => r && (r.ns === ENTRY_ID || r.ns === NS));
788
+ if (next) currentRow = next;
789
+ } catch {
790
+ // best effort
791
+ }
792
+ };
793
+ if (typeof ctx.on === 'function') {
794
+ ctx.on('settings/document-updated', refreshSettings);
795
+ ctx.on('loader/volatile-update', refreshSettings);
796
+ ctx.on('settings/updated', refreshSettings);
797
+ }
798
+ if (typeof sctx.settings?.on === 'function') {
799
+ try { sctx.settings.on('update', refreshSettings); } catch { /* ignore */ }
774
800
  }
775
- const row = (() => { try { return sctx.settings?.describe?.().find((r) => r && r.ns === NS); } catch { return null; } })();
776
801
  const scope = {
777
- get: () => row?.value ?? config ?? {},
802
+ get: () => {
803
+ refreshSettings();
804
+ return currentRow?.value ?? config ?? {};
805
+ },
778
806
  replace: async (next) => {
779
807
  try {
780
- return await sctx.settings?.replace?.(NS, next, row?.revision);
808
+ const targetNs = sctx.settings?.describe?.().some((r) => r && r.ns === ENTRY_ID) ? ENTRY_ID : NS;
809
+ const res = await sctx.settings?.replace?.(targetNs, next, currentRow?.revision);
810
+ refreshSettings();
811
+ return res;
781
812
  } catch (err) {
782
813
  ctx?.logger?.warn?.(`[plugin-notify] settings replace failed: ${String(err)}`);
783
814
  }
784
815
  },
785
816
  };
786
- getConfig = () => scope.get() ?? config ?? {};
817
+ getConfig = () => (scope ? unwrapConfig(scope.get() ?? config ?? {}) : unwrapConfig(config ?? {}));
787
818
  });
788
819
  ctx.inject(['webServer'], (wctx) => tryMountWebServer(wctx))
789
820
  }
@@ -842,8 +873,8 @@ export function apply(ctx, config = {}) {
842
873
  const excludeSessionPrefixes = cfg.excludeSessionPrefixes ?? [];
843
874
 
844
875
  if (event.type === 'turn/start') {
845
- if (turnStarts.size > 100) {
846
- cleanupTurnStarts();
876
+ if (turnStarts.size >= 100) {
877
+ cleanupTurnStarts(7200000, Date.now(), 100);
847
878
  }
848
879
  turnStarts.set(String(session.id), Date.now());
849
880
  return;
@@ -895,28 +926,28 @@ function send(ctx, n, webhooks, timeoutMs, local, includeSession, includeDuratio
895
926
  const signal = AbortSignal.timeout(timeoutMs);
896
927
  const channels = Object.keys(webhooks);
897
928
  (ctx?.logger?.debug ?? ctx?.logger?.info)?.(`[plugin-notify] ${n.kind} · ${n.title} · session ${n.sessionId} · channels ${channels.join(',') || 'none'} · local ${local}`);
898
- const post = (url, body) => {
929
+ const post = (channel, url, body) => {
899
930
  fetch(url, {
900
931
  method: 'POST',
901
932
  headers: { 'content-type': 'application/json' },
902
933
  body: JSON.stringify(body),
903
934
  signal,
904
935
  }).catch(error => {
905
- 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)}`);
906
937
  });
907
938
  };
908
939
  if (webhooks.feishu)
909
- post(webhooks.feishu, { msg_type: 'text', content: { text } });
940
+ post('feishu', webhooks.feishu, { msg_type: 'text', content: { text } });
910
941
  if (webhooks.wecom)
911
- post(webhooks.wecom, { msgtype: 'text', text: { content: text } });
942
+ post('wecom', webhooks.wecom, { msgtype: 'text', text: { content: text } });
912
943
  if (webhooks.dingtalk)
913
- post(webhooks.dingtalk, { msgtype: 'text', text: { content: text } });
944
+ post('dingtalk', webhooks.dingtalk, { msgtype: 'text', text: { content: text } });
914
945
  if (webhooks.slack)
915
- post(webhooks.slack, { text });
946
+ post('slack', webhooks.slack, { text });
916
947
  if (webhooks.discord)
917
- post(webhooks.discord, { content: text });
948
+ post('discord', webhooks.discord, { content: text });
918
949
  if (webhooks.custom) {
919
- post(webhooks.custom, {
950
+ post('custom', webhooks.custom, {
920
951
  text,
921
952
  kind: n.kind,
922
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.0",
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",