@goodandready/dsh-voice 0.9.41 → 0.9.43

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/README.md CHANGED
@@ -222,16 +222,19 @@ Open **Settings → Plugins → Plugin settings → Voice** in the Web UI:
222
222
  ### Agent Tool (`transcribe_audio`)
223
223
  Registers `transcribe_audio(file_path, language?)` in `ctx.tools`, allowing agents to analyze audio files, interview recordings, and voice notes directly from disk. Path containment is strictly enforced against allowed directories (`allowedAudioDirs`, `~/.dsh`, `tmpdir`, `cwd`) with symlink traversal escape prevention and magic-byte audio format validation.
224
224
 
225
- ### Internal HTTP Endpoints
225
+ ### Internal HTTP & WebSocket Endpoints
226
+ * `GET /dsh-voice/ws/stream` — RFC 6455 WebSocket streaming channel for real-time dictation with interim transcription previews (`{ type: 'interim', text }`) and final transcript dispatch (`{ type: 'final', text, action, provider }`). Authenticated via `hasSameOrigin` or `x-dsh-voice-token` (#282, #292).
226
227
  * `POST /dsh-voice/transcribe` — `{ dataBase64, mimeType, mode }` → `{ ok, text, provider, tookMs }`
227
228
  * `POST /dsh-voice/polish` — `{ text }` → `{ ok, text }`
228
- * `GET /dsh-voice/status` — Returns daemon status, active chains, SenseVoice and `effectiveSensevoiceProvider`.
229
+ * `GET /dsh-voice/status` — Returns daemon status, active chains, SenseVoice and `effectiveSensevoiceProvider`. Emits `sessionToken` exclusively to verified same-origin callers (#292).
229
230
  * `GET /dsh-voice/config` — Returns live plugin configuration snapshot.
230
- * `PUT /dsh-voice/config` — Updates and persists plugin configuration across network.
231
+ * `PUT /dsh-voice/config` — Updates and persists plugin configuration across network with automatic session token rotation (#292).
231
232
  * `GET/POST /dsh-voice/sensevoice-installer` — SenseVoice 1-click model installation status and trigger (protected by `isTrustedCaller`, model paths redacted).
232
233
  * `POST /api/dsh-voice/update` — Triggers plugin self-update to latest compatible npm version (protected by local caller verification and profile lock check).
233
234
  * `GET /api/dsh-voice/update` — Returns update check status (current version, latest version, updateAvailable).
234
235
 
236
+ > **Security & Quota Protections (#292, #293)**: State-changing HTTP routes and WebSocket upgrades enforce strict same-origin verification and token authentication against reverse LAN bridges (such as `dsh-lanmode`), preventing unauthorized configuration changes or quota depletion. Real-time interim WebSocket streaming processes only the latest audio chunk ($O(N)$ linear byte consumption) and restricts recognition strictly to local engines (`local-whisper`, `sensevoice`, `browser-webgpu`), ensuring zero cloud quota consumption during interim speech preview.
237
+
235
238
  ### 🔒 Profile Lock & Controlled Operator Recovery Policy
236
239
  When updating or installing plugins, DeepSeek Harness profiles coordinate concurrent operations using `<profile-dir>/package.json.lock` (for example, `~/.dsh/profiles/<profile>/package.json.lock`).
237
240
  * **Canonical Contender Policy**: The contender process never deletes or unlinks an existing lockfile. Automatic stale lock removal by contenders is prohibited to eliminate TOCTOU (Time-of-Check to Time-of-Use) race conditions and prevent unintended lockfile clobbering.
package/lib/client.js CHANGED
@@ -1498,6 +1498,7 @@ voice._chainsPromise = null
1498
1498
  voice._chainsPromise = fetch('/dsh-voice/status', { cache: 'no-store' })
1499
1499
  .then((res) => res.json())
1500
1500
  .then((data) => {
1501
+ if (data?.sessionToken && typeof window !== 'undefined') window.__dshVoiceToken = data.sessionToken
1501
1502
  if (data && typeof data.localOnly === 'boolean') {
1502
1503
  voice.settings.localOnly = data.localOnly
1503
1504
  }
@@ -1,7 +1,7 @@
1
1
  // lib/config-routes.js — Host route for plugin configuration persistence over network.
2
2
  // Solves DSH network limitation where client-side persistence is blocked in non-loopback environments (Gitea #149).
3
3
 
4
- import { writeJson, readBody, isTrustedCaller } from './http-util.js'
4
+ import { writeJson, readBody, isTrustedCaller, rotateVoiceSessionToken, getVoiceSessionToken } from './http-util.js'
5
5
 
6
6
  export function registerConfigRoutes(ctx, {
7
7
  NS,
@@ -153,11 +153,12 @@ export function registerConfigRoutes(ctx, {
153
153
  triggerAutostart()
154
154
  }
155
155
 
156
+ rotateVoiceSessionToken()
156
157
  let newRev
157
158
  try {
158
159
  newRev = settingsService?.describe?.()?.find?.((r) => r.ns === NS)?.revision
159
160
  } catch {}
160
- writeJson(res, 200, { ok: true, config: live(), revision: newRev })
161
+ writeJson(res, 200, { ok: true, config: live(), revision: newRev, sessionToken: getVoiceSessionToken() })
161
162
  } catch (err) {
162
163
  writeJson(res, 500, { ok: false, error: { code: 'save', message: String(err?.message || err) } })
163
164
  }
@@ -1,6 +1,6 @@
1
1
  // HTTP host routes registered on ctx.webServer.
2
2
  import os from 'node:os'
3
- import { readBody, isLoopback, isTrustedCaller, getVoiceSessionToken } from './http-util.js'
3
+ import { readBody, isLoopback, isTrustedCaller, getVoiceSessionToken, verifyVoiceSessionToken, hasSameOrigin } from './http-util.js'
4
4
  import { resolveSensevoiceProvider } from './sensevoice-installer.js'
5
5
  import { applyVoiceMacros } from './macros.js'
6
6
  import { createSyntheticWav } from './wav.js'
@@ -46,7 +46,7 @@ export function registerHostRoutes(ctx, deps) {
46
46
  const sensevoiceError = sensevoiceOk ? null : getSensevoiceError()
47
47
  writeJson(res, 200, {
48
48
  ok: true,
49
- sessionToken: getVoiceSessionToken(),
49
+ sessionToken: hasSameOrigin(req) ? getVoiceSessionToken() : null,
50
50
  whisperRunning: whisperOk,
51
51
  whisperError: whisperOk ? null : whisperError,
52
52
  sensevoiceRunning: sensevoiceOk,
@@ -385,7 +385,12 @@ export function registerHostRoutes(ctx, deps) {
385
385
  const url = new URL(req.url || '/', 'http://127.0.0.1')
386
386
  const token = req.headers['x-dsh-voice-token'] || url.searchParams.get('token')
387
387
  const expected = getVoiceSessionToken()
388
- if (expected && token !== expected) {
388
+ if (!hasSameOrigin(req) && (!token || !verifyVoiceSessionToken(token))) {
389
+ socket.write('HTTP/1.1 403 Forbidden\r\n\r\n')
390
+ socket.destroy()
391
+ return
392
+ }
393
+ if (expected && token && !verifyVoiceSessionToken(token)) {
389
394
  socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n')
390
395
  socket.destroy()
391
396
  return
@@ -409,14 +414,33 @@ export function registerHostRoutes(ctx, deps) {
409
414
  streamChunks.push(Buffer.from(msg.dataBase64, 'base64'))
410
415
  } else if (msg.type === 'interim') {
411
416
  if (streamChunks.length > 0) {
412
- const buf = Buffer.concat(streamChunks)
413
- if (buf.length > 3000) {
414
- try {
415
- const interimOut = await transcribe(buf, streamMime, streamMode)
416
- if (interimOut && interimOut.text) {
417
- ws.send(JSON.stringify({ type: 'interim', text: interimOut.text }))
417
+ const latestChunk = streamChunks[streamChunks.length - 1]
418
+ if (latestChunk && latestChunk.length > 3000) {
419
+ const cfg = live()
420
+ const baseModeCfg = streamMode === 'message' ? cfg.message : cfg.dictation
421
+ const LOCAL_ENGINES = new Set(['local-whisper', 'sensevoice', 'browser-webgpu'])
422
+ const interimChain = (baseModeCfg.chain || []).filter((e) => LOCAL_ENGINES.has(e.provider))
423
+
424
+ let canRunLocal = false
425
+ for (const entry of interimChain) {
426
+ if (entry.provider === 'local-whisper' && (await whisperAlive())) { canRunLocal = true; break }
427
+ if (entry.provider === 'sensevoice' && (await sensevoiceAlive())) { canRunLocal = true; break }
428
+ if (entry.provider === 'browser-webgpu') { canRunLocal = true; break }
429
+ }
430
+
431
+ if (canRunLocal && interimChain.length > 0) {
432
+ const interimModeCfg = { ...baseModeCfg, chain: interimChain, polish: false }
433
+ const controller = new AbortController()
434
+ const timer = setTimeout(() => controller.abort(), 5000)
435
+ try {
436
+ const interimOut = await transcribe(interimModeCfg, latestChunk, streamMime, controller.signal)
437
+ if (interimOut && interimOut.text) {
438
+ ws.send(JSON.stringify({ type: 'interim', text: interimOut.text }))
439
+ }
440
+ } catch (err) { /* ignore interim failure */ } finally {
441
+ clearTimeout(timer)
418
442
  }
419
- } catch (_) {}
443
+ }
420
444
  }
421
445
  }
422
446
  } else if (msg.type === 'finish') {
@@ -426,11 +450,15 @@ export function registerHostRoutes(ctx, deps) {
426
450
  ws.send(JSON.stringify({ type: 'final', text: '', ok: true }))
427
451
  return
428
452
  }
453
+ const cfg = live()
454
+ const modeCfg = streamMode === 'message' ? cfg.message : cfg.dictation
455
+ const controller = new AbortController()
456
+ const timer = setTimeout(() => controller.abort(), cfg.timeoutMs || 25000)
429
457
  try {
430
- const finalOut = await transcribe(buf, streamMime, streamMode)
458
+ const finalOut = await transcribe(modeCfg, buf, streamMime, controller.signal)
431
459
  let text = finalOut && finalOut.text ? finalOut.text : ''
432
460
  let action = null
433
- if (extractVoiceActions) {
461
+ if (extractVoiceActions && cfg.voiceActions !== false) {
434
462
  const act = extractVoiceActions(text)
435
463
  if (act.action) { action = act.action; text = act.text }
436
464
  }
@@ -443,9 +471,11 @@ export function registerHostRoutes(ctx, deps) {
443
471
  type: 'error',
444
472
  error: String(err && err.message ? err.message : err), ok: false,
445
473
  }))
474
+ } finally {
475
+ clearTimeout(timer)
446
476
  }
447
477
  }
448
- } catch (_) {}
478
+ } catch (err) { /* ignore parse error */ }
449
479
  }
450
480
  })
451
481
  })
package/lib/http-util.js CHANGED
@@ -22,6 +22,11 @@ export function setVoiceSessionToken(t) {
22
22
  sessionSecret = String(t || '')
23
23
  }
24
24
 
25
+ export function rotateVoiceSessionToken() {
26
+ sessionSecret = randomBytes(24).toString('hex')
27
+ return sessionSecret
28
+ }
29
+
25
30
  export function verifyVoiceSessionToken(token) {
26
31
  if (!token || typeof token !== 'string') return false
27
32
  const secret = getVoiceSessionToken()
@@ -97,15 +102,156 @@ export function readBody(req, maxBytes) {
97
102
  export function isLoopback(addr) {
98
103
  if (!addr) return false
99
104
  const clean = String(addr).replace(/^::ffff:/, '')
100
- return clean === '127.0.0.1' || clean === '::1' || clean === 'localhost'
105
+ return clean === '127.0.0.1' || clean === '::1' || clean === 'localhost' || clean === '[::1]'
106
+ }
107
+
108
+ /**
109
+ * Check whether an incoming HTTP request is from the DeepSeek desktop shell (#12, #295).
110
+ * Matches patterns from @deepseek-ai/dsh-desktop and official DSH shell wrappers.
111
+ * @param {import('node:http').IncomingMessage} req
112
+ * @returns {boolean}
113
+ */
114
+ export function isDesktopShellRequest(req) {
115
+ if (!req || !req.headers) return false
116
+ const ua = String(req.headers['user-agent'] || '')
117
+ const cookie = String(req.headers.cookie || '')
118
+ if (ua.includes('@deepseek-ai/dsh-desktop/') && cookie.includes('dsh-auth-')) return true
119
+ const remote = req.socket?.remoteAddress || req.connection?.remoteAddress || ''
120
+ if (ua.includes('dsh-desktop') && isLoopback(remote)) return true
121
+ return false
122
+ }
123
+
124
+ /**
125
+ * Check whether an incoming HTTP request carries a DeepSeek Harness session cookie (#295).
126
+ * DSH core sets 'dsh-auth-<hash>=v1...' as a SameSite=Strict HttpOnly cookie.
127
+ * @param {import('node:http').IncomingMessage} req
128
+ * @returns {boolean}
129
+ */
130
+ export function hasDshAuthCookie(req) {
131
+ if (!req || !req.headers) return false
132
+ const cookie = String(req.headers.cookie || '')
133
+ return cookie.includes('dsh-auth-')
134
+ }
135
+
136
+ /**
137
+ * Split host header or URL host into lowercase hostname and port string.
138
+ * @param {string} rawHost
139
+ * @returns {{ hostname: string, port: string }}
140
+ */
141
+ function parseHost(rawHost) {
142
+ if (!rawHost) return { hostname: '', port: '' }
143
+ const clean = String(rawHost).trim().toLowerCase()
144
+ const ipv6Match = clean.match(/^\[([^\]]+)\](?::(\d+))?$/)
145
+ let hostname = ''
146
+ let port = ''
147
+ if (ipv6Match) {
148
+ hostname = ipv6Match[1]
149
+ port = ipv6Match[2] || ''
150
+ } else {
151
+ const parts = clean.split(':')
152
+ hostname = parts[0]
153
+ port = parts[1] || ''
154
+ }
155
+ if (port === '80' || port === '443') {
156
+ port = ''
157
+ }
158
+ return { hostname, port }
159
+ }
160
+
161
+ /**
162
+ * Verify whether an Origin or Referer URL is allowed for the given Host header (#12, #292, #295).
163
+ * @param {string} urlStr
164
+ * @param {string} hostHeader
165
+ * @param {import('node:http').IncomingMessage} req
166
+ * @returns {boolean}
167
+ */
168
+ export function isOriginOrRefererAllowed(urlStr, hostHeader, req) {
169
+ if (!urlStr || typeof urlStr !== 'string') return false
170
+ const trimmed = urlStr.trim()
171
+ if (!trimmed) return false
172
+
173
+ const remote = req?.socket?.remoteAddress || req?.connection?.remoteAddress || ''
174
+ const remoteIsLoopback = isLoopback(remote)
175
+
176
+ // 1. Literal 'null' origin (sent by Chromium/Electron for local files or sandboxed views)
177
+ if (trimmed === 'null') {
178
+ return remoteIsLoopback && (isDesktopShellRequest(req) || hasDshAuthCookie(req))
179
+ }
180
+
181
+ try {
182
+ const u = new URL(trimmed)
183
+
184
+ // 2. Official DeepSeek desktop custom app scheme (e.g. dsh-app://app or dsh-app://localhost)
185
+ if (u.protocol === 'dsh-app:') {
186
+ const appHost = u.host.toLowerCase()
187
+ const allowedAppHost = !appHost || appHost === 'app' || appHost === 'localhost'
188
+ return allowedAppHost && remoteIsLoopback
189
+ }
190
+
191
+ // 3. Web schemes: HTTP and HTTPS
192
+ if (u.protocol === 'http:' || u.protocol === 'https:') {
193
+ if (!hostHeader) return false
194
+ const target = parseHost(hostHeader)
195
+ const origin = parseHost(u.host)
196
+
197
+ // Exact match
198
+ if (origin.hostname === target.hostname && origin.port === target.port) {
199
+ return true
200
+ }
201
+
202
+ // Loopback equivalence (e.g. localhost:3080 vs 127.0.0.1:3080 or ::1:3080)
203
+ if (isLoopback(origin.hostname) && isLoopback(target.hostname) && origin.port === target.port) {
204
+ return true
205
+ }
206
+ }
207
+
208
+ return false
209
+ } catch {
210
+ return false
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Check whether a request originates from the same origin as the Host header or trusted desktop shell (#12, #292, #295).
216
+ * @param {import('node:http').IncomingMessage} req
217
+ * @returns {boolean}
218
+ */
219
+ export function hasSameOrigin(req) {
220
+ if (!req || !req.headers) return false
221
+ const secSite = String(req.headers['sec-fetch-site'] || '').toLowerCase()
222
+ if (secSite === 'cross-site' || secSite === 'same-site') return false
223
+
224
+ const host = String(req.headers.host || '').trim().toLowerCase()
225
+ if (!host) return false
226
+
227
+ const origin = String(req.headers.origin || '').trim()
228
+ if (origin) {
229
+ return isOriginOrRefererAllowed(origin, host, req)
230
+ }
231
+
232
+ const referer = String(req.headers.referer || '').trim()
233
+ if (referer) {
234
+ return isOriginOrRefererAllowed(referer, host, req)
235
+ }
236
+
237
+ // Same-origin metadata from browser Fetch API
238
+ if (secSite === 'same-origin') return true
239
+
240
+ // DeepSeek Desktop shell or authenticated DSH session on loopback/same-host
241
+ const remote = req.socket?.remoteAddress || req.connection?.remoteAddress || ''
242
+ if (isDesktopShellRequest(req)) return true
243
+ if (hasDshAuthCookie(req) && (isLoopback(remote) || host)) return true
244
+
245
+ return false
101
246
  }
102
247
 
103
248
  /**
104
249
  * Verify that the HTTP request originates from a trusted caller.
105
- * Fail-closed security guard for state-changing or quota-consuming POST routes (#117, #173, #277).
106
- * - Loopback callers (127.0.0.1, ::1) are trusted unless rejected by browser security headers.
107
- * - Non-loopback LAN mutating/quota-consuming POST/PUT requests require valid session token (#277).
250
+ * Fail-closed security guard for state-changing or quota-consuming routes (#117, #173, #277, #292, #295).
108
251
  * - Routes marked loopbackOnly strictly reject non-loopback callers.
252
+ * - Mutating requests (POST, PUT, PATCH) or WS upgrades require validated same-origin or valid session token (#12, #292, #295).
253
+ * Raw LAN callers or reverse bridges without Origin/Referer/Cookie and without token are fail-closed rejected.
254
+ * - Non-mutating GET requests require loopback or same-origin.
109
255
  * @param {import('node:http').IncomingMessage} req
110
256
  * @param {{ loopbackOnly?: boolean }} [options]
111
257
  * @returns {boolean}
@@ -117,32 +263,24 @@ export function isTrustedCaller(req, options = {}) {
117
263
  const secSite = String(req.headers?.['sec-fetch-site'] || '').toLowerCase()
118
264
  if (secSite === 'cross-site' || secSite === 'same-site') return false
119
265
 
120
- // 2. If Origin header is present, it must strictly match the Host header
121
266
  const host = String(req.headers?.host || '').toLowerCase()
122
- const origin = String(req.headers?.origin || '').trim().toLowerCase()
267
+
268
+ // 2. If Origin header is present, verify against host or trusted desktop scheme (#12, #292, #295)
269
+ const origin = String(req.headers?.origin || '').trim()
123
270
  if (origin) {
124
- try {
125
- const u = new URL(origin)
126
- if (u.host !== host) return false
127
- } catch {
128
- return false
129
- }
271
+ if (!isOriginOrRefererAllowed(origin, host, req)) return false
130
272
  }
131
273
 
132
- // 3. If Referer header is present, it must strictly match the Host header
133
- const referer = String(req.headers?.referer || '').trim().toLowerCase()
274
+ // 3. If Referer header is present, verify against host or trusted desktop scheme (#12, #292, #295)
275
+ const referer = String(req.headers?.referer || '').trim()
134
276
  if (referer) {
135
- try {
136
- const u = new URL(referer)
137
- if (u.host !== host) return false
138
- } catch {
139
- return false
140
- }
277
+ if (!isOriginOrRefererAllowed(referer, host, req)) return false
141
278
  }
142
279
 
143
280
  // 4. For state-changing or data-carrying requests (POST/PUT/PATCH), require application/json or octet-stream
144
281
  const method = String(req.method || 'GET').toUpperCase()
145
- if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
282
+ const isMutating = method === 'POST' || method === 'PUT' || method === 'PATCH'
283
+ if (isMutating) {
146
284
  const contentType = String(req.headers?.['content-type'] || '').toLowerCase()
147
285
  const isJson = contentType.startsWith('application/json')
148
286
  const isOctet = contentType.startsWith('application/octet-stream')
@@ -151,39 +289,46 @@ export function isTrustedCaller(req, options = {}) {
151
289
  }
152
290
  }
153
291
 
154
- // 5. Loopback remote address is trusted when not rejected by browser headers above
155
292
  const remote = req.socket?.remoteAddress || req.connection?.remoteAddress || ''
156
- if (isLoopback(remote)) return true
293
+ const remoteIsLoopback = isLoopback(remote)
294
+ const hostName = host.replace(/:\d+$/, '')
295
+ const hostIsLoopback = isLoopback(hostName)
296
+ const isPureLocal = remoteIsLoopback && hostIsLoopback
157
297
 
158
- // If route strictly requires loopback (e.g. host model installer or hardware benchmark)
159
- if (options && options.loopbackOnly) return false
298
+ // 5. If route strictly requires loopback (e.g. host model installer or hardware benchmark)
299
+ if (options && options.loopbackOnly) {
300
+ if (!isPureLocal) return false
301
+ return true
302
+ }
160
303
 
161
- // 6. Non-loopback LAN requests: POST/PUT/PATCH quota-burning operations require session auth token (#277)
162
- const isMutating = method === 'POST' || method === 'PUT' || method === 'PATCH'
304
+ // 6. Mutating requests (POST/PUT/PATCH):
305
+ // Even from loopback (which may be proxied via dsh-lanmode bridge), requests without Origin/Referer
306
+ // must present a valid x-dsh-voice-token (#292).
307
+ // Non-loopback LAN callers strictly require a valid session token to prevent forged LAN mutations (#277).
163
308
  if (isMutating) {
164
309
  const incomingToken = String(req.headers?.['x-dsh-voice-token'] || req.headers?.['x-voice-token'] || '')
165
- if (incomingToken && verifyVoiceSessionToken(incomingToken)) {
166
- return true
310
+ const hasValidToken = Boolean(incomingToken && verifyVoiceSessionToken(incomingToken))
311
+
312
+ if (!remoteIsLoopback) {
313
+ // Non-loopback LAN callers: always require valid token (#277)
314
+ return hasValidToken
167
315
  }
316
+
317
+ // Loopback callers (local browser, desktop shell, or reverse bridge dsh-lanmode):
318
+ // If request has verified same-origin or desktop shell, allow it (#12, #295).
319
+ if (hasSameOrigin(req)) return true
320
+ // If request has valid session token, allow it.
321
+ if (hasValidToken) return true
322
+ // Requests without Origin/Referer and without token: strictly reject (#292)
168
323
  return false
169
324
  }
170
325
 
171
- // 7. Non-state-changing GET requests from LAN: verify same-origin
326
+ // 7. Loopback remote address for non-mutating GET requests
327
+ if (remoteIsLoopback) return true
328
+
329
+ // 8. Non-loopback LAN GET requests: require same-origin or authenticated session
172
330
  if (secSite === 'same-origin') return true
173
- if (origin) {
174
- try {
175
- const u = new URL(origin)
176
- if (u.host === host) return true
177
- } catch { /* invalid url */ }
178
- return false
179
- }
180
- if (referer) {
181
- try {
182
- const u = new URL(referer)
183
- if (u.host === host) return true
184
- } catch { /* invalid url */ }
185
- return false
186
- }
331
+ if (hasSameOrigin(req)) return true
187
332
 
188
333
  return false
189
334
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-voice",
3
- "version": "0.9.41",
3
+ "version": "0.9.43",
4
4
  "description": "Voice input for DeepSeek Harness: dictation chunked by pauses and voice messages, each with its own provider fallback chain (Deepgram, Groq, HuggingFace, local whisper.cpp, plus any OpenAI-compatible API of your own).",
5
5
  "license": "MIT",
6
6
  "type": "module",