@goodandready/dsh-tts 0.4.34 → 0.4.36

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,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.36 (2026-10-23)
4
+
5
+ ### Fixed
6
+ - **Edge TTS Transient Error Retry** (#212, GitHub #4):
7
+ - Retries transient `edge_tts.exceptions.NoAudioReceived` errors and empty audio payloads (`< 32` bytes) up to 3 times before failing or tripping circuit breaker.
8
+ - Automatically cleans up corrupt/empty audio artifacts between retry attempts.
9
+ - Honors `req.signal` (AbortSignal) cancellation during backoff delays without hanging.
10
+ - Fails fast on `ENOENT` / missing binary without wasting retry cycles.
11
+
12
+ ## 0.4.35 (2026-10-20)
13
+
14
+ ### Fixed
15
+ - **Authentication Guard for Administrative and Credential Routes** (#210):
16
+ - Enforced DSH core connection authentication (`connection.requestRejection(req)`) across all `/dsh-tts/*` HTTP endpoints.
17
+ - 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`).
18
+ - Permitted authenticated operators via DSH connection service to manage provider credentials and configuration from LAN or remote interfaces.
19
+ - Added helpers `getClientIp(req)`, `isLanAddress(address)`, and `getRejectionStatus(req, options)` with comprehensive unit test coverage.
20
+
3
21
  ## 0.4.34 (2026-10-17)
4
22
 
5
23
  - **Per-Reply Speak Action** (#208, GitHub PR #2):
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/local.js CHANGED
@@ -3,23 +3,47 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
3
3
  import os from 'node:os'
4
4
  import path from 'node:path'
5
5
 
6
- function spawnCollect(bin, args, { stdinText, timeoutMs } = {}) {
6
+ function spawnCollect(bin, args, { stdinText, timeoutMs, signal, spawnImpl = spawn } = {}) {
7
7
  return new Promise((resolve, reject) => {
8
- const proc = spawn(bin, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true })
8
+ if (signal?.aborted) {
9
+ reject(signal.reason instanceof Error ? signal.reason : new Error('aborted'))
10
+ return
11
+ }
12
+ const proc = spawnImpl === spawn
13
+ ? spawn(bin, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true })
14
+ : spawnImpl(bin, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true })
9
15
  const out = []
10
16
  const err = []
11
17
  const timer = setTimeout(() => {
12
- try { proc.kill('SIGKILL') } catch { /* gone */ }
18
+ try { proc.kill('SIGKILL') } catch (_err) { /* process already exited */ }
13
19
  reject(new Error(`${bin} timed out`))
14
20
  }, timeoutMs || 60000)
15
- proc.stdout.on('data', (c) => out.push(c))
16
- proc.stderr.on('data', (c) => err.push(c))
17
- proc.on('error', (e) => {
21
+
22
+ let onAbort = null
23
+ if (signal) {
24
+ onAbort = () => {
25
+ clearTimeout(timer)
26
+ try { proc.kill('SIGKILL') } catch (_err) { /* process already exited */ }
27
+ reject(signal.reason instanceof Error ? signal.reason : new Error('aborted'))
28
+ }
29
+ signal.addEventListener('abort', onAbort, { once: true })
30
+ }
31
+
32
+ const cleanup = () => {
18
33
  clearTimeout(timer)
34
+ if (signal && onAbort) {
35
+ try { signal.removeEventListener('abort', onAbort) } catch (_err) { /* abort listener already removed */ }
36
+ }
37
+ }
38
+
39
+ proc.stdout?.on('data', (c) => out.push(c))
40
+ proc.stderr?.on('data', (c) => err.push(c))
41
+ proc.on('error', (e) => {
42
+ cleanup()
19
43
  reject(new Error(`${bin} unavailable: ${e.message}`))
20
44
  })
21
45
  proc.on('close', (code) => {
22
- clearTimeout(timer)
46
+ cleanup()
23
47
  const stdout = Buffer.concat(out)
24
48
  const stderr = Buffer.concat(err).toString('utf8').slice(0, 240)
25
49
  if (code !== 0) {
@@ -29,10 +53,10 @@ function spawnCollect(bin, args, { stdinText, timeoutMs } = {}) {
29
53
  resolve(stdout)
30
54
  })
31
55
  if (stdinText != null) {
32
- proc.stdin.on('error', () => {})
33
- proc.stdin.end(Buffer.from(String(stdinText), 'utf8'))
56
+ proc.stdin?.on('error', () => {})
57
+ proc.stdin?.end(Buffer.from(String(stdinText), 'utf8'))
34
58
  } else {
35
- proc.stdin.end()
59
+ proc.stdin?.end?.()
36
60
  }
37
61
  })
38
62
  }
@@ -64,24 +88,82 @@ export async function piperSpeak(text, { bin, model, timeoutMs }) {
64
88
  }
65
89
  }
66
90
 
67
- export async function edgeSpeak(text, { bin, voice, timeoutMs }) {
91
+ export async function edgeSpeak(text, options = {}) {
92
+ const {
93
+ bin,
94
+ voice,
95
+ timeoutMs,
96
+ retries = 3,
97
+ retryDelayMs = 350,
98
+ signal,
99
+ spawnImpl = spawn,
100
+ } = options
101
+
102
+ const maxAttempts = Math.max(1, Number(retries) || 1)
68
103
  const dir = await mkdtemp(path.join(os.tmpdir(), 'dsh-tts-edge-'))
69
104
  const outFile = path.join(dir, 'out.mp3')
105
+ let lastError = null
106
+
70
107
  try {
71
- await spawnCollect(bin || 'edge-tts', [
72
- '--voice', voice || 'ru-RU-SvetlanaNeural',
73
- '--text', text,
74
- '--write-media', outFile,
75
- ], { timeoutMs })
76
- const bytes = await readFile(outFile)
77
- if (!bytes || bytes.length < 32) throw new Error('edge-tts produced empty audio')
78
- return { audio: bytes, mime: 'audio/mpeg' }
108
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
109
+ if (signal?.aborted) {
110
+ throw signal.reason instanceof Error ? signal.reason : new Error('aborted')
111
+ }
112
+ try {
113
+ await rm(outFile, { force: true })
114
+ } catch (_err) { /* ignore cleanup error */ }
115
+
116
+ try {
117
+ await spawnCollect(bin || 'edge-tts', [
118
+ '--voice', voice || 'ru-RU-SvetlanaNeural',
119
+ '--text', text,
120
+ '--write-media', outFile,
121
+ ], { timeoutMs, signal, spawnImpl })
122
+
123
+ let bytes = null
124
+ try {
125
+ bytes = await readFile(outFile)
126
+ } catch (_readErr) {
127
+ throw new Error('edge-tts produced no output file')
128
+ }
129
+
130
+ if (!bytes || bytes.length < 32) {
131
+ throw new Error('edge-tts produced empty audio')
132
+ }
133
+
134
+ return { audio: bytes, mime: 'audio/mpeg' }
135
+ } catch (err) {
136
+ lastError = err
137
+ if (signal?.aborted) throw err
138
+
139
+ const errMsg = String((err && err.message) || err)
140
+ if (/ENOENT|not found/i.test(errMsg)) {
141
+ throw err
142
+ }
143
+
144
+ if (attempt < maxAttempts) {
145
+ const delay = typeof retryDelayMs === 'number' ? retryDelayMs * attempt : 350
146
+ if (delay > 0) {
147
+ await new Promise((resolve, reject) => {
148
+ const timer = setTimeout(resolve, delay)
149
+ if (signal) {
150
+ const onAbort = () => {
151
+ clearTimeout(timer)
152
+ reject(signal.reason instanceof Error ? signal.reason : new Error('aborted'))
153
+ }
154
+ signal.addEventListener('abort', onAbort, { once: true })
155
+ }
156
+ })
157
+ }
158
+ }
159
+ }
160
+ }
161
+ throw lastError || new Error('edge-tts failed after retries')
79
162
  } finally {
80
163
  await rm(dir, { recursive: true, force: true })
81
164
  }
82
165
  }
83
166
 
84
-
85
167
  /**
86
168
  * MiniMax via their official CLI utility.
87
169
  *
@@ -105,4 +187,4 @@ export async function minimaxSpeak(text, { bin, voice, model, timeoutMs }) {
105
187
  } finally {
106
188
  await rm(dir, { recursive: true, force: true })
107
189
  }
108
- }
190
+ }
@@ -12,10 +12,21 @@ export function makeLocalProviders(deps, req) {
12
12
  const { cfg, modelManager } = deps
13
13
  const { text, lang, models, voices } = req
14
14
 
15
- async function edge() {
15
+ async function edge(extraOptions = {}) {
16
16
  try {
17
17
  const voice = pick(DEFAULT_VOICES, voices, 'edge') || pick(DEFAULT_MODELS, models, 'edge')
18
- const out = await edgeSpeak(text, { bin: cfg.edgeBin, voice, timeoutMs: cfg.timeoutMs })
18
+ const retries = typeof extraOptions.retries === 'number'
19
+ ? extraOptions.retries
20
+ : (typeof cfg.edgeRetries === 'number' ? cfg.edgeRetries : 3)
21
+ const out = await edgeSpeak(text, {
22
+ bin: cfg.edgeBin,
23
+ voice,
24
+ timeoutMs: cfg.timeoutMs,
25
+ retries,
26
+ retryDelayMs: extraOptions.retryDelayMs,
27
+ signal: req.signal,
28
+ spawnImpl: extraOptions.spawnImpl || req.edgeSpawnImpl,
29
+ })
19
30
  return { ok: true, provider: 'edge', audio: out.audio, mime: out.mime }
20
31
  } catch (e) {
21
32
  return { ok: false, provider: 'edge', reason: String(e && e.message || e) }
@@ -96,4 +107,4 @@ export function makeLocalProviders(deps, req) {
96
107
  }
97
108
 
98
109
  return { kokoro, f5, edge, piper, espeak, minimax }
99
- }
110
+ }
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.34",
3
+ "version": "0.4.36",
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",