@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 +15 -0
- package/lib/client.js +1 -7
- package/lib/index.js +64 -60
- package/package.json +1 -1
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
|
|
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
|
|
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
|
// ---------------------------------------------------------------------------
|
|
@@ -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
|
|
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');
|
|
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
|
|
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
|
|
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.
|
|
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",
|