@goodandready/dsh-plugin-notify 0.3.8 → 0.4.1

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/lib/index.js CHANGED
@@ -1,27 +1,30 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import crypto from 'node:crypto';
3
+ import fs from 'node:fs';
4
+ import fsp from 'node:fs/promises';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
2
7
  import Schema from '@deepseek-ai/schemastery';
3
8
  import { credentialRef } from '@deepseek-ai/dsh-credentials';
4
9
 
5
10
  export const name = '@goodandready/dsh-plugin-notify';
6
11
  /** Settings namespace shared with the Web settings card. */
7
12
  export const NS = '@goodandready/dsh-plugin-notify';
13
+ /** Canonical entry id matching cordis.patch.yml */
14
+ export const ENTRY_ID = 'plugin-notify';
8
15
 
9
16
  // Session firehose + credentials (webhook URL refs) + settings scope for the card + webServer for SSE events + connection for authentication.
10
17
  export const inject = ['sessions', 'credentials', 'settings', 'webServer', 'connection'];
11
18
 
12
19
  const webhookRef = (label) => Schema.string()
13
20
  .role('credential-ref')
14
- .description(`${label}: DSH credential name whose value is the full webhook URL (not the URL itself). Empty disables the channel.`);
15
-
16
- // Unwrap Volatile boxes before any caller reads a value. A volatile field holds a box
17
- // rather than its value, and the Loader mutates those boxes in place.
18
- export function plainConfig(value) {
19
- if (value === null || typeof value !== 'object') return value
20
- if (Array.isArray(value)) return value.map(plainConfig)
21
- if (typeof value.get === 'function') return plainConfig(value.get())
22
- return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, plainConfig(v)]))
23
- }
21
+ .volatile()
22
+ .description(`${label}: DSH credential name whose value is the full webhook URL (not the URL itself). Empty disables the channel.`)
24
23
 
