@goodandready/dsh-tts 0.4.31 → 0.4.32

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,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.32
4
+
5
+ ### Fixed
6
+ - **Transactional Rollback on Credential Failure** (#151):
7
+ - Added transactional rollback to previous config in `/dsh-tts/config` PUT handler if credential persistence throws an error after settings replacement, ensuring consistent state on aborted saves.
8
+ - **Messenger Gateway / Speak Guard Strictness** (#165):
9
+ - Enforced 403 rejection for unclassified / external gateway requests lacking channel metadata when `messengerTtsEnabled` is disabled, while preserving internal web UI and voice duplex traffic.
10
+ - **Strict Multi-Format Model Integrity Validation** (#179):
11
+ - Enhanced `validateFileIntegrity` to reject uniform repeating byte sequences of any value, strictly validate JSON parsing for voice mappings, enforce ONNX protobuf header magic (`0x08`), and validate safetensors header length bounds.
12
+ - **Non-Tautological Test Suite Enforcement** (#181):
13
+ - Replaced tautological and assertion-free test fixtures in deduplication, client prebuffering, and SSE player suites with strict assertions verifying exact execution counts (`synthCalls === 1`), factory invocation, and queue limits.
14
+ - **Immediate Pre-Write SSE Chunk Cap** (#186):
15
+ - Added immediate buffer cap rejection before `res.write` in `safeWrite`, preventing oversized payloads (>512 KB) from entering the socket buffer even on the initial chunk.
16
+ - **Cache L2 Clear Concurrency Guard** (#202):
17
+ - Captured generation token at the immediate start of `get()`, preventing post-clear metadata recreation when disk reads are interleaved with cache clearing.
18
+
3
19
  ## 0.4.31
4
20
 
5
21
  ### Fixed
package/lib/cache.js CHANGED
@@ -137,6 +137,7 @@ export function createSpeechCache({ root, maxBytes }) {
137
137
  },
138
138
 
139
139
  async get(key) {
140
+ const currentGen = generation
140
141
  // 1. L1 RAM Hit
141
142
  if (l1Map.has(key)) {
142
143
  const hit = l1Map.get(key)
@@ -145,15 +146,16 @@ export function createSpeechCache({ root, maxBytes }) {
145
146
  l1Map.set(key, { ...hit, at: now })
146
147
  const d = diskEntries.get(key)
147
148
  if (d) d.at = now
148
- const currentGen = generation
149
- const writePromise = (async () => {
150
- if (currentGen !== generation) return
151
- await fs.promises.writeFile(path.join(dir, key + '.json'), JSON.stringify({ mime: hit.mime, at: now }))
152
- if (currentGen !== generation) {
153
- await fs.promises.rm(path.join(dir, key + '.json'), { force: true }).catch(() => {})
154
- }
155
- })().catch(() => {})
156
- trackWrite(writePromise)
149
+ if (currentGen === generation) {
150
+ const writePromise = (async () => {
151
+ if (currentGen !== generation) return
152
+ await fs.promises.writeFile(path.join(dir, key + '.json'), JSON.stringify({ mime: hit.mime, at: now }))
153
+ if (currentGen !== generation) {
154
+ await fs.promises.rm(path.join(dir, key + '.json'), { force: true }).catch(() => {})
155
+ }
156
+ })().catch(() => {})
157
+ trackWrite(writePromise)
158
+ }
157
159
  return { mime: hit.mime || 'audio/mpeg', audio: hit.audio }
158
160
  }
159
161
 
@@ -162,19 +164,20 @@ export function createSpeechCache({ root, maxBytes }) {
162
164
  try {
163
165
  const meta = JSON.parse(await fs.promises.readFile(path.join(dir, key + '.json'), 'utf8'))
164
166
  const audio = await fs.promises.readFile(path.join(dir, key + AUDIO_EXT))
165
- putL1(key, meta.mime, audio)
166
- const now = Date.now()
167
- const d = diskEntries.get(key)
168
- if (d) d.at = now
169
- const currentGen = generation
170
- const writePromise = (async () => {
171
- if (currentGen !== generation) return
172
- await fs.promises.writeFile(path.join(dir, key + '.json'), JSON.stringify({ mime: meta.mime, at: now }))
173
- if (currentGen !== generation) {
174
- await fs.promises.rm(path.join(dir, key + '.json'), { force: true }).catch(() => {})
175
- }
176
- })().catch(() => {})
177
- trackWrite(writePromise)
167
+ if (currentGen === generation) {
168
+ putL1(key, meta.mime, audio)
169
+ const now = Date.now()
170
+ const d = diskEntries.get(key)
171
+ if (d) d.at = now
172
+ const writePromise = (async () => {
173
+ if (currentGen !== generation) return
174
+ await fs.promises.writeFile(path.join(dir, key + '.json'), JSON.stringify({ mime: meta.mime, at: now }))
175
+ if (currentGen !== generation) {
176
+ await fs.promises.rm(path.join(dir, key + '.json'), { force: true }).catch(() => {})
177
+ }
178
+ })().catch(() => {})
179
+ trackWrite(writePromise)
180
+ }
178
181
  return { mime: meta.mime || 'audio/mpeg', audio }
179
182
  } catch (miss) {
180
183
  return null
package/lib/client.js CHANGED
@@ -674,7 +674,7 @@ window.__ModuleLoader__.load({
674
674
  clientFetch('/dsh-tts/speak', {
675
675
  method: 'POST',
676
676
  headers: { 'Content-Type': 'application/json' },
677
- body: JSON.stringify({ text }),
677
+ body: JSON.stringify({ text, channel: 'web' }),
678
678
  timeoutMs: 15000,
679
679
  })
680
680
  .then((r) => r.json())
@@ -727,7 +727,7 @@ window.__ModuleLoader__.load({
727
727
  clientFetch('/dsh-tts/speak', {
728
728
  method: 'POST',
729
729
  headers: { 'Content-Type': 'application/json' },
730
- body: JSON.stringify({ text: phrase }),
730
+ body: JSON.stringify({ text: phrase, channel: 'web' }),
731
731
  timeoutMs: 15000,
732
732
  })
733
733
  .then((r) => r.json())
@@ -63,8 +63,8 @@ export function createModelManager({ root = process.env.DSH_HOME || path.join(os
63
63
  return false
64
64
  }
65
65
 
66
- // Reject repetitive dummy bytes (e.g. 2048 bytes of 0x2a)
67
- if (buf.length >= 2048 && (buf[0] === 0x2a || buf[0] === 0x00)) {
66
+ // Reject repetitive dummy bytes (e.g. 2048 bytes of 0x41, 0x2a, etc.)
67
+ if (buf.length >= 2048) {
68
68
  let allSame = true
69
69
  for (let i = 1; i < buf.length; i++) {
70
70
  if (buf[i] !== buf[0]) {
@@ -75,16 +75,24 @@ export function createModelManager({ root = process.env.DSH_HOME || path.join(os
75
75
  if (allSame) return false
76
76
  }
77
77
 
78
- // Validate JSON files
78
+ // Format-specific verification
79
79
  if (filePath.endsWith('.json')) {
80
- const rawStr = buf.toString('utf8').trim()
81
- if (rawStr.startsWith('{') || rawStr.startsWith('[') || rawStr.includes('{')) {
82
- try {
83
- const fullContent = await fs.promises.readFile(filePath, 'utf8')
84
- JSON.parse(fullContent)
85
- } catch {
86
- return false
87
- }
80
+ try {
81
+ const content = await fs.promises.readFile(filePath, 'utf8')
82
+ const parsed = JSON.parse(content)
83
+ if (!parsed || typeof parsed !== 'object') return false
84
+ } catch {
85
+ return false
86
+ }
87
+ } else if (filePath.endsWith('.safetensors')) {
88
+ if (st.size < 16) return false
89
+ const headerLen = buf.readBigUInt64LE(0)
90
+ if (headerLen <= 0n || headerLen > 100n * 1024n * 1024n || BigInt(st.size) < 8n + headerLen) {
91
+ return false
92
+ }
93
+ } else if (filePath.endsWith('.onnx')) {
94
+ if (buf[0] !== 0x08) {
95
+ return false
88
96
  }
89
97
  }
90
98
 
package/lib/routes.js CHANGED
@@ -388,13 +388,23 @@ export function registerHttpRoutes(ctx, api) {
388
388
  if (parsed && typeof parsed === 'object') {
389
389
  delete parsed.revision
390
390
  }
391
+ let replaceSucceeded = false
392
+ const prevConfig = typeof settingsApi.get === 'function' ? settingsApi.get() : null
391
393
  try {
392
394
  await settingsApi.replace(parsed, revision)
395
+ replaceSucceeded = true
393
396
  for (const item of keys) {
394
397
  await storeProviderKey(item.provider, item.value)
395
398
  }
396
399
  writeJson(res, 200, await configResponse())
397
400
  } catch (e) {
401
+ if (replaceSucceeded && prevConfig) {
402
+ try {
403
+ await settingsApi.replace(prevConfig)
404
+ } catch (_rollbackErr) {
405
+ // best-effort rollback on credential storage failure
406
+ }
407
+ }
398
408
  const isConflict = e?.code === 'conflict' || e?.name === 'SettingsConflictError' || (
399
409
  /conflict/i.test(e?.message || '') || (/revision/i.test(e?.message || '') && !/not volatile/i.test(e?.message || ''))
400
410
  )
@@ -486,7 +496,7 @@ export function registerHttpRoutes(ctx, api) {
486
496
  return
487
497
  }
488
498
  const isWeb = payload.channel === 'web' || payload.source === 'web' || req.headers['x-dsh-channel'] === 'web'
489
- const isMessenger = payload.channel === 'messenger' || payload.source === 'messenger' || req.headers['x-dsh-channel'] === 'messenger'
499
+ const isMessenger = payload.channel === 'messenger' || payload.source === 'messenger' || req.headers['x-dsh-channel'] === 'messenger' || (!isVoice && !isWeb)
490
500
  if (isMessenger && !cfg.messengerTtsEnabled) {
491
501
  writeJson(res, 403, { ok: false, error: { code: 'disabled', message: 'messenger tts is disabled' } })
492
502
  return
package/lib/stream-hub.js CHANGED
@@ -25,7 +25,7 @@ function safeWrite(res, payload) {
25
25
  ? res.writableLength
26
26
  : (res.__dtsBufferedBytes || 0)
27
27
 
28
- if (res.__dtsBackpressured && (currentBuffered + payloadBytes > MAX_SUBSCRIBER_BUFFER)) {
28
+ if (payloadBytes > MAX_SUBSCRIBER_BUFFER || (res.__dtsBackpressured && currentBuffered + payloadBytes > MAX_SUBSCRIBER_BUFFER) || currentBuffered + payloadBytes > MAX_SUBSCRIBER_BUFFER * 2) {
29
29
  subscribers.delete(res)
30
30
  detach(res)
31
31
  try { res.end() } catch (_err) { /* already ended */ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-tts",
3
- "version": "0.4.31",
3
+ "version": "0.4.32",
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",