profileShare

rasmusjy / voicetask

Read-only snapshot

No repository description.

main default branch 105 files Expires Sep 13, 2026, 9:06 AM
config.ts 2,826 bytes
1 import type { FastifyInstance } from 'fastify'
2 import { ConfigUpdateRequestSchema, type ConfigUpdateResponse } from '../../shared/types'
3 import { applyConfig, configStatus, readEnvKey } from '../config/runtimeConfig'
4
5 export interface ConfigRouteDeps {
6 demoLocked: boolean
7 envPath?: string
8 env?: NodeJS.ProcessEnv
9 /** A public instance answers with its mode but never lets anyone change it. */
10 publicDemo?: boolean
11 }
12
13 // Keys are writable from the app, so only the local UI may post here. A page on
14 // another origin gets a browser-supplied Origin that never matches.
15 export function isTrustedOrigin(origin: string | undefined, host: string | undefined): boolean {
16 if (!origin) return true
17 let url: URL
18 try {
19 url = new URL(origin)
20 } catch {
21 return false
22 }
23 const hostname = url.hostname.replace(/^\[|\]$/g, '')
24 if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') return true
25 return host !== undefined && url.host === host
26 }
27
28 export function registerConfigRoutes(app: FastifyInstance, deps: ConfigRouteDeps): void {
29 const env = deps.env ?? process.env
30
31 app.get('/api/config', async () => configStatus(deps.demoLocked, env))
32
33 app.post('/api/config', async (request, reply) => {
34 // Same-origin is the right test for a local app, but on a public host every
35 // visitor is same-origin, and this route writes .env.
36 if (deps.publicDemo === true) {
37 return reply.code(403).send({ error: 'this is a public demo and always runs on offline mock providers' })
38 }
39 if (!isTrustedOrigin(request.headers.origin, request.headers.host)) {
40 return reply.code(403).send({ error: 'settings can only be changed from VoiceTask on this computer' })
41 }
42
43 const parsed = ConfigUpdateRequestSchema.safeParse(request.body)
44 if (!parsed.success) {
45 return reply.code(400).send({ error: parsed.error.issues[0]?.message ?? 'invalid settings' })
46 }
47
48 if (parsed.data.mode === 'real') {
49 const anthropic = parsed.data.anthropicKey ?? readEnvKey('ANTHROPIC_API_KEY', env)
50 const openai = parsed.data.openaiKey ?? readEnvKey('OPENAI_API_KEY', env)
51 const missing: string[] = []
52 if (!anthropic) missing.push('an Anthropic key for the interview')
53 if (!openai) missing.push('an OpenAI key for speech-to-text')
54 if (missing.length > 0) {
55 return reply.code(400).send({ error: `Still need ${missing.join(' and ')}.` })
56 }
57 }
58
59 let result: ConfigUpdateResponse
60 try {
61 result = applyConfig(parsed.data, { demoLocked: deps.demoLocked, envPath: deps.envPath, env })
62 } catch (err) {
63 const detail = err instanceof Error ? err.message : 'unknown error'
64 return reply.code(500).send({ error: `Could not save the settings file: ${detail}` })
65 }
66
67 return reply.send(result)
68 })
69 }
70