@goodandready/dsh-plugin-notify 0.3.5 → 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 CHANGED
@@ -4,6 +4,12 @@ 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.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
+
7
13
  ## [0.3.5] - 2026-09-24
8
14
 
9
15
  ### Fixed
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 registers on webServer, rejects untrusted requests, and handles trusted stream
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
- ℹ tests 15
200
- ℹ pass 15
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 registers on webServer, rejects untrusted requests, and handles trusted stream
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
- ℹ tests 15
189
- ℹ pass 15
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 registers on webServer, rejects untrusted requests, and handles trusted stream
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
- ℹ tests 15
188
- ℹ pass 15
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
- * Fail-closed origin check for internal SSE events stream.
128
- * Accepts same-origin sec-fetch-site, matching origin and host, loopback remote address, or DSH auth.
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 authHeader = request.headers['authorization'] || request.headers['x-dsh-auth'];
134
- if (authHeader && authHeader.length > 5) return true;
135
-
136
- const remoteAddr = request.socket?.remoteAddress || request.connection?.remoteAddress;
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
- return false;
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
- if (typeof res.writeHead === 'function') res.writeHead(405, { 'Content-Type': 'text/plain' });
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
- if (!isTrustedRequest(req)) {
178
- if (typeof res.writeHead === 'function') res.writeHead(403, { 'Content-Type': 'text/plain' });
179
- if (typeof res.end === 'function') res.end('Forbidden');
180
- return;
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 mountWebServer(targetCtx) {
205
- if (!targetCtx?.webServer?.register) return;
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
- const timer = setInterval(() => sendSseHeartbeat(), 25000);
256
+ timer = setInterval(() => sendSseHeartbeat(), 25000);
213
257
  timer?.unref?.();
214
- return () => clearInterval(timer);
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(() => targetCtx.webServer.register(routeDef), 'dsh-plugin-notify: sse events');
218
- targetCtx.effect(startHeartbeat, 'dsh-plugin-notify: sse heartbeat');
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
- mountWebServer(target);
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.5",
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",