@goodandready/dsh-plugin-notify 0.3.4 → 0.3.6
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 +7 -1
- package/README.md +9 -3
- package/README.ru.md +9 -3
- package/README.zh.md +9 -3
- package/lib/index.js +132 -39
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,7 +4,13 @@ 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.3.
|
|
7
|
+
## [0.3.6] - 2026-09-25
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
- **SSE Authentication Security Boundary (#21)**: Replaced client-controlled header checks (`Authorization.length > 5`, `sec-fetch-site`) with authentic DSH session verification via `connection.requestRejection(req)` (`Host`/`Origin` fence and browser session validation), with loopback-only fallback when connection service is absent.
|
|
11
|
+
- **SSE Clients & Turn Starts Lifecycle Leak (#40)**: Bound SSE clients, route registration, heartbeat timer, and `turnStarts` map to unified effect-owned lifecycle. On plugin dispose, active SSE connections are cleanly terminated with closing comment and ended, route is unregistered, and `turnStarts` map is cleared to ensure complete isolation on reload.
|
|
12
|
+
|
|
13
|
+
## [0.3.5] - 2026-09-24
|
|
8
14
|
|
|
9
15
|
### Fixed
|
|
10
16
|
- **Memory Leak in Turn Tracking (#32)**: Added TTL-based cleanup (30m max age) for stale `turnStarts` entries with automatic pruning on turn events.
|
package/README.md
CHANGED
|
@@ -193,11 +193,17 @@ Expected output:
|
|
|
193
193
|
✔ AbortSignal.timeout is attached to webhook POST
|
|
194
194
|
✔ excluded session prefixes suppress notifications
|
|
195
195
|
✔ Config schema validates sound, toast, and desktop notification fields with opt-in defaults
|
|
196
|
-
✔ SSE route
|
|
196
|
+
✔ SSE route delegates authentication to DSH connection service with loopback fallback (issue #21 fix)
|
|
197
|
+
✔ SSE route falls back to loopback-only check when connection service is absent (issue #21 fix)
|
|
197
198
|
✔ client does not register settings.section slot (issue #16 fix)
|
|
198
199
|
✔ deliveries and warnings are routed through ctx.logger without console calls (issue #22 fix)
|
|
199
|
-
|
|
200
|
-
|
|
200
|
+
✔ cleanupTurnStarts purges stale turnStarts entries older than TTL (issue #32 fix)
|
|
201
|
+
✔ sendSseHeartbeat writes ping comment to active clients and purges failed clients (issue #33 fix)
|
|
202
|
+
✔ summarizeTurn safely reverse iterates events and handles missing session.events (issue #34 fix)
|
|
203
|
+
✔ textOf handles plain strings, arrays of blocks, and strings in arrays (issue #35 fix)
|
|
204
|
+
✔ lifecycle: apply -> dispose closes SSE clients, resets turnStarts, and isolates re-apply (issue #40 fix)
|
|
205
|
+
ℹ tests 21
|
|
206
|
+
ℹ pass 21
|
|
201
207
|
ℹ fail 0
|
|
202
208
|
```
|
|
203
209
|
|
package/README.ru.md
CHANGED
|
@@ -182,11 +182,17 @@ npm test
|
|
|
182
182
|
✔ AbortSignal.timeout is attached to webhook POST
|
|
183
183
|
✔ excluded session prefixes suppress notifications
|
|
184
184
|
✔ Config schema validates sound, toast, and desktop notification fields with opt-in defaults
|
|
185
|
-
✔ SSE route
|
|
185
|
+
✔ SSE route delegates authentication to DSH connection service with loopback fallback (issue #21 fix)
|
|
186
|
+
✔ SSE route falls back to loopback-only check when connection service is absent (issue #21 fix)
|
|
186
187
|
✔ client does not register settings.section slot (issue #16 fix)
|
|
187
188
|
✔ deliveries and warnings are routed through ctx.logger without console calls (issue #22 fix)
|
|
188
|
-
|
|
189
|
-
|
|
189
|
+
✔ cleanupTurnStarts purges stale turnStarts entries older than TTL (issue #32 fix)
|
|
190
|
+
✔ sendSseHeartbeat writes ping comment to active clients and purges failed clients (issue #33 fix)
|
|
191
|
+
✔ summarizeTurn safely reverse iterates events and handles missing session.events (issue #34 fix)
|
|
192
|
+
✔ textOf handles plain strings, arrays of blocks, and strings in arrays (issue #35 fix)
|
|
193
|
+
✔ lifecycle: apply -> dispose closes SSE clients, resets turnStarts, and isolates re-apply (issue #40 fix)
|
|
194
|
+
ℹ tests 21
|
|
195
|
+
ℹ pass 21
|
|
190
196
|
ℹ fail 0
|
|
191
197
|
```
|
|
192
198
|
|
package/README.zh.md
CHANGED
|
@@ -181,11 +181,17 @@ npm test
|
|
|
181
181
|
✔ AbortSignal.timeout is attached to webhook POST
|
|
182
182
|
✔ excluded session prefixes suppress notifications
|
|
183
183
|
✔ Config schema validates sound, toast, and desktop notification fields with opt-in defaults
|
|
184
|
-
✔ SSE route
|
|
184
|
+
✔ SSE route delegates authentication to DSH connection service with loopback fallback (issue #21 fix)
|
|
185
|
+
✔ SSE route falls back to loopback-only check when connection service is absent (issue #21 fix)
|
|
185
186
|
✔ client does not register settings.section slot (issue #16 fix)
|
|
186
187
|
✔ deliveries and warnings are routed through ctx.logger without console calls (issue #22 fix)
|
|
187
|
-
|
|
188
|
-
|
|
188
|
+
✔ cleanupTurnStarts purges stale turnStarts entries older than TTL (issue #32 fix)
|
|
189
|
+
✔ sendSseHeartbeat writes ping comment to active clients and purges failed clients (issue #33 fix)
|
|
190
|
+
✔ summarizeTurn safely reverse iterates events and handles missing session.events (issue #34 fix)
|
|
191
|
+
✔ textOf handles plain strings, arrays of blocks, and strings in arrays (issue #35 fix)
|
|
192
|
+
✔ lifecycle: apply -> dispose closes SSE clients, resets turnStarts, and isolates re-apply (issue #40 fix)
|
|
193
|
+
ℹ tests 21
|
|
194
|
+
ℹ pass 21
|
|
189
195
|
ℹ fail 0
|
|
190
196
|
```
|
|
191
197
|
|
package/lib/index.js
CHANGED
|
@@ -6,8 +6,8 @@ export const name = '@goodandready/dsh-plugin-notify';
|
|
|
6
6
|
/** Settings namespace shared with the Web settings card. */
|
|
7
7
|
export const NS = '@goodandready/dsh-plugin-notify';
|
|
8
8
|
|
|
9
|
-
// Session firehose + credentials (webhook URL refs) + settings scope for the card + webServer for SSE events.
|
|
10
|
-
export const inject = ['sessions', 'credentials', 'settings', 'webServer'];
|
|
9
|
+
// Session firehose + credentials (webhook URL refs) + settings scope for the card + webServer for SSE events + connection for authentication.
|
|
10
|
+
export const inject = ['sessions', 'credentials', 'settings', 'webServer', 'connection'];
|
|
11
11
|
|
|
12
12
|
const webhookRef = (label) => Schema.string()
|
|
13
13
|
.role('credential-ref')
|
|
@@ -123,34 +123,27 @@ function isLoopback(address) {
|
|
|
123
123
|
return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1' || address === 'localhost';
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
function connectionOf(ctx) {
|
|
127
|
+
if (!ctx) return null;
|
|
128
|
+
return Reflect.get(ctx, 'connection') || null;
|
|
129
|
+
}
|
|
130
|
+
|
|
126
131
|
/**
|
|
127
|
-
*
|
|
128
|
-
*
|
|
132
|
+
* Origin and session verification for internal SSE events stream.
|
|
133
|
+
* Delegates to DSH connection service (Host/Origin fence + browser authentication session),
|
|
134
|
+
* with loopback-only fallback when connection service is absent (e.g. standalone tests).
|
|
129
135
|
*/
|
|
130
|
-
export function isTrustedRequest(request) {
|
|
136
|
+
export function isTrustedRequest(request, targetCtx) {
|
|
131
137
|
if (!request || !request.headers) return false;
|
|
132
138
|
|
|
133
|
-
const
|
|
134
|
-
if (
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
if (remoteAddr && isLoopback(remoteAddr)) return true;
|
|
138
|
-
|
|
139
|
-
const secFetchSite = request.headers['sec-fetch-site'];
|
|
140
|
-
if (secFetchSite === 'same-origin' || secFetchSite === 'same-site') {
|
|
141
|
-
return true;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
const origin = request.headers['origin'];
|
|
145
|
-
const host = request.headers['host'];
|
|
146
|
-
if (origin && host) {
|
|
147
|
-
try {
|
|
148
|
-
const originHost = new URL(origin).host;
|
|
149
|
-
if (originHost === host) return true;
|
|
150
|
-
} catch { /* invalid origin URL */ }
|
|
139
|
+
const conn = connectionOf(targetCtx);
|
|
140
|
+
if (conn && typeof conn.requestRejection === 'function') {
|
|
141
|
+
const rejection = conn.requestRejection(request);
|
|
142
|
+
return rejection === void 0;
|
|
151
143
|
}
|
|
152
144
|
|
|
153
|
-
|
|
145
|
+
const remoteAddr = request.socket?.remoteAddress || request.connection?.remoteAddress;
|
|
146
|
+
return Boolean(remoteAddr && isLoopback(remoteAddr));
|
|
154
147
|
}
|
|
155
148
|
|
|
156
149
|
export function broadcastSse(eventData) {
|
|
@@ -168,17 +161,42 @@ export function broadcastSse(eventData) {
|
|
|
168
161
|
return sent;
|
|
169
162
|
}
|
|
170
163
|
|
|
171
|
-
export function handleSseConnection(req, res) {
|
|
164
|
+
export function handleSseConnection(req, res, targetCtx) {
|
|
172
165
|
if (req.method !== 'GET') {
|
|
173
|
-
|
|
166
|
+
res.statusCode = 405;
|
|
167
|
+
if (typeof res.setHeader === 'function') res.setHeader('allow', 'GET');
|
|
168
|
+
if (typeof res.writeHead === 'function') res.writeHead(405, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
174
169
|
if (typeof res.end === 'function') res.end('Method Not Allowed');
|
|
175
170
|
return;
|
|
176
171
|
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
172
|
+
|
|
173
|
+
const conn = connectionOf(targetCtx);
|
|
174
|
+
if (conn && typeof conn.requestRejection === 'function') {
|
|
175
|
+
const rejection = conn.requestRejection(req);
|
|
176
|
+
if (rejection !== void 0) {
|
|
177
|
+
res.statusCode = rejection;
|
|
178
|
+
if (typeof res.writeHead === 'function') {
|
|
179
|
+
res.writeHead(rejection, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
180
|
+
}
|
|
181
|
+
if (typeof res.end === 'function') {
|
|
182
|
+
res.end(rejection === 401 ? 'Unauthorized' : 'Forbidden');
|
|
183
|
+
}
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
} else {
|
|
187
|
+
const remoteAddr = req.socket?.remoteAddress || req.connection?.remoteAddress;
|
|
188
|
+
if (!remoteAddr || !isLoopback(remoteAddr)) {
|
|
189
|
+
res.statusCode = 403;
|
|
190
|
+
if (typeof res.writeHead === 'function') {
|
|
191
|
+
res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
192
|
+
}
|
|
193
|
+
if (typeof res.end === 'function') {
|
|
194
|
+
res.end('Forbidden');
|
|
195
|
+
}
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
181
198
|
}
|
|
199
|
+
|
|
182
200
|
if (typeof res.writeHead === 'function') {
|
|
183
201
|
res.writeHead(200, {
|
|
184
202
|
'Content-Type': 'text/event-stream',
|
|
@@ -201,39 +219,114 @@ export function handleSseConnection(req, res) {
|
|
|
201
219
|
}
|
|
202
220
|
}
|
|
203
221
|
|
|
204
|
-
function
|
|
205
|
-
|
|
222
|
+
export function closeAllSseClients() {
|
|
223
|
+
for (const res of Array.from(sseClients)) {
|
|
224
|
+
try {
|
|
225
|
+
if (!res.writableEnded) {
|
|
226
|
+
if (typeof res.write === 'function' && res.headersSent) {
|
|
227
|
+
try {
|
|
228
|
+
res.write(': closing\n\n');
|
|
229
|
+
} catch { /* ignore write failure */ }
|
|
230
|
+
}
|
|
231
|
+
if (typeof res.end === 'function') {
|
|
232
|
+
res.end();
|
|
233
|
+
} else if (typeof res.destroy === 'function') {
|
|
234
|
+
res.destroy();
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
} catch {
|
|
238
|
+
// ignore close error
|
|
239
|
+
} finally {
|
|
240
|
+
sseClients.delete(res);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
sseClients.clear();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function mountWebServer(targetCtx, hostCtx) {
|
|
247
|
+
if (!targetCtx?.webServer?.register) return () => {};
|
|
206
248
|
const routeDef = {
|
|
207
249
|
kind: 'exact',
|
|
208
250
|
path: '/dsh-plugin-notify/events',
|
|
209
|
-
handler: (req, res) => handleSseConnection(req, res),
|
|
251
|
+
handler: (req, res) => handleSseConnection(req, res, targetCtx),
|
|
210
252
|
};
|
|
253
|
+
|
|
254
|
+
let timer = null;
|
|
211
255
|
const startHeartbeat = () => {
|
|
212
|
-
|
|
256
|
+
timer = setInterval(() => sendSseHeartbeat(), 25000);
|
|
213
257
|
timer?.unref?.();
|
|
214
|
-
return () =>
|
|
258
|
+
return () => {
|
|
259
|
+
if (timer) {
|
|
260
|
+
clearInterval(timer);
|
|
261
|
+
timer = null;
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
let unregister = null;
|
|
267
|
+
let disposed = false;
|
|
268
|
+
const cleanup = () => {
|
|
269
|
+
if (disposed) return;
|
|
270
|
+
disposed = true;
|
|
271
|
+
if (timer) {
|
|
272
|
+
clearInterval(timer);
|
|
273
|
+
timer = null;
|
|
274
|
+
}
|
|
275
|
+
if (typeof unregister === 'function') {
|
|
276
|
+
try { unregister(); } catch { /* ignore */ }
|
|
277
|
+
unregister = null;
|
|
278
|
+
}
|
|
279
|
+
closeAllSseClients();
|
|
215
280
|
};
|
|
281
|
+
|
|
216
282
|
if (typeof targetCtx.effect === 'function') {
|
|
217
|
-
targetCtx.effect(() =>
|
|
218
|
-
|
|
283
|
+
targetCtx.effect(() => {
|
|
284
|
+
unregister = targetCtx.webServer.register(routeDef);
|
|
285
|
+
return () => cleanup();
|
|
286
|
+
}, 'dsh-plugin-notify: sse events');
|
|
287
|
+
targetCtx.effect(() => {
|
|
288
|
+
return startHeartbeat();
|
|
289
|
+
}, 'dsh-plugin-notify: sse heartbeat');
|
|
219
290
|
} else {
|
|
220
|
-
targetCtx.webServer.register(routeDef);
|
|
291
|
+
unregister = targetCtx.webServer.register(routeDef);
|
|
221
292
|
startHeartbeat();
|
|
222
293
|
}
|
|
294
|
+
|
|
295
|
+
return cleanup;
|
|
223
296
|
}
|
|
224
297
|
|
|
225
298
|
export function apply(ctx, config = {}) {
|
|
226
299
|
let getConfig = () => config ?? {};
|
|
227
300
|
|
|
228
301
|
let webServerMounted = false;
|
|
302
|
+
let unmountWebServer = null;
|
|
303
|
+
|
|
229
304
|
const tryMountWebServer = (target) => {
|
|
230
305
|
if (webServerMounted) return;
|
|
231
306
|
if (target?.webServer?.register) {
|
|
232
307
|
webServerMounted = true;
|
|
233
|
-
|
|
308
|
+
const effectiveTarget = target?.connection ? target : (ctx?.connection ? ctx : target);
|
|
309
|
+
unmountWebServer = mountWebServer(effectiveTarget, ctx);
|
|
234
310
|
}
|
|
235
311
|
};
|
|
236
312
|
|
|
313
|
+
const disposePlugin = () => {
|
|
314
|
+
if (typeof unmountWebServer === 'function') {
|
|
315
|
+
try { unmountWebServer(); } catch { /* ignore */ }
|
|
316
|
+
unmountWebServer = null;
|
|
317
|
+
}
|
|
318
|
+
closeAllSseClients();
|
|
319
|
+
turnStarts.clear();
|
|
320
|
+
webServerMounted = false;
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
if (typeof ctx.effect === 'function') {
|
|
324
|
+
ctx.effect(() => () => disposePlugin(), 'dsh-plugin-notify: lifecycle cleanup');
|
|
325
|
+
}
|
|
326
|
+
if (typeof ctx.on === 'function') {
|
|
327
|
+
ctx.on('dispose', disposePlugin);
|
|
328
|
+
}
|
|
329
|
+
|
|
237
330
|
if (typeof ctx.inject === 'function') {
|
|
238
331
|
ctx.inject(['settings'], (sctx) => {
|
|
239
332
|
const scope = sctx.settings.register(NS, Config, { base: config ?? {} });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-plugin-notify",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.6",
|
|
4
4
|
"description": "DSH plugin: audio chimes, cross-session toasts, desktop push, and IM webhooks for turn completion, errors, and approvals.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|