24
+ // Everything the settings card writes is marked volatile so dsh 0.2 accepts
25
+ // live edits through the settings controller (0.1 ignores the flag). Only
26
+ // leaves may be volatile: cordis 0.2 rejects volatile fields under a
27
+ // volatile enclosing object.
25
28
  export const Config = Schema.object({
26
29
  webhooks: Schema.object({
27
30
  feishu: webhookRef('Feishu custom bot'),
@@ -31,23 +34,35 @@ export const Config = Schema.object({
31
34
  discord: webhookRef('Discord webhook'),
32
35
  custom: webhookRef('Custom generic webhook (POST JSON)'),
33
36
  }).description('Per-channel credential refs for webhook URLs; leave empty to disable'),
34
- events: Schema.array(Schema.string()).volatile()
37
+ events: Schema.array(Schema.string())
38
+ .volatile()
35
39
  .description('Events that trigger notifications: task_done / error / approval_requested'),
36
- local: Schema.boolean().volatile().default(true).description('Also emit a local system notification (macOS osascript)'),
37
- enableSound: Schema.boolean().volatile().default(false).description('Play synthesized audio chime on completion, error, or approval'),
38
- enableToasts: Schema.boolean().volatile().default(false).description('Show in-app on-screen toast notifications across sessions'),
39
- enableDesktopNotifications: Schema.boolean().volatile().default(false).description('Show native desktop/OS push notifications (Windows, macOS, Linux)'),
40
- notifyBackgroundOnly: Schema.boolean().volatile().default(false).description('Notify only if the event occurred in a background/inactive session'),
41
- timeoutMs: Schema.number().volatile().default(5000).description('Per-webhook request timeout (ms)'),
40
+ local: Schema.boolean().default(true).volatile().description('Also emit a local system notification (macOS osascript)'),
41
+ enableSound: Schema.boolean().default(false).volatile().description('Play synthesized audio chime on completion, error, or approval'),
42
+ enableToasts: Schema.boolean().default(false).volatile().description('Show in-app on-screen toast notifications across sessions'),
43
+ enableDesktopNotifications: Schema.boolean().default(false).volatile().description('Show native desktop/OS push notifications (Windows, macOS, Linux)'),
44
+ notifyBackgroundOnly: Schema.boolean().default(false).volatile().description('Notify only if the event occurred in a background/inactive session'),
45
+ timeoutMs: Schema.number().default(5000).volatile().description('Per-webhook request timeout (ms)'),
42
46
  dnd: Schema.object({
43
- start: Schema.string().default('').description('Do-not-disturb start (HH:MM, empty disables)'),
44
- end: Schema.string().default('').description('Do-not-disturb end (HH:MM, cross-midnight ok)'),
47
+ start: Schema.string().default('').volatile().description('Do-not-disturb start (HH:MM, empty disables)'),
48
+ end: Schema.string().default('').volatile().description('Do-not-disturb end (HH:MM, cross-midnight ok)'),
45
49
  }).description('DND window: events are logged but no local/webhook emission'),
46
- includeSession: Schema.boolean().volatile().default(true).description('Include the session line in notification text'),
47
- includeDuration: Schema.boolean().volatile().default(true).description('Include the duration line in notification text'),
48
- excludeSessionPrefixes: Schema.array(Schema.string()).volatile()
50
+ includeSession: Schema.boolean().default(true).volatile().description('Include the session line in notification text'),
51
+ includeDuration: Schema.boolean().default(true).volatile().description('Include the duration line in notification text'),
52
+ excludeSessionPrefixes: Schema.array(Schema.string())
49
53
  .default([])
54
+ .volatile()
50
55
  .description('Skip notifications when session id starts with any prefix (e.g. msgw- for messenger-gateway)'),
56
+ webPush: Schema.object({
57
+ enabled: Schema.boolean().default(false).volatile()
58
+ .description('Web Push (PWA) channel: send service-worker push notifications that reach devices with every dsh tab closed'),
59
+ onlyWhenAway: Schema.boolean().default(true).volatile()
60
+ .description('Skip web push while a browser client is connected to the SSE stream'),
61
+ subject: Schema.string().default('mailto:notify@localhost').volatile()
62
+ .description('VAPID subject (mailto: or https: contact URL) advertised to push services'),
63
+ stateFile: Schema.string().default('').volatile()
64
+ .description('State file for VAPID keys and push subscriptions (default: $DSH_HOME/plugin-notify-state.json)'),
65
+ }).description('Web Push (PWA) channel configuration'),
51
66
  });
52
67
 
53
68
  function isExcludedSession(sessionId, prefixes) {
@@ -59,6 +74,17 @@ function isExcludedSession(sessionId, prefixes) {
59
74
  return false;
60
75
  }
61
76
 
77
+ // Unwrap Volatile boxes before any caller reads a value. A volatile field holds a box
78
+ // rather than its value, and the Loader mutates those boxes in place.
79
+ export function plainConfig(value) {
80
+ if (value === null || typeof value !== 'object') return value
81
+ if (Array.isArray(value)) return value.map(plainConfig)
82
+ if (typeof value.get === 'function') return plainConfig(value.get())
83
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, plainConfig(v)]))
84
+ }
85
+
86
+ export const unwrapConfig = plainConfig
87
+
62
88
  const DEFAULT_EVENTS = ['task_done', 'error', 'approval_requested'];
63
89
  /** Per-session last `turn/start` epoch ms, for turn-duration reporting. */
64
90
  export const turnStarts = new Map();
@@ -252,13 +278,416 @@ export function closeAllSseClients() {
252
278
  sseClients.clear();
253
279
  }
254
280
 
