@goodandready/dsh-tts 0.4.33 → 0.4.35

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
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.35 (2026-10-20)
4
+
5
+ ### Fixed
6
+ - **Authentication Guard for Administrative and Credential Routes** (#210):
7
+ - Enforced DSH core connection authentication (`connection.requestRejection(req)`) across all `/dsh-tts/*` HTTP endpoints.
8
+ - Fail-closed: Remote LAN clients without an authenticated operator session or token are rejected with 401/403 and cannot manage credentials or mutate plugin configuration (`PUT/DELETE /dsh-tts/credential`, `PUT /dsh-tts/config`, `/dsh-tts/models/install`, `/dsh-tts/models/delete`, `/dsh-tts/cache/clear`).
9
+ - Permitted authenticated operators via DSH connection service to manage provider credentials and configuration from LAN or remote interfaces.
10
+ - Added helpers `getClientIp(req)`, `isLanAddress(address)`, and `getRejectionStatus(req, options)` with comprehensive unit test coverage.
11
+
12
+ ## 0.4.34 (2026-10-17)
13
+
14
+ - **Per-Reply Speak Action** (#208, GitHub PR #2):
15
+ - Added dedicated speaker button to `conversation.chat.assistant-actions` (order 5), positioned beside copy action to read rendered assistant turn text directly from the DOM.
16
+ - Added in-row transport cluster next to the reply clock (pause, stop, skip, export) that remains visible during playback even when mouse leaves message row.
17
+ - **Context Menu & Contained Scroll** (#208, GitHub PR #2):
18
+ - Added right-click context menu on the speaker action to display recent utterances with contained wheel scrolling that prevents closing on scroll.
19
+ - **Hotkeys & Playback Enhancements** (#208, GitHub PR #2):
20
+ - Added `Shift+Space` hotkey to stop active playback or instantly replay the last utterance from memory cache (`player.lastItem`).
21
+ - Added `statusKnown` guard in SSE event handling to prevent dropping initial stream utterances during server startup.
22
+ - Primed pending audio poll immediately on mount and on audio unlock gesture.
23
+ - **Client Bundle**: Rebuilt `lib/client.js` from canonical `lib/client-src/` fragments with updated tests.
24
+
3
25
  ## 0.4.33 (2026-10-11)
4
26
 
5
27
  - **SSE Stream Watchdog**: Added idle timeout watchdog (30s) in Web player (`lib/client-src/20-player.js`) to automatically trigger reconnect on stale/frozen EventSource connection (#206).
package/lib/client.js CHANGED
@@ -210,6 +210,39 @@ window.__ModuleLoader__.load({
210
210
  '.dts-alert-err{background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);color:var(--dsw-alias-state-error-primary);border:1px solid color-mix(in srgb, var(--dsw-alias-state-error-primary) 20%, transparent)}' +
211
211
  '.dts-alert-warn{background:color-mix(in srgb, var(--dsw-alias-state-warn-primary, #f59e0b) 10%, transparent);color:var(--dsw-alias-state-warn-primary, #f59e0b);border:1px solid color-mix(in srgb, var(--dsw-alias-state-warn-primary, #f59e0b) 20%, transparent)}' +
212
212
  '.dts-alert-ok{background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent);color:var(--dsw-alias-state-success-primary);border:1px solid color-mix(in srgb, var(--dsw-alias-state-success-primary) 20%, transparent)}' +
213
+ // Per-message speak button: icon-only, same weight as the sibling actions.
214
+ '.dts-act{box-sizing:border-box;width:calc(28px + var(--dsh-content-font-delta,0px));height:calc(28px + var(--dsh-content-font-delta,0px));display:inline-flex;align-items:center;justify-content:center;padding:6px;cursor:pointer;border:none;border-radius:var(--dsw-radius-sm, 8px);background:none;color:var(--dsw-alias-label-tertiary, #999);transition:background .15s ease,color .15s ease}' +
215
+ '.dts-act:hover{background:var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.12));color:var(--dsw-alias-label-secondary, #666)}' +
216
+ '.dts-act:active{background:color-mix(in srgb, var(--dsw-alias-label-secondary, #666) 16%, transparent)}' +
217
+ '.dts-act[data-active]{color:var(--dsw-alias-brand-primary, #4d6bfe)}' +
218
+ '.dts-act[data-active]:hover{background:color-mix(in srgb, var(--dsw-alias-brand-primary, #4d6bfe) 12%, transparent);color:var(--dsw-alias-brand-primary, #4d6bfe)}' +
219
+ '.dts-act:focus-visible{outline:2px solid var(--dsw-alias-brand-primary, #4d6bfe);outline-offset:1px}' +
220
+ '.dts-act svg{width:calc(17px + var(--dsh-content-font-delta,0px));height:calc(17px + var(--dsh-content-font-delta,0px));display:block}' +
221
+ '@keyframes dts-act-pulse{0%,100%{opacity:.35}50%{opacity:1}}' +
222
+ '.dts-act[data-busy] [data-wave]{animation:dts-act-pulse 1.1s ease-in-out infinite}' +
223
+ '.dts-act[data-busy] [data-wave="2"]{animation-delay:.22s}' +
224
+ // Right-click menu: just the recent lines, all of them, scrolled by wheel.
225
+ '.dts-menu{position:fixed;z-index:2147483000;box-sizing:border-box;min-width:210px;max-width:300px;max-height:min(64vh, 460px);overflow:hidden;padding:6px;display:flex;flex-direction:column;gap:2px;border-radius:12px;border:1px solid var(--dsw-alias-border-l2, #e8e8e8);background:var(--dsw-alias-bg-layer-2, #fff);box-shadow:0 12px 32px rgba(0,0,0,.16),0 2px 6px rgba(0,0,0,.06);font-size:13px;line-height:1.4}' +
226
+ '.dts-menu-item{display:flex;align-items:center;gap:8px;box-sizing:border-box;width:100%;padding:7px 10px;border:none;border-radius:8px;background:none;color:var(--dsw-alias-label-primary, #111);font:inherit;text-align:left;cursor:pointer}' +
227
+ '.dts-menu-item:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.12))}' +
228
+ '.dts-menu-item:disabled{opacity:.42;cursor:default}' +
229
+ '.dts-menu-item span:last-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' +
230
+ // The list is the whole menu: no cap on how many lines, it scrolls instead.
231
+ '.dts-menu-list{display:flex;flex-direction:column;gap:2px;flex:1 1 auto;min-height:0;overflow-y:auto;overscroll-behavior:contain;scrollbar-width:thin;scrollbar-color:var(--dsw-alias-border-l2, #ddd) transparent}' +
232
+ '.dts-menu-list::-webkit-scrollbar{width:8px}' +
233
+ '.dts-menu-list::-webkit-scrollbar-thumb{background:var(--dsw-alias-border-l2, #ddd);border-radius:4px}' +
234
+ '.dts-menu-list::-webkit-scrollbar-track{background:transparent}' +
235
+ '.dts-menu-text{flex:none;width:100%;box-sizing:border-box;padding:6px 10px;border:none;border-radius:8px;background:none;color:var(--dsw-alias-label-primary, #111);font:inherit;text-align:left;cursor:pointer;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' +
236
+ '.dts-menu-text:hover{background:var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.12))}' +
237
+ // Transport cluster: same row as the reply actions, parked after the clock.
238
+ // DSH fades that row out (opacity:0) until you hover the message, so force
239
+ // it back on while a clip is playing — otherwise the controls vanish.
240
+ '[data-actions-reveal]:has(.dts-transport) [class*="actions"]{opacity:1 !important}' +
241
+ '.dts-transport{display:inline-flex;align-items:center;gap:2px;margin-left:4px;order:999}' +
242
+ '.dts-tbtn{box-sizing:border-box;width:27px;height:27px;display:inline-flex;align-items:center;justify-content:center;padding:4px;border:none;border-radius:7px;background:none;color:var(--dsw-alias-label-tertiary, #999);cursor:pointer;transition:background .15s ease,color .15s ease}' +
243
+ '.dts-tbtn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.12));color:var(--dsw-alias-label-primary, #111)}' +
244
+ '.dts-tbtn:disabled{opacity:.38;cursor:default}' +
245
+ '.dts-tbtn svg{width:16px;height:16px;display:block}' +
213
246
  '.dts-grow{flex:1;min-width:100px}'
214
247
 
215
248
  const setCssId = 'dsh-tts/settings.module.css'
@@ -257,6 +290,9 @@ window.__ModuleLoader__.load({
257
290
  player.blocked = false
258
291
  playerChanged()
259
292
  drainQueue()
293
+ // Anything the host synthesised while the browser was blocking playback is
294
+ // still queued server-side; pull it once the gesture opened the gate.
295
+ try { pollPending() } catch { /* host not ready yet */ }
260
296
  }
261
297
 
262
298
  function useAutoplayGate() {
@@ -285,6 +321,13 @@ window.__ModuleLoader__.load({
285
321
  get autoplayBlocked() { return autoplay.blocked },
286
322
  }
287
323
  const seenIds = new Set()
324
+ // Reply <div data-turn-tail> the current playback belongs to. Set when the
325
+ // per-message speaker starts a clip; null for auto-speech, where the newest
326
+ // reply hosts the transport instead.
327
+ let activeTailEl = null
328
+ // False until /dsh-tts/status answers: before that the `enabled` default is
329
+ // not a real reading, so stream items must not be discarded.
330
+ let statusKnown = false
288
331
  let currentAudioResolve = null
289
332
  function playerChanged() {
290
333
  for (const listener of [...player.listeners]) {
@@ -375,6 +418,14 @@ window.__ModuleLoader__.load({
375
418
  const { audio, url } = item.prepared
376
419
  audio.playbackRate = Math.max(0.5, Math.min(2, player.rate || 1))
377
420
  player.audio = audio
421
+ if (item.audioBase64 && item.kind !== 'chime') {
422
+ // Keep the last clip in memory so replay is instant (no round trip).
423
+ player.lastItem = {
424
+ text: item.text || '',
425
+ mime: item.mime || 'audio/mpeg',
426
+ audioBase64: item.audioBase64,
427
+ }
428
+ }
378
429
  if (item.text) player.lastText = item.text
379
430
  playerChanged()
380
431
  prebufferNext()
@@ -454,6 +505,7 @@ window.__ModuleLoader__.load({
454
505
  }
455
506
 
456
507
  function stopPlayback() {
508
+ activeTailEl = null
457
509
  removeGestureListeners()
458
510
  player.blocked = false
459
511
  setAutoplayBlocked(false)
@@ -560,7 +612,7 @@ window.__ModuleLoader__.load({
560
612
  try {
561
613
  const item = JSON.parse(e.data)
562
614
  if (!item || !item.id) return
563
- if (player.enabled === false) return
615
+ if (statusKnown && player.enabled === false) return
564
616
  player.after = item.id
565
617
  if (seenIds.has(item.id)) return
566
618
  seenIds.add(item.id)
@@ -579,7 +631,7 @@ window.__ModuleLoader__.load({
579
631
  try {
580
632
  const item = JSON.parse(e.data)
581
633
  if (!item) return
582
- if (player.enabled === false) return
634
+ if (statusKnown && player.enabled === false) return
583
635
  player.queue.push(item)
584
636
  drainQueue()
585
637
  } catch (_err) { /* ignore malformed chime payload */ }
@@ -637,6 +689,7 @@ window.__ModuleLoader__.load({
637
689
  if (!st.ok) return
638
690
  const meta = await st.json()
639
691
  player.enabled = !!(meta && meta.speakReplies)
692
+ statusKnown = true
640
693
  if (meta && typeof meta.rate === 'number') player.rate = meta.rate
641
694
  if (meta && meta.chime) player.chime = meta.chime
642
695
  if (meta && meta.roles) player.roles = meta.roles
@@ -720,6 +773,36 @@ window.__ModuleLoader__.load({
720
773
  })
721
774
  }
722
775
 
776
+ // Replay the most recent clip: from memory when we still hold it, else ask
777
+ // the host (its synthesis cache makes the second round a fast path).
778
+ function replayLast() {
779
+ const last = player.lastItem
780
+ if (last && last.audioBase64) {
781
+ playAudioItem({
782
+ id: `replay-${Date.now()}`,
783
+ text: last.text,
784
+ mime: last.mime || 'audio/mpeg',
785
+ audioBase64: last.audioBase64,
786
+ })
787
+ return true
788
+ }
789
+ if (player.lastText) {
790
+ playStandalone(player.lastText)
791
+ return true
792
+ }
793
+ const lists = recent.list()
794
+ const fallback = (lists.favs && lists.favs[0]) || (lists.items && lists.items[0])
795
+ if (fallback) {
796
+ playStandalone(fallback)
797
+ return true
798
+ }
799
+ return false
800
+ }
801
+
802
+ function isSpeakingNow() {
803
+ return !!player.audio || player.busy || player.queue.length > 0
804
+ }
805
+
723
806
  function previewSpeech(provider, model, voice, text) {
724
807
  stopPlayback()
725
808
  return clientFetch('/dsh-tts/preview', {
@@ -813,6 +896,9 @@ window.__ModuleLoader__.load({
813
896
  'dockFavorite': 'Favorite',
814
897
  'dockUnfavorite': 'Unfavorite',
815
898
  'dockFavorites': 'Favorite clips',
899
+ 'speakMessage': 'Read this reply aloud',
900
+ 'speakMessageStop': 'Stop reading this reply',
901
+ 'speakMessageEmpty': 'Nothing to read in this reply',
816
902
  'exportAudio': 'Export audio clip',
817
903
  'exportSuccess': 'Audio exported',
818
904
  'statusOnline': 'Host online',
@@ -1039,6 +1125,9 @@ window.__ModuleLoader__.load({
1039
1125
  'dockFavorite': '收藏',
1040
1126
  'dockUnfavorite': '取消收藏',
1041
1127
  'dockFavorites': '收藏语音',
1128
+ 'speakMessage': '朗读这条回复',
1129
+ 'speakMessageStop': '停止朗读这条回复',
1130
+ 'speakMessageEmpty': '这条回复没有可朗读的内容',
1042
1131
  'exportAudio': '导出语音片段',
1043
1132
  'exportSuccess': '音频已导出',
1044
1133
  'statusOnline': '服务在线',
@@ -1848,6 +1937,7 @@ window.__ModuleLoader__.load({
1848
1937
  setCredentials(data && data.credentials ? data.credentials : {})
1849
1938
  if (data && data.revision !== undefined) setRevision(data.revision)
1850
1939
  player.enabled = !!cfg.speakReplies
1940
+ statusKnown = true
1851
1941
  }
1852
1942
 
1853
1943
  React.useEffect(() => {
@@ -2222,81 +2312,341 @@ window.__ModuleLoader__.load({
2222
2312
  )
2223
2313
  }
2224
2314
 
2225
- // Input-dock button: shows what is playing, stop control, and recents for replay.
2226
- function SpeakerControl(props) {
2227
- // A slot with `locale` injects translations into props; the fallback is the
2228
- // binder created at registration, in case the slot drops translations.
2229
- const t = (props && props.t) || fallbackDockText
2315
+ // Per-message speak button in the reply action strip (left of the feedback
2316
+ // thumbs). The slot only hands over the durable message id, so the reply
2317
+ // text is read back from the rendered turn: the same prose blocks the
2318
+ // built-in copy button copies.
2319
+ function replyTextFromTurn(el) {
2320
+ try {
2321
+ const tail = el && el.closest ? el.closest('[data-turn-tail]') : null
2322
+ if (!tail) return ''
2323
+ const turn = tail.getAttribute('data-turn-tail')
2324
+ // The tail sits in its own flow item, so climb until an ancestor also
2325
+ // holds rendered reply blocks, then keep only this turn's ones.
2326
+ let scope = tail.parentElement
2327
+ for (let hops = 0; scope && hops < 8; hops++) {
2328
+ if (scope.querySelector('[data-chat-flow-kind="assistant-step"]')) break
2329
+ scope = scope.parentElement
2330
+ }
2331
+ if (!scope) return ''
2332
+ const collect = (inProcess) => {
2333
+ const parts = []
2334
+ for (const node of scope.querySelectorAll('[data-chat-flow-kind="assistant-step"]')) {
2335
+ if (turn !== null && node.getAttribute('data-chat-turn') !== turn) continue
2336
+ if (node.querySelector('[data-turn-tail]')) continue
2337
+ const nestedProcess = !!node.closest('[data-turn-process-hidden]')
2338
+ if (nestedProcess !== inProcess) continue
2339
+ const text = String(node.innerText || node.textContent || '').trim()
2340
+ if (text) parts.push(text)
2341
+ }
2342
+ return parts
2343
+ }
2344
+ // Prose first; fall back to the compacted process answers only when the
2345
+ // turn kept the reply inside the process accordion.
2346
+ let parts = collect(false)
2347
+ if (!parts.length) parts = collect(true)
2348
+ return parts.join('\n\n').trim()
2349
+ } catch (unreadable) {
2350
+ return ''
2351
+ }
2352
+ }
2353
+
2354
+ /** Rounded-square speaker chip, styled after the reference design. */
2355
+ function speakGlyph() {
2356
+ return React.createElement('svg', { viewBox: '0 0 24 24', fill: 'none', 'aria-hidden': 'true', focusable: 'false' },
2357
+ React.createElement('path', {
2358
+ d: 'M11 5.6 6.7 9H3.6v6h3.1L11 18.4z',
2359
+ stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round',
2360
+ }),
2361
+ React.createElement('path', {
2362
+ 'data-wave': '1',
2363
+ d: 'M14.7 9.3a3.8 3.8 0 0 1 0 5.4',
2364
+ stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round',
2365
+ }),
2366
+ React.createElement('path', {
2367
+ 'data-wave': '2',
2368
+ d: 'M17.8 6.5a8.2 8.2 0 0 1 0 11',
2369
+ stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round',
2370
+ }),
2371
+ )
2372
+ }
2373
+
2374
+ // Transport glyphs for the in-row playback cluster.
2375
+ function transportIcon(name) {
2376
+ const stroke = { stroke: 'currentColor', strokeWidth: 1.7, strokeLinecap: 'round', strokeLinejoin: 'round' }
2377
+ const kids = []
2378
+ if (name === 'pause') {
2379
+ kids.push(React.createElement('rect', { key: 'l', x: 7, y: 5, width: 3.2, height: 14, rx: 1, fill: 'currentColor' }))
2380
+ kids.push(React.createElement('rect', { key: 'r', x: 13.8, y: 5, width: 3.2, height: 14, rx: 1, fill: 'currentColor' }))
2381
+ } else if (name === 'play') {
2382
+ kids.push(React.createElement('path', { key: 'p', d: 'M8 5.6 19 12 8 18.4z', fill: 'currentColor' }))
2383
+ } else if (name === 'stop') {
2384
+ kids.push(React.createElement('rect', { key: 's', x: 6.5, y: 6.5, width: 11, height: 11, rx: 2, fill: 'currentColor' }))
2385
+ } else if (name === 'skip') {
2386
+ kids.push(React.createElement('path', { key: 't', d: 'M6.5 5.8 14 12l-7.5 6.2z', fill: 'currentColor' }))
2387
+ kids.push(React.createElement('rect', { key: 'b', x: 15.6, y: 5.8, width: 2.4, height: 12.4, rx: 1, fill: 'currentColor' }))
2388
+ } else {
2389
+ kids.push(React.createElement('path', { key: 'a', d: 'M12 4.5v9.4', ...stroke }))
2390
+ kids.push(React.createElement('path', { key: 'b', d: 'm8.4 10.4 3.6 3.6 3.6-3.6', ...stroke }))
2391
+ kids.push(React.createElement('path', { key: 'c', d: 'M5.5 17.6h13', ...stroke }))
2392
+ }
2393
+ return React.createElement('svg', { viewBox: '0 0 24 24', fill: 'none', 'aria-hidden': 'true', focusable: 'false' }, kids)
2394
+ }
2395
+
2396
+ /** Does this reply own the transport cluster right now? */
2397
+ function ownsTransport(tail) {
2398
+ if (!tail) return false
2399
+ if (activeTailEl && activeTailEl.isConnected) return tail === activeTailEl || tail.contains(activeTailEl)
2400
+ // Auto-speech has no explicit owner: the newest reply carries the controls.
2401
+ const all = document.querySelectorAll('[data-turn-tail]')
2402
+ return all.length > 0 && all[all.length - 1] === tail
2403
+ }
2404
+
2405
+ function MessageSpeakAction(props) {
2406
+ const t = (props && props.t) || ((key) => key)
2230
2407
  const p = usePlayer()
2231
- const [showList, setShowList] = React.useState(false)
2232
- const active = !!p.audio || p.queue.length > 0 || p.busy
2233
- const [, force] = React.useReducer((n) => n + 1, 0)
2234
- const lists = recent.list()
2235
- const hasHistory = (lists.items && lists.items.length > 0) || (lists.favs && lists.favs.length > 0)
2408
+ const btnRef = React.useRef(null)
2409
+ const menuRef = React.useRef(null)
2410
+ const textRef = React.useRef('')
2411
+ const seqRef = React.useRef(0)
2412
+ const [busy, setBusy] = React.useState(false)
2413
+ const [warn, setWarn] = React.useState(false)
2414
+ const [menu, setMenu] = React.useState(null)
2415
+ const active = !busy && !!p.audio && textRef.current !== '' && p.lastText === textRef.current
2416
+
2417
+ React.useEffect(() => {
2418
+ if (!busy) return undefined
2419
+ const guard = setTimeout(() => setBusy(false), 20000)
2420
+ return () => clearTimeout(guard)
2421
+ }, [busy])
2422
+
2423
+ // Close the context menu on an outside press, Esc, or any scroll.
2424
+ React.useEffect(() => {
2425
+ if (!menu) return undefined
2426
+ const onDown = (event) => {
2427
+ if (menuRef.current && menuRef.current.contains(event.target)) return
2428
+ if (btnRef.current && btnRef.current.contains(event.target)) return
2429
+ setMenu(null)
2430
+ }
2431
+ const onKey = (event) => { if (event.key === 'Escape') setMenu(null) }
2432
+ // Scrolling inside the menu (its recent list) must not dismiss it.
2433
+ const onScroll = (event) => {
2434
+ if (event && menuRef.current && event.target && menuRef.current.contains(event.target)) return
2435
+ setMenu(null)
2436
+ }
2437
+ window.addEventListener('pointerdown', onDown, true)
2438
+ window.addEventListener('keydown', onKey, true)
2439
+ window.addEventListener('scroll', onScroll, true)
2440
+ window.addEventListener('resize', onScroll)
2441
+ return () => {
2442
+ window.removeEventListener('pointerdown', onDown, true)
2443
+ window.removeEventListener('keydown', onKey, true)
2444
+ window.removeEventListener('scroll', onScroll, true)
2445
+ window.removeEventListener('resize', onScroll)
2446
+ }
2447
+ }, [menu])
2448
+
2449
+ // Keep the whole menu on screen: shift it up when it would run past the fold.
2450
+ React.useEffect(() => {
2451
+ if (!menu || !menuRef.current) return
2452
+ const rect = menuRef.current.getBoundingClientRect()
2453
+ const overflow = rect.bottom - (window.innerHeight - 8)
2454
+ if (overflow > 0) {
2455
+ setMenu((current) => (current ? { x: current.x, y: Math.max(8, current.y - overflow) } : current))
2456
+ }
2457
+ }, [menu])
2458
+
2459
+ const tailOf = () => (btnRef.current && btnRef.current.closest ? btnRef.current.closest('[data-turn-tail]') : null)
2460
+
2461
+ const speak = (text, seq, tailEl) => {
2462
+ stopPlayback()
2463
+ return clientFetch('/dsh-tts/speak', {
2464
+ method: 'POST',
2465
+ headers: { 'Content-Type': 'application/json' },
2466
+ body: JSON.stringify({ text, channel: 'web' }),
2467
+ timeoutMs: 30000,
2468
+ })
2469
+ .then((r) => r.json())
2470
+ .then((d) => {
2471
+ // A second click cancels the first request; never play stale audio.
2472
+ if (seqRef.current !== seq) return false
2473
+ if (!d || !d.ok || !d.audioBase64) return false
2474
+ activeTailEl = tailEl
2475
+ playAudioItem({
2476
+ id: `reply-${Date.now()}`,
2477
+ text,
2478
+ mime: d.mime || 'audio/mpeg',
2479
+ audioBase64: d.audioBase64,
2480
+ })
2481
+ return true
2482
+ })
2483
+ .catch((failed) => {
2484
+ console.warn('[dsh-tts] reply speak failed:', failed)
2485
+ return false
2486
+ })
2487
+ }
2488
+
2489
+ const onClick = () => {
2490
+ if (menu) { setMenu(null); return }
2491
+ if (busy) {
2492
+ seqRef.current += 1
2493
+ stopPlayback()
2494
+ setBusy(false)
2495
+ return
2496
+ }
2497
+ if (active) {
2498
+ stopPlayback()
2499
+ return
2500
+ }
2501
+ const tailEl = tailOf()
2502
+ const text = replyTextFromTurn(btnRef.current)
2503
+ if (!text) {
2504
+ setWarn(true)
2505
+ setTimeout(() => setWarn(false), 1600)
2506
+ return
2507
+ }
2508
+ // Already synthesised this clip? Play it from memory instead of the network.
2509
+ const last = player.lastItem
2510
+ if (last && last.audioBase64 && last.text === text) {
2511
+ textRef.current = text
2512
+ activeTailEl = tailEl
2513
+ playAudioItem({
2514
+ id: `replay-${Date.now()}`,
2515
+ text,
2516
+ mime: last.mime || 'audio/mpeg',
2517
+ audioBase64: last.audioBase64,
2518
+ })
2519
+ return
2520
+ }
2521
+ const seq = seqRef.current + 1
2522
+ seqRef.current = seq
2523
+ textRef.current = text
2524
+ setWarn(false)
2525
+ setBusy(true)
2526
+ speak(text, seq, tailEl).then((ok) => {
2527
+ if (seqRef.current !== seq) return
2528
+ setBusy(false)
2529
+ if (!ok) {
2530
+ setWarn(true)
2531
+ setTimeout(() => setWarn(false), 1600)
2532
+ }
2533
+ })
2534
+ }
2236
2535
 
2237
- // Hide only if completely idle and no recent/favorites history (#161)
2238
- if (!active && !hasHistory && !showList && !p.blocked) return null
2536
+ const openMenu = (event) => {
2537
+ event.preventDefault()
2538
+ event.stopPropagation()
2539
+ const box = btnRef.current ? btnRef.current.getBoundingClientRect() : { left: event.clientX, bottom: event.clientY }
2540
+ const x = Math.max(8, Math.min(Math.round(box.left) - 8, window.innerWidth - 312))
2541
+ // Open just under the button; the measuring effect above shifts the menu
2542
+ // up if the (now scrollable, possibly tall) list would run past the fold.
2543
+ const y = Math.max(8, Math.min(Math.round(box.bottom) + 6, window.innerHeight - 120))
2544
+ setMenu({ x, y })
2545
+ }
2239
2546
 
2240
- const row = (text, starred) => React.createElement('div', { className: 'dts-row', key: text.slice(0, 24) + starred },
2547
+ const run = (fn) => () => { setMenu(null); fn() }
2548
+
2549
+ const label = active ? t('speakMessageStop') : t('speakMessage')
2550
+ const title = warn ? t('speakMessageEmpty') : label
2551
+ const button = React.createElement('button', {
2552
+ ref: btnRef,
2553
+ type: 'button',
2554
+ className: 'dts-act',
2555
+ title,
2556
+ 'aria-label': title,
2557
+ 'aria-pressed': active || undefined,
2558
+ 'data-active': active || undefined,
2559
+ 'data-busy': busy || undefined,
2560
+ onClick,
2561
+ onContextMenu: openMenu,
2562
+ }, speakGlyph())
2563
+
2564
+ // Playback transport lives at the end of the same row, after the clock.
2565
+ const tail = tailOf()
2566
+ const speaking = isSpeakingNow()
2567
+ const transport = speaking && ownsTransport(tail) ? React.createElement('div', { className: 'dts-transport' },
2568
+ React.createElement('button', {
2569
+ type: 'button', className: 'dts-tbtn', title: p.paused ? t('dockResume') : t('dockPause'),
2570
+ 'aria-label': p.paused ? t('dockResume') : t('dockPause'),
2571
+ onClick: () => togglePause(),
2572
+ }, transportIcon(p.paused ? 'play' : 'pause')),
2241
2573
  React.createElement('button', {
2242
- type: 'button', className: 'dts-link', style: { flex: 1, textAlign: 'left', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
2243
- title: text,
2244
- onClick: () => playStandalone(text),
2245
- }, text.slice(0, 80)),
2574
+ type: 'button', className: 'dts-tbtn', title: t('dockStop'), 'aria-label': t('dockStop'),
2575
+ onClick: () => stopPlayback(),
2576
+ }, transportIcon('stop')),
2246
2577
  React.createElement('button', {
2247
- type: 'button', className: 'dts-link', title: '★',
2248
- onClick: () => { recent.toggleFav(text); force() },
2249
- }, starred ? '★' : '☆'),
2578
+ type: 'button', className: 'dts-tbtn', title: t('dockSkip'), 'aria-label': t('dockSkip'),
2579
+ onClick: () => skipCurrent(),
2580
+ }, transportIcon('skip')),
2250
2581
  React.createElement('button', {
2251
- type: 'button', className: 'dts-link', title: t('exportAudio') || 'Export',
2252
- onClick: () => exportAudioClip(text),
2253
- }, '⤓'),
2582
+ type: 'button', className: 'dts-tbtn', title: t('exportAudio'), 'aria-label': t('exportAudio'),
2583
+ onClick: () => exportAudioClip(player.lastText),
2584
+ }, transportIcon('export')),
2585
+ ) : null
2586
+
2587
+ if (!menu) return React.createElement(React.Fragment, null, button, transport)
2588
+
2589
+ // Recents only, all of them: the menu scrolls when the list is longer than
2590
+ // the popup. Playback controls stay in the reply row, right of the clock.
2591
+ const history = (recent.list().items || [])
2592
+
2593
+ const row = (text, index) => React.createElement('button', {
2594
+ key: `recent-${index}`,
2595
+ type: 'button',
2596
+ className: 'dts-menu-text',
2597
+ title: text,
2598
+ onClick: run(() => playStandalone(text)),
2599
+ }, text.slice(0, 80))
2600
+
2601
+ const menuEl = React.createElement('div', {
2602
+ ref: menuRef,
2603
+ className: 'dts-menu',
2604
+ role: 'menu',
2605
+ style: { left: `${menu.x}px`, top: `${menu.y}px` },
2606
+ onContextMenu: (event) => { event.preventDefault(); event.stopPropagation() },
2607
+ },
2608
+ history.length
2609
+ ? React.createElement('div', {
2610
+ className: 'dts-menu-list',
2611
+ // Wheel over the list scrolls the list, not the conversation.
2612
+ onWheel: (event) => event.stopPropagation(),
2613
+ }, history.map((text, index) => row(text, index)))
2614
+ : React.createElement('div', { className: 'dts-menu-item', style: { opacity: 0.45, cursor: 'default' } },
2615
+ React.createElement('span', null, t('recentEmpty')),
2616
+ ),
2254
2617
  )
2618
+
2619
+ return React.createElement(React.Fragment, null, button, transport, menuEl)
2620
+ }
2621
+
2622
+ function registerMessageAction(ctx) {
2623
+ try {
2624
+ ctx.slots.inject('conversation.chat.assistant-actions', () => ctx.slots.register(
2625
+ {
2626
+ name: 'conversation.chat.assistant-actions',
2627
+ id: 'dsh-tts-speak',
2628
+ // 10 is the feedback thumbs; a lower order keeps us to their left.
2629
+ order: 5,
2630
+ locale: NS,
2631
+ },
2632
+ MessageSpeakAction,
2633
+ ))
2634
+ } catch (seatUnavailable) {
2635
+ console.warn('[dsh-tts] reply action seat not available:', seatUnavailable && seatUnavailable.message)
2636
+ }
2637
+ }
2638
+
2639
+ // The dock keeps only the "sound is blocked" hint now: the transport moved
2640
+ // into the reply row, to the right of the message clock.
2641
+ function SpeakerControl(props) {
2642
+ const t = (props && props.t) || fallbackDockText
2643
+ const p = usePlayer()
2644
+ if (!p.blocked) return null
2255
2645
  return React.createElement('div', { style: { position: 'relative', display: 'inline-flex', gap: '4px', alignItems: 'center' } },
2256
- p.blocked ? React.createElement('button', {
2646
+ React.createElement('button', {
2257
2647
  type: 'button', className: 'dts-link dts-warn', title: t('unlockAudio') || 'Click to unlock audio',
2258
2648
  onClick: unlockAudio,
2259
- }, '🔔') : null,
2260
- (hasHistory || active) ? React.createElement('button', {
2261
- type: 'button', className: 'dts-link', title: t('recent') || 'Recent',
2262
- onClick: () => setShowList((v) => !v),
2263
- }, '☰') : null,
2264
- showList ? React.createElement('div', {
2265
- className: 'dts-wrap',
2266
- style: { position: 'absolute', bottom: '36px', right: 0, zIndex: 30, maxHeight: '320px', overflowY: 'auto', background: 'var(--dsw-alias-bg-layer-2)', border: '1px solid var(--dsw-alias-border-l2)', borderRadius: '12px', padding: '10px 12px', minWidth: '260px', flexDirection: 'column', gap: '6px', display: 'flex' },
2267
- },
2268
- lists.favs.length ? React.createElement('div', { className: 'dts-h' }, '★') : null,
2269
- lists.favs.map((text) => row(text, true)),
2270
- React.createElement('div', { className: 'dts-h' }, '⟳'),
2271
- lists.items.filter((x) => !lists.favs.includes(x)).map((text) => row(text, false)),
2272
- !lists.items.length ? React.createElement('span', { className: 'dts-sub' }, t('recentEmpty') || 'No recent speech yet') : null,
2273
- ) : null,
2274
- active ? React.createElement(React.Fragment, null,
2275
- React.createElement('button', {
2276
- type: 'button',
2277
- className: 'dts-link',
2278
- title: p.paused ? t('dockResume') : t('dockPause'),
2279
- onClick: togglePause,
2280
- }, p.paused ? '▶' : '❚❚'),
2281
- React.createElement('button', {
2282
- type: 'button',
2283
- className: 'dts-link',
2284
- title: t('dockStop'),
2285
- onClick: stopPlayback,
2286
- }, '■'),
2287
- React.createElement('button', {
2288
- type: 'button',
2289
- className: 'dts-link',
2290
- title: t('dockSkip') || 'Skip',
2291
- onClick: skipCurrent,
2292
- }, '⏭'),
2293
- React.createElement('button', {
2294
- type: 'button',
2295
- className: 'dts-link',
2296
- title: t('exportAudio') || 'Export clip',
2297
- onClick: () => exportAudioClip(player.lastText),
2298
- }, '⤓'),
2299
- ) : null,
2649
+ }, '🔔'),
2300
2650
  )
2301
2651
  }
2302
2652
 
@@ -2416,10 +2766,20 @@ window.__ModuleLoader__.load({
2416
2766
  exports.apply = function apply(ctx) {
2417
2767
  registerSettings(ctx)
2418
2768
  registerSpeaker(ctx)
2769
+ registerMessageAction(ctx)
2419
2770
  ctx.effect(() => listenForVoice(), 'dsh-tts: barge-in on user speech')
2420
2771
  ctx.effect(() => {
2421
2772
  if (typeof window === 'undefined') return () => {}
2422
2773
  const onKey = (e) => {
2774
+ // Shift+Space: stop while speaking, otherwise replay the last clip
2775
+ // (memory first, then the host cache) so a finished reply is one key away.
2776
+ if (e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey && (e.code === 'Space' || e.key === ' ')) {
2777
+ e.preventDefault()
2778
+ if (e.repeat) return
2779
+ if (isSpeakingNow()) stopPlayback()
2780
+ else replayLast()
2781
+ return
2782
+ }
2423
2783
  if (e.ctrlKey && e.key === 'Escape') { togglePause(); e.preventDefault() }
2424
2784
  else if (e.altKey && (e.code === 'KeyS' || e.key === 's' || e.key === 'S')) { stopPlayback(); e.preventDefault() }
2425
2785
  else if (e.altKey && e.key === 'ArrowRight') { skipCurrent(); e.preventDefault() }
@@ -2429,6 +2789,9 @@ window.__ModuleLoader__.load({
2429
2789
  }, 'dsh-tts: hotkeys')
2430
2790
  ctx.effect(() => startSseStream(), 'dsh-tts: SSE audio stream')
2431
2791
  ctx.effect(() => {
2792
+ // Prime the status flag right away: autoplay used to wait out the first
2793
+ // interval tick, which dropped early stream items on a fresh start.
2794
+ pollPending()
2432
2795
  const timer = setInterval(pollPending, 1000)
2433
2796
  return () => clearInterval(timer)
2434
2797
  }, 'dsh-tts: poll pending audio')
package/lib/http-util.js CHANGED
@@ -4,7 +4,7 @@ export function writeJson(res, code, body) {
4
4
  try {
5
5
  res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' })
6
6
  res.end(JSON.stringify(body))
7
- } catch { /* socket closed */ }
7
+ } catch (_err) { /* socket closed */ }
8
8
  }
9
9
 
10
10
  export function readBody(req, maxBytes) {
@@ -23,15 +23,36 @@ export function readBody(req, maxBytes) {
23
23
 
24
24
  export function isLoopback(address) {
25
25
  if (!address || typeof address !== 'string') return false
26
+ const clean = address.toLowerCase().replace(/^\[|\]$/g, '')
26
27
  return (
27
- address === '127.0.0.1' ||
28
- address === '::1' ||
29
- address === '::ffff:127.0.0.1' ||
30
- address.startsWith('127.') ||
31
- address === 'localhost'
28
+ clean === '127.0.0.1' ||
29
+ clean === '::1' ||
30
+ clean === '::ffff:127.0.0.1' ||
31
+ clean.startsWith('127.') ||
32
+ clean === 'localhost'
32
33
  )
33
34
  }
34
35
 
36
+ export function isLanAddress(address) {
37
+ if (!address || typeof address !== 'string') return false
38
+ const clean = address.toLowerCase().replace(/^\[|\]$/g, '')
39
+ const ipv4 = clean.startsWith('::ffff:') ? clean.slice(7) : clean
40
+ return (
41
+ ipv4.startsWith('192.168.') ||
42
+ ipv4.startsWith('10.') ||
43
+ /^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(ipv4)
44
+ )
45
+ }
46
+
47
+ export function getClientIp(request) {
48
+ if (!request) return ''
49
+ const remote = request.socket?.remoteAddress || request.connection?.remoteAddress
50
+ if (remote) {
51
+ return remote.startsWith('::ffff:') ? remote.slice(7) : remote
52
+ }
53
+ return ''
54
+ }
55
+
35
56
  function parseCookies(cookieHeader) {
36
57
  if (!cookieHeader || typeof cookieHeader !== 'string') return {}
37
58
  const out = {}
@@ -47,66 +68,106 @@ function parseCookies(cookieHeader) {
47
68
  }
48
69
 
49
70
  /**
50
- * Strict fail-closed check for administrative/settings requests.
51
- * Only accepts loopback with matching origin/host, same-origin context,
52
- * or valid DSH auth tokens. Arbitrary headers, unverified tokens, and same-site requests are rejected.
71
+ * Returns undefined if the request is permitted, or an HTTP error status code (401, 403) if rejected.
72
+ *
73
+ * Evaluation order:
74
+ * 1. Base validation (fail-closed on missing headers or cross-site Sec-Fetch-Site).
75
+ * 2. Origin vs Host verification.
76
+ * 3. DSH connection service authentication (connection.requestRejection(request)).
77
+ * 4. Fallback authentication when connection service is absent (env token, loopback, etc.).
78
+ *
79
+ * @param {object} request Node HTTP request
80
+ * @param {object} [options]
81
+ * @param {object} [options.connection] DSH connection service
82
+ * @param {boolean} [options.requireAuth] require authenticated operator (fail-closed for remote)
83
+ * @param {boolean} [options.isRemote] mark in-memory/mock request as remote
84
+ * @returns {number|undefined} undefined if allowed, 401 or 403 if rejected
53
85
  */
54
- export function isTrustedSettingsRequest(request) {
55
- if (!request || !request.headers) return false
86
+ export function getRejectionStatus(request, options = {}) {
87
+ if (!request || !request.headers) return 403
56
88
 
57
- // Fail-closed: unconditionally reject cross-site and non-same-origin Sec-Fetch-Site (e.g. same-site, cross-site)
89
+ // 1. Fail-closed: unconditionally reject cross-site and non-same-origin Sec-Fetch-Site (e.g. same-site, cross-site)
58
90
  const secFetch = request.headers['sec-fetch-site']
59
- if (secFetch && secFetch !== 'same-origin') {
60
- return false
91
+ if (secFetch && secFetch !== 'same-origin' && secFetch !== 'none') {
92
+ return 403
61
93
  }
62
94
 
63
- // If origin is present, ensure it matches request host
95
+ // 2. If origin is present, ensure it matches request host
64
96
  const origin = request.headers['origin']
65
97
  const host = request.headers['host']
66
98
  if (origin && host) {
67
99
  try {
68
100
  const originHost = new URL(origin).host
69
- if (originHost !== host) return false
70
- } catch {
71
- return false
101
+ if (originHost !== host) return 403
102
+ } catch (_err) {
103
+ return 403
104
+ }
105
+ }
106
+
107
+ // 3. If DSH connection service is available, invoke requestRejection(req)
108
+ const conn = options.connection
109
+ if (conn && typeof conn.requestRejection === 'function') {
110
+ const rejection = conn.requestRejection(request)
111
+ if (rejection !== undefined && rejection !== null && rejection !== false) {
112
+ return typeof rejection === 'number' ? rejection : 401
72
113
  }
114
+ // Admitted by DSH connection service
115
+ return undefined
73
116
  }
74
117
 
75
- // Verify explicit authorization token against DSH environment tokens if defined
118
+ // 4. Verify explicit authorization token against DSH environment tokens if defined
76
119
  const expectedToken = process.env.DSH_AUTH_TOKEN || process.env.DSH_TOKEN
77
120
  const rawAuth = request.headers['authorization'] || request.headers['x-dsh-auth']
78
121
  const cookieHeader = request.headers['cookie']
79
122
 
80
123
  if (expectedToken) {
124
+ let token = ''
81
125
  if (typeof rawAuth === 'string') {
82
- const token = rawAuth.startsWith('Bearer ') ? rawAuth.slice(7).trim() : rawAuth.trim()
83
- if (token && token === expectedToken) return true
126
+ token = rawAuth.startsWith('Bearer ') ? rawAuth.slice(7).trim() : rawAuth.trim()
84
127
  }
85
- if (typeof cookieHeader === 'string') {
128
+ if (!token && typeof cookieHeader === 'string') {
86
129
  const cookies = parseCookies(cookieHeader)
87
- const token = cookies['token'] || cookies['dsh_token']
88
- if (token && token === expectedToken) return true
130
+ token = cookies['token'] || cookies['dsh_token'] || ''
131
+ }
132
+ if (token && token === expectedToken) {
133
+ return undefined
89
134
  }
135
+ return 401
90
136
  }
91
137
 
92
- // Remote IP loopback check
93
- const remoteAddr = request.socket?.remoteAddress || request.connection?.remoteAddress
138
+ // 5. Remote IP check
139
+ const remoteAddr = getClientIp(request)
94
140
  if (remoteAddr) {
95
- return isLoopback(remoteAddr)
141
+ if (isLoopback(remoteAddr)) {
142
+ return undefined
143
+ }
144
+ // Remote client (LAN or WAN) without connection auth or token:
145
+ // Fail-closed. For write / sensitive endpoints requireAuth returns 401, otherwise 403.
146
+ return options.requireAuth ? 401 : 403
96
147
  }
97
148
 
98
- // Internal/mock caller without socket: only allow if sec-fetch-site is explicitly same-origin or origin matches host
149
+ // 6. Internal/mock caller without socket:
150
+ if (options.requireAuth && options.isRemote) {
151
+ return 401
152
+ }
99
153
  if (secFetch === 'same-origin') {
100
- return true
154
+ return undefined
101
155
  }
102
-
103
156
  if (origin && host) {
104
157
  try {
105
- return new URL(origin).host === host
106
- } catch {
107
- return false
158
+ if (new URL(origin).host === host) return undefined
159
+ } catch (_err) {
160
+ return 403
108
161
  }
109
162
  }
110
163
 
111
- return false
164
+ return 403
112
165
  }
166
+
167
+ /**
168
+ * Strict fail-closed check for administrative/settings requests.
169
+ * Backward-compatible boolean wrapper around getRejectionStatus.
170
+ */
171
+ export function isTrustedSettingsRequest(request, options = {}) {
172
+ return getRejectionStatus(request, options) === undefined
173
+ }
package/lib/routes.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { MODEL_SPECS } from './engines/manager.js'
2
2
  import { resolve } from 'node:path'
3
3
  import { registerPluginUpdater } from './updater.js'
4
- import { writeJson, readBody, isTrustedSettingsRequest } from './http-util.js'
4
+ import { writeJson, readBody, getRejectionStatus, isTrustedSettingsRequest } from './http-util.js'
5
5
  import { addStreamSubscriber, removeStreamSubscriber, streamSubscriberCount } from './stream-hub.js'
6
6
  import { pendingKeyWrites, stripSecretsFromConfig, needsApiKey } from './keys.js'
7
7
  import { PROVIDER_KEYS } from './providers.js'
@@ -29,15 +29,43 @@ export function registerHttpRoutes(ctx, api) {
29
29
  validateConfig,
30
30
  breakerSnapshot,
31
31
  } = api
32
+
33
+ const resolveConnection = () => {
34
+ try {
35
+ if (api && api.connection) return api.connection
36
+ } catch (_err) { /* connection resolution fallback */ }
37
+ try {
38
+ if (ctx && typeof ctx.get === 'function') {
39
+ const c = ctx.get('connection')
40
+ if (c) return c
41
+ }
42
+ } catch (_err) { /* connection get fallback */ }
43
+ try {
44
+ if (ctx) return Reflect.get(ctx, 'connection') || ctx.connection
45
+ } catch (_err) { /* connection reflect fallback */ }
46
+ return undefined
47
+ }
48
+
49
+ const rejectIfUntrusted = (req, res, extraOptions = {}) => {
50
+ const conn = resolveConnection()
51
+ const status = getRejectionStatus(req, { connection: conn, ...extraOptions })
52
+ if (status !== undefined) {
53
+ const code = status === 401 ? 'unauthorized' : 'forbidden'
54
+ const message = status === 401
55
+ ? 'unauthorized: session or token required'
56
+ : (extraOptions.message || 'same-origin only')
57
+ writeJson(res, status, { ok: false, error: { code, message } })
58
+ return true
59
+ }
60
+ return false
61
+ }
62
+
32
63
  ctx.effect(() => ctx.webServer.register({
33
64
  kind: 'exact',
34
65
  path: '/dsh-tts/status',
35
66
  handler: async (req, res) => {
36
67
  if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
37
- if (!isTrustedSettingsRequest(req)) {
38
- writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
39
- return
40
- }
68
+ if (rejectIfUntrusted(req, res)) return
41
69
  const cfg = live()
42
70
  writeJson(res, 200, {
43
71
  ok: true,
@@ -67,7 +95,7 @@ export function registerHttpRoutes(ctx, api) {
67
95
  path: '/dsh-tts/stream',
68
96
  handler: async (req, res) => {
69
97
  if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
70
- if (!isTrustedSettingsRequest(req)) { writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } }); return }
98
+ if (rejectIfUntrusted(req, res)) return
71
99
  try {
72
100
  res.writeHead(200, {
73
101
  'Content-Type': 'text/event-stream',
@@ -88,7 +116,7 @@ export function registerHttpRoutes(ctx, api) {
88
116
  path: '/dsh-tts/models/status',
89
117
  handler: async (req, res) => {
90
118
  if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
91
- if (!isTrustedSettingsRequest(req)) { writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } }); return }
119
+ if (rejectIfUntrusted(req, res)) return
92
120
  try {
93
121
  const list = await modelManager.listStatus()
94
122
  writeJson(res, 200, { ok: true, models: list })
@@ -103,10 +131,7 @@ export function registerHttpRoutes(ctx, api) {
103
131
  path: '/dsh-tts/models/install',
104
132
  handler: async (req, res) => {
105
133
  if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } }); return }
106
- if (!isTrustedSettingsRequest(req)) {
107
- writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
108
- return
109
- }
134
+ if (rejectIfUntrusted(req, res, { requireAuth: true })) return
110
135
  let body = {}
111
136
  try {
112
137
  const raw = await readBody(req, 16 * 1024)
@@ -147,10 +172,7 @@ export function registerHttpRoutes(ctx, api) {
147
172
  writeJson(res, 405, { ok: false, error: { code: 'method', message: 'DELETE or POST only' } })
148
173
  return
149
174
  }
150
- if (!isTrustedSettingsRequest(req)) {
151
- writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
152
- return
153
- }
175
+ if (rejectIfUntrusted(req, res, { requireAuth: true })) return
154
176
  let body = {}
155
177
  try {
156
178
  const raw = await readBody(req, 16 * 1024)
@@ -179,10 +201,7 @@ export function registerHttpRoutes(ctx, api) {
179
201
  path: '/dsh-tts/integrations',
180
202
  handler: async (req, res) => {
181
203
  if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
182
- if (!isTrustedSettingsRequest(req)) {
183
- writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
184
- return
185
- }
204
+ if (rejectIfUntrusted(req, res)) return
186
205
  function isPluginOrServicePresent(id) {
187
206
  if (!ctx) return false
188
207
  try {
@@ -246,10 +265,7 @@ export function registerHttpRoutes(ctx, api) {
246
265
  writeJson(res, 405, { ok: false, error: { code: 'method', message: 'DELETE only' } })
247
266
  return
248
267
  }
249
- if (!isTrustedSettingsRequest(req)) {
250
- writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
251
- return
252
- }
268
+ if (rejectIfUntrusted(req, res, { requireAuth: true })) return
253
269
  const removed = await speechCache.clear()
254
270
  writeJson(res, 200, { ok: true, removed, cleared: removed })
255
271
  },
@@ -263,10 +279,7 @@ export function registerHttpRoutes(ctx, api) {
263
279
  writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
264
280
  return
265
281
  }
266
- if (!isTrustedSettingsRequest(req)) {
267
- writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
268
- return
269
- }
282
+ if (rejectIfUntrusted(req, res, { requireAuth: true })) return
270
283
  const removed = await speechCache.clear()
271
284
  writeJson(res, 200, { ok: true, removed, cleared: removed })
272
285
  },
@@ -280,10 +293,7 @@ export function registerHttpRoutes(ctx, api) {
280
293
  writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
281
294
  return
282
295
  }
283
- if (!isTrustedSettingsRequest(req)) {
284
- writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
285
- return
286
- }
296
+ if (rejectIfUntrusted(req, res)) return
287
297
  let body = {}
288
298
  try {
289
299
  const raw = await readBody(req, 64 * 1024)
@@ -313,10 +323,7 @@ export function registerHttpRoutes(ctx, api) {
313
323
  path: '/dsh-tts/stats',
314
324
  handler: async (req, res) => {
315
325
  if (req.method === 'GET') {
316
- if (!isTrustedSettingsRequest(req)) {
317
- writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
318
- return
319
- }
326
+ if (rejectIfUntrusted(req, res)) return
320
327
  writeJson(res, 200, { ok: true, total: stats.total, cacheHits: stats.cacheHits, errors: stats.errors, providers: stats.providers, breaker: typeof breakerSnapshot === 'function' ? breakerSnapshot() : [] })
321
328
  return
322
329
  }
@@ -324,10 +331,7 @@ export function registerHttpRoutes(ctx, api) {
324
331
  writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET or DELETE' } })
325
332
  return
326
333
  }
327
- if (!isTrustedSettingsRequest(req)) {
328
- writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
329
- return
330
- }
334
+ if (rejectIfUntrusted(req, res, { requireAuth: true })) return
331
335
  stats.total = 0
332
336
  stats.cacheHits = 0
333
337
  stats.errors = 0
@@ -340,11 +344,8 @@ export function registerHttpRoutes(ctx, api) {
340
344
  kind: 'exact',
341
345
  path: '/dsh-tts/config',
342
346
  handler: async (req, res) => {
343
- if (!isTrustedSettingsRequest(req)) {
344
- writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'dsh-tts: settings writes are same-origin only' } })
345
- return
346
- }
347
347
  if (req.method === 'GET') {
348
+ if (rejectIfUntrusted(req, res, { message: 'dsh-tts: settings writes are same-origin only' })) return
348
349
  writeJson(res, 200, await configResponse())
349
350
  return
350
351
  }
@@ -352,6 +353,7 @@ export function registerHttpRoutes(ctx, api) {
352
353
  writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET or PUT' } })
353
354
  return
354
355
  }
356
+ if (rejectIfUntrusted(req, res, { requireAuth: true, message: 'dsh-tts: settings writes are same-origin only' })) return
355
357
  const settingsApi = typeof getSettingsApi === 'function' ? getSettingsApi() : null
356
358
  if (!settingsApi) {
357
359
  writeJson(res, 503, { ok: false, error: { code: 'settings', message: 'settings not ready' } })
@@ -423,10 +425,7 @@ export function registerHttpRoutes(ctx, api) {
423
425
  writeJson(res, 405, { ok: false, error: { code: 'method', message: 'PUT or DELETE only' } })
424
426
  return
425
427
  }
426
- if (!isTrustedSettingsRequest(req)) {
427
- writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'dsh-tts: keys are same-origin only' } })
428
- return
429
- }
428
+ if (rejectIfUntrusted(req, res, { requireAuth: true, message: 'dsh-tts: keys are same-origin only' })) return
430
429
  let payload = {}
431
430
  if (req.method === 'PUT' || (req.headers['content-length'] && Number(req.headers['content-length']) > 0)) {
432
431
  let raw
@@ -463,7 +462,7 @@ export function registerHttpRoutes(ctx, api) {
463
462
  path: '/dsh-tts/pending',
464
463
  handler: async (req, res) => {
465
464
  if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
466
- if (!isTrustedSettingsRequest(req)) { writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } }); return }
465
+ if (rejectIfUntrusted(req, res)) return
467
466
  const url = new URL(req.url || '/', 'http://127.0.0.1')
468
467
  const after = url.searchParams.get('after') || ''
469
468
  let items = pending.slice()
@@ -480,7 +479,7 @@ export function registerHttpRoutes(ctx, api) {
480
479
  path: '/dsh-tts/speak',
481
480
  handler: async (req, res) => {
482
481
  if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } }); return }
483
- if (!isTrustedSettingsRequest(req)) { writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } }); return }
482
+ if (rejectIfUntrusted(req, res)) return
484
483
  let raw
485
484
  try { raw = await readBody(req, 256 * 1024) } catch (e) {
486
485
  writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } }); return
@@ -531,4 +530,4 @@ export function registerHttpRoutes(ctx, api) {
531
530
  manifestUrl: new URL('../package.json', import.meta.url),
532
531
  mutex: updaterMutex,
533
532
  }), 'dsh-tts: updater route /api/dsh-tts/update')
534
- }
533
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-tts",
3
- "version": "0.4.33",
3
+ "version": "0.4.35",
4
4
  "description": "Text-to-speech for DeepSeek Harness: speak agent replies in the Web UI with a provider fallback chain (OpenAI, ElevenLabs, Google, Azure, Groq, Deepgram, OpenRouter, Edge, Piper, eSpeak).",
5
5
  "license": "MIT",
6
6
  "type": "module",