255
- function mountWebServer(targetCtx, hostCtx) {
281
+ // ---------------------------------------------------------------------------
282
+ // Web Push (PWA) channel: VAPID keys + subscription state, push routes, sender.
283
+ // Reaches devices with every dsh tab closed — the SSE/local channels cannot.
284
+ // ---------------------------------------------------------------------------
285
+
286
+ /** Service worker served at GET /dsh-plugin-notify/sw.js (no secrets inside). */
287
+ export const SW_SOURCE = [
288
+ 'self.addEventListener("push", (event) => {',
289
+ ' let data = {};',
290
+ ' try { data = event.data ? event.data.json() : {}; } catch (_) { data = {}; }',
291
+ ' const title = data.title || "DeepSeek Harness";',
292
+ ' event.waitUntil(self.registration.showNotification(title, {',
293
+ ' body: data.body || "",',
294
+ ' tag: data.tag || "dsh-notify",',
295
+ ' data: { sessionId: data.sessionId || "" },',
296
+ ' }));',
297
+ '});',
298
+ 'self.addEventListener("notificationclick", (event) => {',
299
+ ' event.notification.close();',
300
+ ' const sessionId = (event.notification.data && event.notification.data.sessionId) || "";',
301
+ ' const target = sessionId ? "./?dshNotifySession=" + encodeURIComponent(sessionId) : "./";',
302
+ ' event.waitUntil((async () => {',
303
+ ' const clientList = await self.clients.matchAll({ type: "window", includeUncontrolled: true });',
304
+ ' for (const client of clientList) {',
305
+ ' let pathname = "";',
306
+ ' try { pathname = new URL(client.url).pathname; } catch (_) { continue; }',
307
+ ' if (pathname.endsWith("/") || pathname.endsWith("/index.html")) {',
308
+ ' await client.focus();',
309
+ ' if (sessionId) client.postMessage({ type: "dsh-notify-jump", sessionId });',
310
+ ' return;',
311
+ ' }',
312
+ ' }',
313
+ ' await self.clients.openWindow(target);',
314
+ ' })());',
315
+ '});',
316
+ ].join('\n');
317
+
318
+ /** Env overrides so users can keep VAPID keys out of the state file entirely. */
319
+ export const VAPID_PUBLIC_ENV = 'DSH_NOTIFY_VAPID_PUBLIC_KEY';
320
+ export const VAPID_PRIVATE_ENV = 'DSH_NOTIFY_VAPID_PRIVATE_KEY';
321
+
322
+ /** P-256 VAPID keypair as base64url — raw 65-byte point / raw 32-byte scalar,
323
+ * the exact shapes web-push.generateVAPIDKeys() produces (applicationServerKey needs the raw point).
324
+ * ECDH, not DER slicing: the pkcs8 DER ends with the public key, so slice(-32) would grab point bytes. */
325
+ export function generateVapidKeys() {
326
+ const ecdh = crypto.createECDH('prime256v1');
327
+ ecdh.generateKeys();
328
+ return {
329
+ publicKey: ecdh.getPublicKey('base64url'),
330
+ privateKey: ecdh.getPrivateKey('base64url'),
331
+ };
332
+ }
333
+
334
+ export function resolveStatePath(webPushConfig = {}) {
335
+ if (webPushConfig && typeof webPushConfig.stateFile === 'string' && webPushConfig.stateFile.trim())
336
+ return webPushConfig.stateFile.trim();
337
+ const home = (process.env.DSH_HOME && process.env.DSH_HOME.trim())
338
+ || path.join(os.homedir(), '.dsh');
339
+ return path.join(home, 'plugin-notify-state.json');
340
+ }
341
+
342
+ export async function loadState(statePath) {
343
+ try {
344
+ const raw = await fsp.readFile(statePath, 'utf8');
345
+ const parsed = JSON.parse(raw);
346
+ return {
347
+ vapid: (parsed && typeof parsed.vapid === 'object' && parsed.vapid) || null,
348
+ subscriptions: Array.isArray(parsed?.subscriptions) ? parsed.subscriptions : [],
349
+ };
350
+ } catch {
351
+ return { vapid: null, subscriptions: [] };
352
+ }
353
+ }
354
+
355
+ /** Atomic-ish persist: temp file + rename, serialized per path to avoid RMW races. */
356
+ const writeChains = new Map();
357
+ export function saveState(statePath, state) {
358
+ const prev = writeChains.get(statePath) || Promise.resolve();
359
+ const next = prev.catch(() => {}).then(async () => {
360
+ await fsp.mkdir(path.dirname(statePath), { recursive: true });
361
+ const tmp = `${statePath}.${process.pid}.${Date.now()}.tmp`;
362
+ await fsp.writeFile(tmp, JSON.stringify(state, null, 2), 'utf8');
363
+ await fsp.rename(tmp, statePath);
364
+ });
365
+ writeChains.set(statePath, next);
366
+ return next;
367
+ }
368
+
369
+ /** Env keys win; otherwise state file; otherwise generate once and persist. */
370
+ export async function getVapidKeys(ctx, webPushConfig = {}) {
371
+ const envPub = process.env[VAPID_PUBLIC_ENV];
372
+ const envPriv = process.env[VAPID_PRIVATE_ENV];
373
+ if (envPub && envPriv) {
374
+ return { publicKey: envPub, privateKey: envPriv };
375
+ }
376
+ const statePath = resolveStatePath(webPushConfig);
377
+ const state = await loadState(statePath);
378
+ if (state.vapid && state.vapid.publicKey && state.vapid.privateKey)
379
+ return state.vapid;
380
+ const keys = generateVapidKeys();
381
+ state.vapid = keys;
382
+ await saveState(statePath, state);
383
+ ctx?.logger?.info?.(`[plugin-notify] generated VAPID keys → ${statePath}`);
384
+ return keys;
385
+ }
386
+
387
+ function isValidSubscription(sub) {
388
+ return Boolean(
389
+ sub
390
+ && typeof sub.endpoint === 'string'
391
+ && /^https:\/\//i.test(sub.endpoint)
392
+ && sub.keys
393
+ && typeof sub.keys.p256dh === 'string' && sub.keys.p256dh.length > 0
394
+ && typeof sub.keys.auth === 'string' && sub.keys.auth.length > 0,
395
+ );
396
+ }
397
+
398
+ export async function addSubscription(statePath, sub) {
399
+ if (!isValidSubscription(sub))
400
+ throw new Error('invalid push subscription (need https endpoint and keys.p256dh/auth)');
401
+ const state = await loadState(statePath);
402
+ const clean = { endpoint: sub.endpoint, keys: { p256dh: sub.keys.p256dh, auth: sub.keys.auth }, addedAt: Date.now() };
403
+ const existing = state.subscriptions.findIndex((s) => s.endpoint === clean.endpoint);
404
+ if (existing >= 0) state.subscriptions[existing] = clean;
405
+ else state.subscriptions.push(clean);
406
+ await saveState(statePath, state);
407
+ return state.subscriptions.length;
408
+ }
409
+
410
+ export async function removeSubscription(statePath, endpoint) {
411
+ const state = await loadState(statePath);
412
+ const before = state.subscriptions.length;
413
+ state.subscriptions = state.subscriptions.filter((s) => s.endpoint !== endpoint);
414
+ if (state.subscriptions.length !== before)
415
+ await saveState(statePath, state);
416
+ return before - state.subscriptions.length;
417
+ }
418
+
419
+ export async function listSubscriptions(statePath) {
420
+ const state = await loadState(statePath);
421
+ return state.subscriptions;
422
+ }
423
+
424
+ let webPushLibrary = null;
425
+ /** Test seam: inject a stub with async sendNotification(sub, payload, options). */
426
+ export function setWebPushLibrary(lib) {
427
+ webPushLibrary = lib;
428
+ }
429
+ async function getWebPush() {
430
+ if (!webPushLibrary) {
431
+ const mod = await import('web-push');
432
+ webPushLibrary = mod.default ?? mod;
433
+ }
434
+ return webPushLibrary;
435
+ }
436
+
437
+ /** Compact payload for the 4 KB push budget: pointer + label, never secret material. */
438
+ export function webPushPayload(n, config = {}) {
439
+ const badge = n.kind === 'task_done' ? '✅' : n.kind === 'error' ? '⚠️' : '⏸️';
440
+ const kindLabel = n.kind === 'task_done' ? 'Task done' : n.kind === 'error' ? 'Error' : 'Approval needed';
441
+ const title = `${badge} ${kindLabel}: ${n.title || n.sessionId || ''}`.trim();
442
+ const bodyLines = [];
443
+ if (n.summary) bodyLines.push(n.summary);
444
+ if (n.reason) bodyLines.push(`Reason: ${n.reason}`);
445
+ if ((config.includeDuration ?? true) && n.durationMs !== undefined)
446
+ bodyLines.push(`Duration: ${formatDuration(n.durationMs)}`);
447
+ if ((config.includeSession ?? true) && n.sessionId)
448
+ bodyLines.push(`Session: ${n.sessionId}`);
449
+ return {
450
+ kind: n.kind,
451
+ title,
452
+ body: bodyLines.join('\n'),
453
+ sessionId: n.sessionId || '',
454
+ tag: `dsh-notify-${n.sessionId || 'any'}`,
455
+ ts: Date.now(),
456
+ };
457
+ }
458
+
459
+ const inFlightDeliveries = new Set();
460
+ /** Test seam: resolves once every in-flight deliverWebPush has finished. */
461
+ export async function settleWebPushDeliveries() {
462
+ while (inFlightDeliveries.size > 0) {
463
+ await Promise.allSettled([...inFlightDeliveries]);
464
+ }
465
+ }
466
+
467
+ export function deliverWebPush(ctx, n, config = {}) {
468
+ let tracked;
469
+ tracked = (async () => {
470
+ const wp = config.webPush || {};
471
+ const statePath = resolveStatePath(wp);
472
+ const subscriptions = await listSubscriptions(statePath);
473
+ if (subscriptions.length === 0) return 0;
474
+ const keys = await getVapidKeys(ctx, wp);
475
+ const webpush = await getWebPush();
476
+ const payload = JSON.stringify(webPushPayload(n, config));
477
+ let sent = 0;
478
+ for (const sub of subscriptions) {
479
+ try {
480
+ await webpush.sendNotification(sub, payload, {
481
+ vapidDetails: {
482
+ subject: wp.subject || 'mailto:notify@localhost',
483
+ publicKey: keys.publicKey,
484
+ privateKey: keys.privateKey,
485
+ },
486
+ timeout: Math.max(config.timeoutMs ?? 5000, 1000),
487
+ });
488
+ sent++;
489
+ } catch (error) {
490
+ if (error?.statusCode === 404 || error?.statusCode === 410) {
491
+ await removeSubscription(statePath, sub.endpoint);
492
+ ctx?.logger?.info?.(`[plugin-notify] pruned dead push subscription (${error.statusCode})`);
493
+ } else {
494
+ ctx?.logger?.warn?.(`[plugin-notify] web push send failed: ${String(error)}`);
495
+ }
496
+ }
497
+ }
498
+ return sent;
499
+ })().finally(() => {
500
+ inFlightDeliveries.delete(tracked);
501
+ });
502
+ inFlightDeliveries.add(tracked);
503
+ return tracked;
504
+ }
505
+
506
+ function readJsonBody(req, limit = 64 * 1024) {
507
+ return new Promise((resolve, reject) => {
508
+ let size = 0;
509
+ const chunks = [];
510
+ req.on('data', (chunk) => {
511
+ size += chunk.length;
512
+ if (size > limit) {
513
+ reject(new Error('request body too large'));
514
+ req.destroy();
515
+ return;
516
+ }
517
+ chunks.push(chunk);
518
+ });
519
+ req.on('end', () => {
520
+ try {
521
+ resolve(chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {});
522
+ } catch {
523
+ reject(new Error('invalid JSON body'));
524
+ }
525
+ });
526
+ req.on('error', reject);
527
+ });
528
+ }
529
+
530
+ function writeJson(res, status, body, headers = {}) {
531
+ if (typeof res.writeHead === 'function') {
532
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', ...headers });
533
+ } else {
534
+ res.statusCode = status;
535
+ }
536
+ if (typeof res.end === 'function') res.end(JSON.stringify(body));
537
+ }
538
+
539
+ function writeText(res, status, text, headers = {}) {
540
+ if (typeof res.writeHead === 'function') {
541
+ res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8', ...headers });
542
+ } else {
543
+ res.statusCode = status;
544
+ }
545
+ if (typeof res.end === 'function') res.end(text);
546
+ }
547
+
548
+ /** Shared auth for push routes: same fence as the SSE stream. Returns true when accepted. */
549
+ 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');
562
+ return false;
563
+ }
564
+ return true;
565
+ }
566
+
567
+ export function handleServiceWorker(req, res) {
568
+ if (req.method !== 'GET') {
569
+ writeText(res, 405, 'Method Not Allowed', { allow: 'GET' });
570
+ return;
571
+ }
572
+ // Unauthenticated on purpose: static, no secrets; the header lets the SW
573
+ // control the app root even though the script lives under /dsh-plugin-notify/.
574
+ if (typeof res.writeHead === 'function') {
575
+ res.writeHead(200, {
576
+ 'Content-Type': 'text/javascript; charset=utf-8',
577
+ 'Service-Worker-Allowed': '/',
578
+ 'Cache-Control': 'no-store',
579
+ });
580
+ } else {
581
+ res.statusCode = 200;
582
+ }
583
+ if (typeof res.end === 'function') res.end(SW_SOURCE);
584
+ }
585
+
586
+ export async function handlePushKey(req, res, targetCtx, getConfig) {
587
+ const getConfigU = () => unwrapConfig(getConfig());
588
+ if (req.method !== 'GET') return writeText(res, 405, 'Method Not Allowed', { allow: 'GET' });
589
+ if (!authorizePushRequest(req, res, targetCtx)) return;
590
+ const cfg = getConfigU();
591
+ if (!(cfg.webPush && cfg.webPush.enabled)) return writeText(res, 404, 'web push disabled');
592
+ const keys = await getVapidKeys(targetCtx, cfg.webPush || {});
593
+ writeJson(res, 200, { publicKey: keys.publicKey, subject: cfg.webPush.subject || 'mailto:notify@localhost' });
594
+ }
595
+
596
+ export async function handlePushSubscribe(req, res, targetCtx, getConfig) {
597
+ const getConfigU = () => unwrapConfig(getConfig());
598
+ if (req.method !== 'POST') return writeText(res, 405, 'Method Not Allowed', { allow: 'POST' });
599
+ if (!authorizePushRequest(req, res, targetCtx)) return;
600
+ const cfg = getConfigU();
601
+ if (!(cfg.webPush && cfg.webPush.enabled)) return writeText(res, 404, 'web push disabled');
602
+ let sub;
603
+ try {
604
+ sub = await readJsonBody(req);
605
+ } catch (error) {
606
+ return writeText(res, 400, String(error.message || error));
607
+ }
608
+ const statePath = resolveStatePath(cfg.webPush || {});
609
+ try {
610
+ const count = await addSubscription(statePath, sub);
611
+ targetCtx?.logger?.info?.(`[plugin-notify] push subscribed (${count} total)`);
612
+ writeJson(res, 200, { ok: true, count });
613
+ } catch (error) {
614
+ writeText(res, 400, String(error.message || error));
615
+ }
616
+ }
617
+
618
+ export async function handlePushUnsubscribe(req, res, targetCtx, getConfig) {
619
+ const getConfigU = () => unwrapConfig(getConfig());
620
+ if (req.method !== 'POST') return writeText(res, 405, 'Method Not Allowed', { allow: 'POST' });
621
+ if (!authorizePushRequest(req, res, targetCtx)) return;
622
+ const cfg = getConfigU();
623
+ if (!(cfg.webPush && cfg.webPush.enabled)) return writeText(res, 404, 'web push disabled');
624
+ let body;
625
+ try {
626
+ body = await readJsonBody(req);
627
+ } catch (error) {
628
+ return writeText(res, 400, String(error.message || error));
629
+ }
630
+ if (!body || typeof body.endpoint !== 'string') return writeText(res, 400, 'endpoint required');
631
+ const removed = await removeSubscription(resolveStatePath(cfg.webPush || {}), body.endpoint);
632
+ writeJson(res, 200, { ok: true, removed });
633
+ }
634
+
635
+ export async function handlePushTest(req, res, targetCtx, getConfig) {
636
+ const getConfigU = () => unwrapConfig(getConfig());
637
+ if (req.method !== 'POST') return writeText(res, 405, 'Method Not Allowed', { allow: 'POST' });
638
+ if (!authorizePushRequest(req, res, targetCtx)) return;
639
+ const cfg = getConfigU();
640
+ if (!(cfg.webPush && cfg.webPush.enabled)) return writeText(res, 404, 'web push disabled');
641
+ const sent = await deliverWebPush(targetCtx, {
642
+ kind: 'task_done',
643
+ title: 'Test notification',
644
+ sessionId: 'test',
645
+ summary: 'If you can read this, the dsh-plugin-notify web push channel works.',
646
+ }, cfg);
647
+ writeJson(res, 200, { ok: true, sent });
648
+ }
649
+
650
+ function mountWebServer(targetCtx, hostCtx, getConfig) {
256
651
  if (!targetCtx?.webServer?.register) return () => {};
257
- const routeDef = {
258
- kind: 'exact',
259
- path: '/dsh-plugin-notify/events',
260
- handler: (req, res) => handleSseConnection(req, res, targetCtx),
652
+ // Async route wrapper: an unexpected rejection becomes a 500 instead of an
653
+ // unhandled rejection; authorized handlers have already written their response.
654
+ const asyncRoute = (handler) => (req, res) => {
655
+ Promise.resolve(handler(req, res)).catch(() => {
656
+ try { writeText(res, 500, 'internal error'); } catch { /* ignore */ }
657
+ });
261
658
  };
659
+ const routeDefs = [
660
+ {
661
+ kind: 'exact',
662
+ path: '/dsh-plugin-notify/events',
663
+ handler: (req, res) => handleSseConnection(req, res, targetCtx),
664
+ },
665
+ {
666
+ kind: 'exact',
667
+ path: '/dsh-plugin-notify/sw.js',
668
+ handler: (req, res) => handleServiceWorker(req, res),
669
+ },
670
+ {
671
+ kind: 'exact',
672
+ path: '/dsh-plugin-notify/push/key',
673
+ handler: asyncRoute((req, res) => handlePushKey(req, res, targetCtx, getConfig)),
674
+ },
675
+ {
676
+ kind: 'exact',
677
+ path: '/dsh-plugin-notify/push/subscribe',
678
+ handler: asyncRoute((req, res) => handlePushSubscribe(req, res, targetCtx, getConfig)),
679
+ },
680
+ {
681
+ kind: 'exact',
682
+ path: '/dsh-plugin-notify/push/unsubscribe',
683
+ handler: asyncRoute((req, res) => handlePushUnsubscribe(req, res, targetCtx, getConfig)),
684
+ },
685
+ {
686
+ kind: 'exact',
687
+ path: '/dsh-plugin-notify/push/test',
688
+ handler: asyncRoute((req, res) => handlePushTest(req, res, targetCtx, getConfig)),
689
+ },
690
+ ];
262
691
 
263
692
  let timer = null;
264
693
  const startHeartbeat = () => {
@@ -281,8 +710,10 @@ function mountWebServer(targetCtx, hostCtx) {
281
710
  clearInterval(timer);
282
711
  timer = null;
283
712
  }
284
- if (typeof unregister === 'function') {
285
- try { unregister(); } catch { /* ignore */ }
713
+ if (Array.isArray(unregister)) {
714
+ for (const fn of unregister) {
715
+ try { fn(); } catch { /* ignore */ }
716
+ }
286
717
  unregister = null;
287
718
  }
288
719
  closeAllSseClients();
@@ -290,14 +721,14 @@ function mountWebServer(targetCtx, hostCtx) {
290
721
 
291
722
  if (typeof targetCtx.effect === 'function') {
292
723
  targetCtx.effect(() => {
293
- unregister = targetCtx.webServer.register(routeDef);
724
+ unregister = routeDefs.map((routeDef) => targetCtx.webServer.register(routeDef));
294
725
  return () => cleanup();
295
726
  }, 'dsh-plugin-notify: sse events');
296
727
  targetCtx.effect(() => {
297
728
  return startHeartbeat();
298
729
  }, 'dsh-plugin-notify: sse heartbeat');
299
730
  } else {
300
- unregister = targetCtx.webServer.register(routeDef);
731
+ unregister = routeDefs.map((routeDef) => targetCtx.webServer.register(routeDef));
301
732
  startHeartbeat();
302
733
  }
303
734
 
@@ -305,7 +736,7 @@ function mountWebServer(targetCtx, hostCtx) {
305
736
  }
306
737
 
307
738
  export function apply(ctx, config = {}) {
308
- let getConfig = () => config ?? {};
739
+ let getConfig = () => unwrapConfig(config ?? {});
309
740
 
310
741
  let webServerMounted = false;
311
742
  let unmountWebServer = null;
@@ -315,7 +746,7 @@ export function apply(ctx, config = {}) {
315
746
  if (target?.webServer?.register) {
316
747
  webServerMounted = true;
317
748
  const effectiveTarget = target?.connection ? target : (ctx?.connection ? ctx : target);
318
- unmountWebServer = mountWebServer(effectiveTarget, ctx);
749
+ unmountWebServer = mountWebServer(effectiveTarget, ctx, () => getConfig());
319
750
  }
320
751
  };
321
752
 
@@ -338,21 +769,58 @@ export function apply(ctx, config = {}) {
338
769
 
339
770
  if (typeof ctx.inject === 'function') {
340
771
  ctx.inject(['settings'], (sctx) => {
341
- const row = (() => { try { return sctx.settings?.describe?.().find((r) => r && r.ns === NS); } catch { return null; } })();
772
+ if (typeof sctx.settings?.register === 'function') {
773
+ try {
774
+ const scope = sctx.settings.register(ENTRY_ID, Config, { base: config ?? {} });
775
+ getConfig = () => (scope ? unwrapConfig(scope.get() ?? config ?? {}) : unwrapConfig(config ?? {}));
776
+ return;
777
+ } catch (err) {
778
+ ctx?.logger?.warn?.(`[plugin-notify] settings.register('${ENTRY_ID}') failed, falling back to describe: ${String(err)}`);
779
+ }
780
+ }
781
+ let currentRow = (() => { try { return sctx.settings?.describe?.().find((r) => r && (r.ns === ENTRY_ID || r.ns === NS)); } catch { return null; } })();
782
+ const refreshSettings = () => {
783
+ try {
784
+ const next = sctx.settings?.describe?.().find((r) => r && (r.ns === ENTRY_ID || r.ns === NS));
785
+ if (next) currentRow = next;
786
+ } catch {
787
+ // best effort
788
+ }
789
+ };
790
+ if (typeof ctx.on === 'function') {
791
+ ctx.on('settings/document-updated', refreshSettings);
792
+ ctx.on('loader/volatile-update', refreshSettings);
793
+ ctx.on('settings/updated', refreshSettings);
794
+ }
795
+ if (typeof sctx.settings?.on === 'function') {
796
+ try { sctx.settings.on('update', refreshSettings); } catch { /* ignore */ }
797
+ }
342
798
  const scope = {
343
- get: () => row?.value ?? config ?? {},
344
- replace: async (next) => { try { return await sctx.settings?.replace?.(NS, next, row?.revision); } catch {} },
799
+ get: () => {
800
+ refreshSettings();
801
+ return currentRow?.value ?? config ?? {};
802
+ },
803
+ replace: async (next) => {
804
+ try {
805
+ const targetNs = sctx.settings?.describe?.().some((r) => r && r.ns === ENTRY_ID) ? ENTRY_ID : NS;
806
+ const res = await sctx.settings?.replace?.(targetNs, next, currentRow?.revision);
807
+ refreshSettings();
808
+ return res;
809
+ } catch (err) {
810
+ ctx?.logger?.warn?.(`[plugin-notify] settings replace failed: ${String(err)}`);
811
+ }
812
+ },
345
813
  };
346
- getConfig = () => scope.get() ?? config ?? {};
814
+ getConfig = () => (scope ? unwrapConfig(scope.get() ?? config ?? {}) : unwrapConfig(config ?? {}));
347
815
  });
348
- ctx.inject(['webServer'], (wctx) => tryMountWebServer(wctx));
816
+ ctx.inject(['webServer'], (wctx) => tryMountWebServer(wctx))
349
817
  }
350
818
  if (ctx.webServer) {
351
819
  tryMountWebServer(ctx);
352
820
  }
353
821
 
354
822
  const dispatch = (n) => {
355
- const cfg = getConfig();
823
+ const cfg = unwrapConfig(getConfig());
356
824
  const dnd = cfg.dnd;
357
825
  if (inDnd(dnd)) {
358
826
  (ctx?.logger?.debug ?? ctx?.logger?.info)?.(`[plugin-notify] ${n.kind} · ${n.title} · session ${n.sessionId} · DND (${dnd?.start}-${dnd?.end}), logged only`);
@@ -381,6 +849,19 @@ export function apply(ctx, config = {}) {
381
849
  .catch((error) => {
382
850
  ctx?.logger?.warn?.(`[plugin-notify] webhook resolve/send failed: ${String(error)}`);
383
851
  });
852
+
853
+ // Web Push (PWA) channel: reaches devices with every dsh tab closed.
854
+ const wp = cfg.webPush;
855
+ if (wp && wp.enabled) {
856
+ const away = sseClients.size === 0;
857
+ if (away || wp.onlyWhenAway === false) {
858
+ deliverWebPush(ctx, n, cfg).catch((error) => {
859
+ ctx?.logger?.warn?.(`[plugin-notify] web push dispatch failed: ${String(error)}`);
860
+ });
861
+ } else {
862
+ (ctx?.logger?.debug ?? ctx?.logger?.info)?.(`[plugin-notify] ${n.kind} · session ${n.sessionId} · web push skipped (clients connected)`);
863
+ }
864
+ }
384
865
  };
385
866
 
386
867
  ctx.on('session/event', (session, event) => {