profileShare

rasmusjy / voicetask

Read-only snapshot

No repository description.

main default branch 105 files Expires Sep 13, 2026, 9:06 AM

Commit

Add API keys from the app instead of the terminal

The server no longer exits when keys are missing. It starts, reports an unconfigured state, and the home screen asks for the two keys or the offline demo. Keys are merged into the local .env and applied to the running process, so no restart is needed. A server started with npm run demo saves keys but stays on mocks.
commit 3e4c5e6

27 changed files with +997 and −117

Jump to a changed file
  1. .env.example +4 −2
  2. README.md +12 −9
  3. client/src/App.css +107 −1
  4. client/src/App.tsx +99 −67
  5. client/src/api.ts +14 −0
  6. client/src/components/ProviderSetup.tsx +140 −0
  7. client/src/labels.test.ts +36 −0
  8. client/src/labels.ts +21 −1
  9. scripts/setup.mjs +4 −1
  10. server/app.ts +16 −3
  11. server/config/runtimeConfig.test.ts +123 −0
  12. server/config/runtimeConfig.ts +104 −0
  13. server/index.ts +11 −18
  14. server/providers/factory.ts +15 −0
  15. server/providers/llmAnthropic.ts +4 −3
  16. server/providers/sttOpenai.ts +4 −3
  17. server/providers/types.ts +6 −0
  18. server/routes/audio.ts +12 −1
  19. server/routes/config.test.ts +135 −0
  20. server/routes/config.ts +62 −0
  21. server/routes/generate.ts +4 −1
  22. server/routes/sessions.ts +4 −1
  23. shared/types.ts +35 −0
  24. spec/PLAN.md +7 −4
  25. spec/SPEC.md +11 −1
  26. spec/TASKS.md +5 −0
  27. spec/VERIFICATION.md +2 −1
modified .env.example +4 −2
@@ -1,5 +1,7 @@
1 -# Try the app without keys by running "npm run demo". It overrides this file for that run.
2 -# For real providers, run "npm run setup" and it writes .env for you.
1 +# You do not need this file to start. Run "npm run dev" and the first screen in the
2 +# browser asks for your keys or offers the offline demo, then writes .env for you.
3 +# "npm run setup" asks the same questions in the terminal.
4 +# "npm run demo" always uses offline mocks and overrides this file for that run.
3 5 # To configure manually, copy this file to .env and replace the key placeholders.
4 6
5 7 # 1 = deterministic offline mocks for STT and LLM (used by tests and CI)
modified README.md +12 −9
@@ -6,30 +6,32 @@You speak or type. VoiceTask asks one focused question at a time, tracks which p
6 6
7 7 VoiceTask runs on your computer. It needs no account and saves each interview locally.
8 8
9 -## Try it without API keys
9 +## Start it
10 10
11 11 ```
12 12 npm install
13 -npm run demo
13 +npm run dev
14 14 ```
15 15
16 -Open the printed Vite URL in Chrome or Edge.
16 +Open the printed Vite URL in Chrome or Edge. The first screen asks how you want to run it:
17 +
18 +- **Paste your own keys.** An Anthropic key for the interview (console.anthropic.com) and an OpenAI key for speech-to-text (platform.openai.com). VoiceTask saves them to a local `.env` file and starts using them right away, without a restart.
19 +- **Try the demo without keys.** The whole flow runs on built-in sample answers, offline.
17 20
18 -Demo mode uses deterministic mock speech and interview providers. It does not need API keys and does not call a provider, even if an existing `.env` selects real providers. It is intended for trying the complete flow safely.
21 +You can switch between the two at any time from the button in the top right of the home screen.
19 22
20 -## Use it with real speech and interview responses
23 +Two other ways to configure it, if you prefer the terminal:
21 24
22 25 ```
23 -npm run setup
24 -npm run dev
26 +npm run demo # always offline, ignores whatever .env selects
27 +npm run setup # asks the same questions in the terminal and writes .env
25 28 ```
26 29
27 -`npm run setup` asks you to choose real APIs or the offline demo. For real mode, it asks for an Anthropic key for the interview and an OpenAI key for speech-to-text, then writes a local `.env`.
28 -
29 30 You can also copy `.env.example` to `.env` and fill in the values yourself.
30 31
31 32 ## How a session works
32 33
34 +0. On first run, choose your keys or the offline demo.
33 35 1. Give the idea a short name and choose the project folder.
34 36 2. Click `Start talking`, speak, then click `Send recording`. You can type instead, or hold Space while you talk.
35 37 3. Answer one question at a time. The progress panel shows what is ready, in progress, and still to discuss.
@@ -58,6 +60,7 @@A source marker such as `[S7]` means the requirement came from interview segment
58 60 - The completed brief and transcript can be downloaded from the app.
59 61 - In real mode, audio is sent to OpenAI for transcription and interview text is sent to Anthropic for interview and file generation.
60 62 - API keys are read from environment variables or the local `.env`. They are not written to sessions, generated files, or logs.
63 +- Keys entered in the app go to the same local `.env`, which is gitignored. The app never sends a key back to the browser; the settings screen shows only the last four characters of a saved key.
61 64
62 65 ## Providers and models
63 66
modified client/src/App.css +107 −1
@@ -202,6 +202,7 @@
202 202 }
203 203
204 204 .create-form input,
205 +.provider-form input,
205 206 .folder-field input {
206 207 width: 100%;
207 208 padding: 12px 14px;
@@ -214,13 +215,15 @@
214 215 }
215 216
216 217 .create-form input:focus,
218 +.provider-form input:focus,
217 219 .folder-field input:focus {
218 220 outline: none;
219 221 border-color: var(--clay);
220 222 box-shadow: 0 0 0 4px color-mix(in srgb, var(--clay) 10%, transparent);
221 223 }
222 224
223 -.create-form input::placeholder {
225 +.create-form input::placeholder,
226 +.provider-form input::placeholder {
224 227 color: var(--ink-soft);
225 228 opacity: 0.62;
226 229 }
@@ -319,6 +322,109 @@
319 322 text-align: left;
320 323 }
321 324
325 +.home-nav-status {
326 + display: flex;
327 + flex-wrap: wrap;
328 + align-items: center;
329 + gap: 10px;
330 +}
331 +
332 +.connection-chip {
333 + display: inline-flex;
334 + align-items: center;
335 + gap: 8px;
336 + padding: 8px 12px;
337 + border: 1px solid var(--line);
338 + border-radius: 999px;
339 + background: color-mix(in srgb, var(--card) 80%, transparent);
340 + color: var(--ink-soft);
341 + font-size: 13px;
342 + font-weight: 600;
343 +}
344 +
345 +.connection-chip::before {
346 + content: '';
347 + width: 8px;
348 + height: 8px;
349 + border-radius: 50%;
350 + background: var(--clay);
351 +}
352 +
353 +.connection-chip.mode-demo::before {
354 + background: var(--amber);
355 +}
356 +
357 +.connection-chip.mode-unconfigured {
358 + border-color: color-mix(in srgb, var(--pulse) 45%, var(--line));
359 + background: var(--pulse-soft);
360 + color: var(--ink);
361 +}
362 +
363 +.connection-chip.mode-unconfigured::before {
364 + background: var(--pulse);
365 +}
366 +
367 +button.connection-chip {
368 + cursor: pointer;
369 + transition: border-color 0.2s ease, color 0.2s ease;
370 +}
371 +
372 +button.connection-chip:hover {
373 + border-color: var(--clay);
374 + color: var(--clay-deep);
375 +}
376 +
377 +.provider-form {
378 + display: flex;
379 + flex-direction: column;
380 + gap: 22px;
381 + margin-top: 26px;
382 +}
383 +
384 +.provider-alt {
385 + display: flex;
386 + align-items: center;
387 + gap: 12px;
388 + margin: 22px 0 16px;
389 + color: var(--ink-soft);
390 + font-size: 12px;
391 + font-weight: 600;
392 + text-transform: uppercase;
393 + letter-spacing: 0.08em;
394 +}
395 +
396 +.provider-alt::before,
397 +.provider-alt::after {
398 + content: '';
399 + flex: 1;
400 + height: 1px;
401 + background: var(--line);
402 +}
403 +
404 +.provider-note {
405 + margin-top: 14px;
406 + padding: 10px 12px;
407 + border-radius: 10px;
408 + background: color-mix(in srgb, var(--amber) 10%, var(--card));
409 + color: var(--ink);
410 + font-size: 13px;
411 +}
412 +
413 +.provider-close {
414 + margin-top: 18px;
415 + padding: 0;
416 + border: none;
417 + background: none;
418 + color: var(--clay-deep);
419 + font-size: 14px;
420 + font-weight: 700;
421 + cursor: pointer;
422 +}
423 +
424 +.provider-close:hover {
425 + text-decoration: underline;
426 +}
427 +
322 428 .outcome-preview {
323 429 position: relative;
324 430 display: grid;
modified client/src/App.tsx +99 −67
@@ -1,18 +1,20 @@
1 1 import { useCallback, useEffect, useRef, useState } from 'react'
2 -import type { Session, SessionSummary } from 'shared/types'
2 +import type { ConfigStatus, Session, SessionSummary } from 'shared/types'
3 3 import * as api from './api'
4 4 import './App.css'
5 5 import { BlockerImport } from './components/BlockerImport'
6 6 import { CoveragePanel } from './components/CoveragePanel'
7 7 import { FolderBrowser } from './components/FolderBrowser'
8 8 import { GeneratePanel } from './components/GeneratePanel'
9 9 import { OutcomePreview } from './components/OutcomePreview'
10 +import { ProviderSetup } from './components/ProviderSetup'
10 11 import { PushToTalkButton } from './components/PushToTalkButton'
11 12 import { QuestionCard } from './components/QuestionCard'
12 13 import { Transcript } from './components/Transcript'
13 14 import { TranscriptExport } from './components/TranscriptExport'
14 15 import { VoiceOrb, type VoiceState } from './components/VoiceOrb'
15 16 import { Waveform, type WaveformMode } from './components/Waveform'
17 +import { connectionLabel } from './labels'
16 18 import { speak, stopSpeaking } from './tts'
17 19
18 20 function latestQuestion(session: Session): string | null {
@@ -41,9 +43,12 @@function SessionListScreen({ onOpen }: { onOpen: (id: string) => void }) {
41 43 const [error, setError] = useState<string | null>(null)
42 44 const [creating, setCreating] = useState(false)
43 45 const [browsingFolder, setBrowsingFolder] = useState(false)
46 + const [config, setConfig] = useState<ConfigStatus | null>(null)
47 + const [editingConnection, setEditingConnection] = useState(false)
44 48
45 49 useEffect(() => {
46 50 api.listSessions().then(setSessions).catch((err: Error) => setError(err.message))
51 + api.getConfig().then(setConfig).catch((err: Error) => setError(err.message))
47 52 }, [])
48 53
49 54 async function handleDelete(id: string) {
@@ -71,16 +76,33 @@function SessionListScreen({ onOpen }: { onOpen: (id: string) => void }) {
71 76 }
72 77 }
73 78
79 + const showSetup = config !== null && (config.mode === 'unconfigured' || editingConnection)
80 +
74 81 return (
75 82 <div className="screen home-screen">
76 83 <header className="home-nav">
77 84 <span className="wordmark">
78 85 voicetask<span className="wordmark-dot">.</span>
79 86 </span>
80 - <span className="local-badge">
81 - <span aria-hidden="true" />
82 - Saved on this computer
83 - </span>
87 + <div className="home-nav-status">
88 + <span className="local-badge">
89 + <span aria-hidden="true" />
90 + Saved on this computer
91 + </span>
92 + {config && config.mode === 'unconfigured' && (
93 + <span className="connection-chip mode-unconfigured">{connectionLabel(config)}</span>
94 + )}
95 + {config && config.mode !== 'unconfigured' && (
96 + <button
97 + type="button"
98 + className={`connection-chip mode-${config.mode}`}
99 + aria-expanded={editingConnection}
100 + onClick={() => setEditingConnection((open) => !open)}
101 + >
102 + {connectionLabel(config)}
103 + </button>
104 + )}
105 + </div>
84 106 </header>
85 107
86 108 <main className="home-main">
@@ -103,74 +125,84 @@function SessionListScreen({ onOpen }: { onOpen: (id: string) => void }) {
103 125 <OutcomePreview />
104 126 </section>
105 127
106 - <section className="start-card" aria-labelledby="start-heading">
107 - <p className="card-kicker">Start here</p>
108 - <h2 id="start-heading">What are you working on?</h2>
109 - <p className="card-intro">A rough idea is enough. The interview will help fill in the rest.</p>
110 -
111 - <form className="create-form" onSubmit={handleCreate}>
112 - <div className="form-field">
113 - <label htmlFor="project-name">Name your idea</label>
114 - <p id="project-name-help">Use a short name so you can find this interview later.</p>
115 - <input
116 - id="project-name"
117 - value={name}
118 - onChange={(event) => setName(event.target.value)}
119 - placeholder="Family recipe app"
120 - aria-describedby="project-name-help"
121 - required
122 - />
123 - </div>
124 -
125 - <div className="form-field">
126 - <span className="field-label" id="project-folder-label">
127 - Choose where to save the build brief
128 - </span>
129 - <p id="project-folder-help">
130 - VoiceTask writes the finished files here. You can also download them as a zip.
131 - </p>
132 - <button
133 - type="button"
134 - className="choose-folder-button"
135 - onClick={() => setBrowsingFolder(true)}
136 - aria-describedby="project-folder-help"
137 - >
138 - {targetDir ? 'Choose a different folder' : 'Choose a folder'}
139 - </button>
140 - {targetDir && (
141 - <p className="selected-folder" title={targetDir}>
142 - <span>Selected</span>
143 - {targetDir}
144 - </p>
145 - )}
146 - <label className="path-label" htmlFor="project-folder">
147 - Or enter a folder path
148 - </label>
149 - <div className="folder-field">
128 + {showSetup && config ? (
129 + <section className="start-card">
130 + <ProviderSetup
131 + status={config}
132 + onSaved={setConfig}
133 + onClose={config.mode === 'unconfigured' ? undefined : () => setEditingConnection(false)}
134 + />
135 + </section>
136 + ) : (
137 + <section className="start-card" aria-labelledby="start-heading">
138 + <p className="card-kicker">Start here</p>
139 + <h2 id="start-heading">What are you working on?</h2>
140 + <p className="card-intro">A rough idea is enough. The interview will help fill in the rest.</p>
141 +
142 + <form className="create-form" onSubmit={handleCreate}>
143 + <div className="form-field">
144 + <label htmlFor="project-name">Name your idea</label>
145 + <p id="project-name-help">Use a short name so you can find this interview later.</p>
150 146 <input
151 - id="project-folder"
152 - value={targetDir}
153 - onChange={(event) => setTargetDir(event.target.value)}
154 - placeholder="Paste an absolute folder path"
155 - aria-labelledby="project-folder-label"
156 - aria-describedby="project-folder-help"
147 + id="project-name"
148 + value={name}
149 + onChange={(event) => setName(event.target.value)}
150 + placeholder="Family recipe app"
151 + aria-describedby="project-name-help"
157 152 required
158 153 />
159 154 </div>
160 - </div>
161 155
162 - <button type="submit" className="btn-primary start-button" disabled={creating}>
163 - {creating ? 'Starting...' : 'Start my interview'}
164 - </button>
165 - <p className="form-footnote">Answer by voice or typing. Stop whenever you have said enough.</p>
166 - </form>
156 + <div className="form-field">
157 + <span className="field-label" id="project-folder-label">
158 + Choose where to save the build brief
159 + </span>
160 + <p id="project-folder-help">
161 + VoiceTask writes the finished files here. You can also download them as a zip.
162 + </p>
163 + <button
164 + type="button"
165 + className="choose-folder-button"
166 + onClick={() => setBrowsingFolder(true)}
167 + aria-describedby="project-folder-help"
168 + >
169 + {targetDir ? 'Choose a different folder' : 'Choose a folder'}
170 + </button>
171 + {targetDir && (
172 + <p className="selected-folder" title={targetDir}>
173 + <span>Selected</span>
174 + {targetDir}
175 + </p>
176 + )}
177 + <label className="path-label" htmlFor="project-folder">
178 + Or enter a folder path
179 + </label>
180 + <div className="folder-field">
181 + <input
182 + id="project-folder"
183 + value={targetDir}
184 + onChange={(event) => setTargetDir(event.target.value)}
185 + placeholder="Paste an absolute folder path"
186 + aria-labelledby="project-folder-label"
187 + aria-describedby="project-folder-help"
188 + required
189 + />
190 + </div>
191 + </div>
167 192
168 - {error && (
169 - <p className="error" role="alert">
170 - {error}
171 - </p>
172 - )}
173 - </section>
193 + <button type="submit" className="btn-primary start-button" disabled={creating}>
194 + {creating ? 'Starting...' : 'Start my interview'}
195 + </button>
196 + <p className="form-footnote">Answer by voice or typing. Stop whenever you have said enough.</p>
197 + </form>
198 +
199 + {error && (
200 + <p className="error" role="alert">
201 + {error}
202 + </p>
203 + )}
204 + </section>
205 + )}
174 206 </main>
175 207
176 208 <section className="process-section" aria-labelledby="process-heading">
modified client/src/api.ts +14 −0
@@ -2,6 +2,9 @@import type {
2 2 AnswerRequest,
3 3 AnswerResponse,
4 4 BlockersResponse,
5 + ConfigStatus,
6 + ConfigUpdateRequest,
7 + ConfigUpdateResponse,
5 8 CreateSessionRequest,
6 9 FsBrowseResponse,
7 10 FsMkdirResponse,
@@ -84,6 +87,17 @@export function importBlockers(id: string): Promise<BlockersResponse> {
84 87 })
85 88 }
86 89
90 +export function getConfig(): Promise<ConfigStatus> {
91 + return requestJson<ConfigStatus>(`${BASE}/config`)
92 +}
93 +
94 +export function saveConfig(req: ConfigUpdateRequest): Promise<ConfigUpdateResponse> {
95 + return requestJson<ConfigUpdateResponse>(`${BASE}/config`, {
96 + method: 'POST',
97 + body: JSON.stringify(req),
98 + })
99 +}
100 +
87 101 export function browseFolder(path?: string): Promise<FsBrowseResponse> {
88 102 const query = path ? `?path=${encodeURIComponent(path)}` : ''
89 103 return requestJson<FsBrowseResponse>(`${BASE}/fs/browse${query}`)
added client/src/components/ProviderSetup.tsx +140 −0
@@ -0,0 +1,140 @@
1 +import { useState } from 'react'
2 +import type { ConfigStatus } from 'shared/types'
3 +import * as api from '../api'
4 +import { connectionDetail } from '../labels'
5 +
6 +interface ProviderSetupProps {
7 + status: ConfigStatus
8 + onSaved: (status: ConfigStatus) => void
9 + onClose?: () => void
10 +}
11 +
12 +export function ProviderSetup({ status, onSaved, onClose }: ProviderSetupProps) {
13 + const [anthropicKey, setAnthropicKey] = useState('')
14 + const [openaiKey, setOpenaiKey] = useState('')
15 + const [saving, setSaving] = useState(false)
16 + const [error, setError] = useState<string | null>(null)
17 + const [note, setNote] = useState<string | null>(null)
18 +
19 + const anthropicReady = anthropicKey.trim().length > 0 || status.anthropicKeySet
20 + const openaiReady = openaiKey.trim().length > 0 || status.openaiKeySet
21 +
22 + async function save(mode: 'demo' | 'real') {
23 + setError(null)
24 + setNote(null)
25 + setSaving(true)
26 + try {
27 + const result =
28 + mode === 'demo'
29 + ? await api.saveConfig({ mode: 'demo' })
30 + : await api.saveConfig({
31 + mode: 'real',
32 + ...(anthropicKey.trim() ? { anthropicKey: anthropicKey.trim() } : {}),
33 + ...(openaiKey.trim() ? { openaiKey: openaiKey.trim() } : {}),
34 + })
35 + setAnthropicKey('')
36 + setOpenaiKey('')
37 + if (result.restartRequired) {
38 + setNote('Keys saved. Stop this server and run "npm run dev" to use them.')
39 + } else {
40 + onClose?.()
41 + }
42 + onSaved(result.status)
43 + } catch (err) {
44 + setError(err instanceof Error ? err.message : String(err))
45 + } finally {
46 + setSaving(false)
47 + }
48 + }
49 +
50 + return (
51 + <section className="provider-setup" aria-labelledby="provider-setup-heading">
52 + <p className="card-kicker">{status.mode === 'unconfigured' ? 'One-time setup' : 'Connection'}</p>
53 + <h2 id="provider-setup-heading">
54 + {status.mode === 'unconfigured' ? 'Connect VoiceTask' : 'How VoiceTask is connected'}
55 + </h2>
56 + <p className="card-intro">{connectionDetail(status)}</p>
57 +
58 + <form
59 + className="provider-form"
60 + onSubmit={(event) => {
61 + event.preventDefault()
62 + void save('real')
63 + }}
64 + >
65 + <div className="form-field">
66 + <label htmlFor="anthropic-key">Anthropic key, for the interview</label>
67 + <p id="anthropic-key-help">
68 + Create one at console.anthropic.com. It starts with sk-ant-.
69 + {status.anthropicKeySet ? ' A key is already saved; leave this empty to keep it.' : ''}
70 + </p>
71 + <input
72 + id="anthropic-key"
73 + type="password"
74 + value={anthropicKey}
75 + onChange={(event) => setAnthropicKey(event.target.value)}
76 + placeholder={status.anthropicKeySet ? 'Saved. Paste a new key to replace it' : 'sk-ant-...'}
77 + autoComplete="off"
78 + spellCheck={false}
79 + aria-describedby="anthropic-key-help"
80 + disabled={saving}
81 + />
82 + </div>
83 +
84 + <div className="form-field">
85 + <label htmlFor="openai-key">OpenAI key, for turning speech into text</label>
86 + <p id="openai-key-help">
87 + Create one at platform.openai.com. It starts with sk-.
88 + {status.openaiKeySet ? ' A key is already saved; leave this empty to keep it.' : ''}
89 + </p>
90 + <input
91 + id="openai-key"
92 + type="password"
93 + value={openaiKey}
94 + onChange={(event) => setOpenaiKey(event.target.value)}
95 + placeholder={status.openaiKeySet ? 'Saved. Paste a new key to replace it' : 'sk-...'}
96 + autoComplete="off"
97 + spellCheck={false}
98 + aria-describedby="openai-key-help"
99 + disabled={saving}
100 + />
101 + </div>
102 +
103 + <button type="submit" className="btn-primary start-button" disabled={saving || !anthropicReady || !openaiReady}>
104 + {saving ? 'Saving...' : 'Save keys'}
105 + </button>
106 + <p className="form-footnote">
107 + Keys are written to a local .env file on this computer, and never into your interview or build brief.
108 + </p>
109 + </form>
110 +
111 + <div className="provider-alt">
112 + <span>or</span>
113 + </div>
114 +
115 + <button type="button" className="choose-folder-button" disabled={saving} onClick={() => void save('demo')}>
116 + Try the demo without keys
117 + </button>
118 + <p className="form-footnote">
119 + The demo runs the whole flow with built-in sample answers, offline. You can switch to real keys later.
120 + </p>
121 +
122 + {note && (
123 + <p className="provider-note" role="status">
124 + {note}
125 + </p>
126 + )}
127 + {error && (
128 + <p className="error" role="alert">
129 + {error}
130 + </p>
131 + )}
132 +
133 + {onClose && (
134 + <button type="button" className="provider-close" onClick={onClose} disabled={saving}>
135 + Back
136 + </button>
137 + )}
138 + </section>
139 + )
140 +}
added client/src/labels.test.ts +36 −0
@@ -0,0 +1,36 @@
1 +import type { ConfigStatus } from 'shared/types'
2 +import { describe, expect, it } from 'vitest'
3 +import { connectionDetail, connectionLabel } from './labels'
4 +
5 +function status(overrides: Partial<ConfigStatus> = {}): ConfigStatus {
6 + return {
7 + mode: 'unconfigured',
8 + demoLocked: false,
9 + anthropicKeySet: false,
10 + openaiKeySet: false,
11 + anthropicKeyHint: null,
12 + openaiKeyHint: null,
13 + ...overrides,
14 + }
15 +}
16 +
17 +describe('connection labels', () => {
18 + it('names each provider mode in plain language', () => {
19 + expect(connectionLabel(status())).toBe('Setup needed')
20 + expect(connectionLabel(status({ mode: 'demo' }))).toBe('Demo mode')
21 + expect(connectionLabel(status({ mode: 'real' }))).toBe('Your own keys')
22 + })
23 +
24 + it('explains a demo server started with the demo command', () => {
25 + expect(connectionDetail(status({ mode: 'demo', demoLocked: true }))).toContain('npm run demo')
26 + expect(connectionDetail(status({ mode: 'demo' }))).not.toContain('npm run demo')
27 + })
28 +
29 + it('shows only key hints when real providers are in use', () => {
30 + const detail = connectionDetail(
31 + status({ mode: 'real', anthropicKeyHint: '...abcd', openaiKeyHint: '...wxyz' }),
32 + )
33 + expect(detail).toContain('...abcd')
34 + expect(detail).toContain('...wxyz')
35 + })
36 +})
modified client/src/labels.ts +21 −1
@@ -1,4 +1,4 @@
1 -import type { CategoryId } from 'shared/types'
1 +import type { CategoryId, ConfigStatus } from 'shared/types'
2 2
3 3 export interface CategoryLabel {
4 4 label: string
@@ -16,3 +16,23 @@export const CATEGORY_LABELS: Record<CategoryId, CategoryLabel> = {
16 16 'non-goals': { label: "What's NOT included", description: "Things you're deliberately leaving out for now" },
17 17 verification: { label: "How you'll know it's done", description: 'What "finished" looks like' },
18 18 }
19 +
20 +export function connectionLabel(status: ConfigStatus): string {
21 + if (status.mode === 'demo') return 'Demo mode'
22 + if (status.mode === 'real') return 'Your own keys'
23 + return 'Setup needed'
24 +}
25 +
26 +export function connectionDetail(status: ConfigStatus): string {
27 + if (status.mode === 'demo') {
28 + return status.demoLocked
29 + ? 'Started with npm run demo, so this session always uses the built-in sample answers.'
30 + : 'Using built-in sample answers. Nothing leaves this computer.'
31 + }
32 + if (status.mode === 'real') {
33 + const anthropic = status.anthropicKeyHint ?? 'saved'
34 + const openai = status.openaiKeyHint ?? 'saved'
35 + return `Interview key ${anthropic}, speech key ${openai}. Both stay in a local file on this computer.`
36 + }
37 + return 'Add your two keys to talk to a real interviewer, or try the demo first.'
38 +}
modified scripts/setup.mjs +4 −1
@@ -26,7 +26,10 @@async function ask(question) {
26 26 return line.trim()
27 27 }
28 28
29 -console.log('VoiceTask setup. Writes a local .env file (gitignored, never committed).\n')
29 +console.log(
30 + 'VoiceTask setup. Writes a local .env file (gitignored, never committed).\n' +
31 + 'You can also skip this and set everything up in the browser after "npm run dev".\n',
32 +)
30 33
31 34 if (existsSync('.env')) {
32 35 const answer = await ask('.env already exists. Overwrite it? [y/N] ')
modified server/app.ts +16 −3
@@ -1,10 +1,11 @@
1 1 import cors from '@fastify/cors'
2 2 import multipart from '@fastify/multipart'
3 3 import Fastify, { type FastifyInstance } from 'fastify'
4 -import { createInterviewLlm, createSttProvider } from './providers/factory'
4 +import { createLazyInterviewLlm, createLazySttProvider } from './providers/factory'
5 5 import type { InterviewLlm, SttProvider } from './providers/types'
6 6 import { registerAudioRoutes } from './routes/audio'
7 7 import { registerBlockerRoutes } from './routes/blockers'
8 +import { registerConfigRoutes } from './routes/config'
8 9 import { registerExportRoutes } from './routes/export'
9 10 import { registerFsRoutes } from './routes/fs'
10 11 import { registerGenerateRoutes } from './routes/generate'
@@ -15,13 +16,20 @@export interface AppDeps {
15 16 store: SessionStore
16 17 llm: InterviewLlm
17 18 stt: SttProvider
19 + demoLocked: boolean
20 + // Overridable so tests never write the real .env of this repository.
21 + envPath?: string
22 + env?: NodeJS.ProcessEnv
18 23 }
19 24
20 25 export function buildApp(deps: Partial<AppDeps> = {}): FastifyInstance {
21 26 const resolved: AppDeps = {
22 27 store: deps.store ?? createDefaultSessionStore(),
23 - llm: deps.llm ?? createInterviewLlm(),
24 - stt: deps.stt ?? createSttProvider(),
28 + llm: deps.llm ?? createLazyInterviewLlm(),
29 + stt: deps.stt ?? createLazySttProvider(),
30 + demoLocked: deps.demoLocked ?? false,
31 + envPath: deps.envPath,
32 + env: deps.env,
25 33 }
26 34
27 35 const app = Fastify({ logger: false })
@@ -35,6 +43,11 @@export function buildApp(deps: Partial<AppDeps> = {}): FastifyInstance {
35 43 registerBlockerRoutes(app, resolved)
36 44 registerFsRoutes(app)
37 45 registerExportRoutes(app, resolved)
46 + registerConfigRoutes(app, {
47 + demoLocked: resolved.demoLocked,
48 + envPath: resolved.envPath,
49 + env: resolved.env,
50 + })
38 51
39 52 return app
40 53 }
added server/config/runtimeConfig.test.ts +123 −0
@@ -0,0 +1,123 @@
1 +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
2 +import { tmpdir } from 'node:os'
3 +import path from 'node:path'
4 +import { afterEach, beforeEach, describe, expect, it } from 'vitest'
5 +import { applyConfig, configStatus, keyHint, mergeEnvFile } from './runtimeConfig'
6 +
7 +describe('configStatus', () => {
8 + it('reports demo mode when mocks are forced', () => {
9 + const status = configStatus(true, { MOCK_PROVIDERS: '1' })
10 + expect(status.mode).toBe('demo')
11 + expect(status.demoLocked).toBe(true)
12 + })
13 +
14 + it('reports real mode only when both keys are present', () => {
15 + expect(configStatus(false, { ANTHROPIC_API_KEY: 'sk-ant-1234abcd' }).mode).toBe('unconfigured')
16 + expect(
17 + configStatus(false, { ANTHROPIC_API_KEY: 'sk-ant-1234abcd', OPENAI_API_KEY: 'sk-1234wxyz' }).mode,
18 + ).toBe('real')
19 + })
20 +
21 + it('treats .env.example placeholders as unset', () => {
22 + const status = configStatus(false, {
23 + ANTHROPIC_API_KEY: '@insert-anthropic-api-key@',
24 + OPENAI_API_KEY: '@insert-openai-api-key@',
25 + })
26 + expect(status.mode).toBe('unconfigured')
27 + expect(status.anthropicKeySet).toBe(false)
28 + expect(status.openaiKeySet).toBe(false)
29 + })
30 +
31 + it('never exposes more than the last four characters of a key', () => {
32 + const status = configStatus(false, { ANTHROPIC_API_KEY: 'sk-ant-secret-value-abcd' })
33 + expect(status.anthropicKeyHint).toBe('...abcd')
34 + expect(JSON.stringify(status)).not.toContain('secret')
35 + expect(keyHint(null)).toBeNull()
36 + })
37 +})
38 +
39 +describe('mergeEnvFile', () => {
40 + it('replaces existing assignments and keeps comments and other keys', () => {
41 + const merged = mergeEnvFile('# note\nMOCK_PROVIDERS=1\nPORT=4000\n', {
42 + MOCK_PROVIDERS: '',
43 + ANTHROPIC_API_KEY: 'sk-ant-new',
44 + })
45 + expect(merged).toBe('# note\nMOCK_PROVIDERS=\nPORT=4000\nANTHROPIC_API_KEY=sk-ant-new\n')
46 + })
47 +
48 + it('drops duplicate assignments of an updated key', () => {
49 + const merged = mergeEnvFile('OPENAI_API_KEY=old-one\nPORT=3001\nOPENAI_API_KEY=old-two\n', {
50 + OPENAI_API_KEY: 'sk-new',
51 + })
52 + expect(merged).toBe('OPENAI_API_KEY=sk-new\nPORT=3001\n')
53 + })
54 +
55 + it('writes into an empty file', () => {
56 + expect(mergeEnvFile('', { MOCK_PROVIDERS: '1' })).toBe('MOCK_PROVIDERS=1\n')
57 + })
58 +})
59 +
60 +describe('applyConfig', () => {
61 + let dir: string
62 + let envPath: string
63 +
64 + beforeEach(async () => {
65 + dir = await mkdtemp(path.join(tmpdir(), 'voicetask-env-'))
66 + envPath = path.join(dir, '.env')
67 + })
68 +
69 + afterEach(async () => {
70 + await rm(dir, { recursive: true, force: true })
71 + })
72 +
73 + it('stores keys and switches the running process to real providers', async () => {
74 + const env: NodeJS.ProcessEnv = { MOCK_PROVIDERS: '1' }
75 + const result = applyConfig(
76 + { mode: 'real', anthropicKey: 'sk-ant-1234abcd', openaiKey: 'sk-1234wxyz' },
77 + { demoLocked: false, envPath, env },
78 + )
79 +
80 + expect(result.restartRequired).toBe(false)
81 + expect(result.status.mode).toBe('real')
82 + expect(env.MOCK_PROVIDERS).toBeUndefined()
83 + expect(env.ANTHROPIC_API_KEY).toBe('sk-ant-1234abcd')
84 +
85 + const written = await readFile(envPath, 'utf8')
86 + expect(written).toContain('ANTHROPIC_API_KEY=sk-ant-1234abcd')
87 + expect(written).toContain('OPENAI_API_KEY=sk-1234wxyz')
88 + expect(written).toContain('MOCK_PROVIDERS=')
89 + })
90 +
91 + it('keeps an existing key when only the other one is sent', async () => {
92 + await writeFile(envPath, 'ANTHROPIC_API_KEY=sk-ant-old\n', 'utf8')
93 + const env: NodeJS.ProcessEnv = { ANTHROPIC_API_KEY: 'sk-ant-old' }
94 + applyConfig({ mode: 'real', openaiKey: 'sk-1234wxyz' }, { demoLocked: false, envPath, env })
95 +
96 + const written = await readFile(envPath, 'utf8')
97 + expect(written).toContain('ANTHROPIC_API_KEY=sk-ant-old')
98 + expect(env.ANTHROPIC_API_KEY).toBe('sk-ant-old')
99 + })
100 +
101 + it('saves keys but stays in mocks when the server was started in demo mode', async () => {
102 + const env: NodeJS.ProcessEnv = { MOCK_PROVIDERS: '1' }
103 + const result = applyConfig(
104 + { mode: 'real', anthropicKey: 'sk-ant-1234abcd', openaiKey: 'sk-1234wxyz' },
105 + { demoLocked: true, envPath, env },
106 + )
107 +
108 + expect(result.restartRequired).toBe(true)
109 + expect(result.status.mode).toBe('demo')
110 + expect(env.ANTHROPIC_API_KEY).toBeUndefined()
111 + expect(await readFile(envPath, 'utf8')).toContain('ANTHROPIC_API_KEY=sk-ant-1234abcd')
112 + })
113 +
114 + it('switches to demo mode without touching stored keys', async () => {
115 + await writeFile(envPath, 'ANTHROPIC_API_KEY=sk-ant-old\nOPENAI_API_KEY=sk-old\n', 'utf8')
116 + const env: NodeJS.ProcessEnv = { ANTHROPIC_API_KEY: 'sk-ant-old', OPENAI_API_KEY: 'sk-old' }
117 + const result = applyConfig({ mode: 'demo' }, { demoLocked: false, envPath, env })
118 +
119 + expect(result.status.mode).toBe('demo')
120 + expect(env.MOCK_PROVIDERS).toBe('1')
121 + expect(await readFile(envPath, 'utf8')).toContain('ANTHROPIC_API_KEY=sk-ant-old')
122 + })
123 +})
added server/config/runtimeConfig.ts +104 −0
@@ -0,0 +1,104 @@
1 +import { chmodSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
2 +import type { ConfigStatus, ConfigUpdateRequest } from '../../shared/types'
3 +
4 +export const ENV_FILE = '.env'
5 +
6 +const PLACEHOLDER_PREFIX = '@insert'
7 +
8 +export function readEnvKey(name: string, env: NodeJS.ProcessEnv = process.env): string | null {
9 + const value = env[name]?.trim()
10 + if (!value || value.startsWith(PLACEHOLDER_PREFIX)) return null
11 + return value
12 +}
13 +
14 +export function keyHint(value: string | null): string | null {
15 + if (!value) return null
16 + return value.length <= 4 ? '****' : `...${value.slice(-4)}`
17 +}
18 +
19 +export function configStatus(demoLocked: boolean, env: NodeJS.ProcessEnv = process.env): ConfigStatus {
20 + const anthropic = readEnvKey('ANTHROPIC_API_KEY', env)
21 + const openai = readEnvKey('OPENAI_API_KEY', env)
22 + const demo = env.MOCK_PROVIDERS === '1'
23 +
24 + return {
25 + mode: demo ? 'demo' : anthropic && openai ? 'real' : 'unconfigured',
26 + demoLocked,
27 + anthropicKeySet: anthropic !== null,
28 + openaiKeySet: openai !== null,
29 + anthropicKeyHint: keyHint(anthropic),
30 + openaiKeyHint: keyHint(openai),
31 + }
32 +}
33 +
34 +export function mergeEnvFile(existing: string, updates: Record<string, string>): string {
35 + const written = new Set<string>()
36 + const lines: string[] = []
37 +
38 + for (const line of existing.length > 0 ? existing.split(/\r?\n/) : []) {
39 + const name = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/)?.[1]
40 + if (name === undefined || !(name in updates)) {
41 + lines.push(line)
42 + continue
43 + }
44 + // A later duplicate would win when the file is read back, so keep only one.
45 + if (written.has(name)) continue
46 + written.add(name)
47 + lines.push(`${name}=${updates[name]}`)
48 + }
49 +
50 + while (lines.length > 0 && lines[lines.length - 1].trim() === '') lines.pop()
51 +
52 + for (const [name, value] of Object.entries(updates)) {
53 + if (!written.has(name)) lines.push(`${name}=${value}`)
54 + }
55 +
56 + return `${lines.join('\n')}\n`
57 +}
58 +
59 +export interface ApplyConfigOptions {
60 + demoLocked: boolean
61 + envPath?: string
62 + env?: NodeJS.ProcessEnv
63 +}
64 +
65 +export interface ApplyConfigResult {
66 + status: ConfigStatus
67 + restartRequired: boolean
68 +}
69 +
70 +/**
71 + * Persists the chosen provider mode to the local .env and, unless this process
72 + * was started in demo mode, applies it to the running server right away.
73 + */
74 +export function applyConfig(update: ConfigUpdateRequest, options: ApplyConfigOptions): ApplyConfigResult {
75 + const env = options.env ?? process.env
76 + const envPath = options.envPath ?? ENV_FILE
77 + const updates: Record<string, string> = {}
78 +
79 + if (update.mode === 'demo') {
80 + updates.MOCK_PROVIDERS = '1'
81 + } else {
82 + updates.MOCK_PROVIDERS = ''
83 + if (update.anthropicKey) updates.ANTHROPIC_API_KEY = update.anthropicKey
84 + if (update.openaiKey) updates.OPENAI_API_KEY = update.openaiKey
85 + }
86 +
87 + const existing = existsSync(envPath) ? readFileSync(envPath, 'utf8') : ''
88 + writeFileSync(envPath, mergeEnvFile(existing, updates), 'utf8')
89 + try {
90 + chmodSync(envPath, 0o600)
91 + } catch {
92 + // Permission bits are advisory here; Windows filesystems ignore them.
93 + }
94 +
95 + const applyToProcess = !(options.demoLocked && update.mode === 'real')
96 + if (applyToProcess) {
97 + for (const [name, value] of Object.entries(updates)) {
98 + if (value === '') delete env[name]
99 + else env[name] = value
100 + }
101 + }
102 +
103 + return { status: configStatus(options.demoLocked, env), restartRequired: !applyToProcess }
104 +}
modified server/index.ts +11 −18
@@ -1,5 +1,6 @@
1 1 import { existsSync, readFileSync } from 'node:fs'
2 2 import { buildApp } from './app'
3 +import { configStatus } from './config/runtimeConfig'
3 4 import { applyDemoMode } from './runtimeMode'
4 5
5 6 // .env wins over inherited shell values, so a stale MOCK_PROVIDERS=1 in some
@@ -14,29 +15,21 @@if (existsSync('.env')) {
14 15 }
15 16 }
16 17
17 -applyDemoMode(process.argv, process.env)
18 -
19 -if (process.env.MOCK_PROVIDERS !== '1') {
20 - const missing = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY'].filter((key) => {
21 - const value = process.env[key]
22 - return !value || value.startsWith('@insert')
23 - })
24 - if (missing.length > 0) {
25 - console.error(
26 - `Missing ${missing.join(' and ')}. Run "npm run setup", or edit .env and replace the ` +
27 - '@insert-...@ placeholders with real keys. For the offline demo set MOCK_PROVIDERS=1 (no keys needed).',
28 - )
29 - process.exit(1)
30 - }
31 -}
18 +const demoLocked = applyDemoMode(process.argv, process.env)
32 19
33 20 const PORT = Number(process.env.PORT ?? 3001)
34 21
22 +function startupMode(): string {
23 + const status = configStatus(demoLocked)
24 + if (status.mode === 'demo') return 'MOCK providers (offline demo)'
25 + if (status.mode === 'real') return 'real providers'
26 + return 'no provider keys yet: add them on the VoiceTask home screen, or pick the offline demo there'
27 +}
28 +
35 29 async function main() {
36 - const app = buildApp()
30 + const app = buildApp({ demoLocked })
37 31 await app.listen({ port: PORT, host: '0.0.0.0' })
38 - const mode = process.env.MOCK_PROVIDERS === '1' ? 'MOCK providers (offline demo)' : 'real providers'
39 - console.log(`VoiceTask server listening on :${PORT} using ${mode}`)
32 + console.log(`VoiceTask server listening on :${PORT} using ${startupMode()}`)
40 33 }
41 34
42 35 main().catch((err) => {
modified server/providers/factory.ts +15 −0
@@ -17,3 +17,18 @@export function createInterviewLlm(): InterviewLlm {
17 17 if (isMockMode()) return createLlmMock()
18 18 return createLlmAnthropic()
19 19 }
20 +
21 +// Resolved per call so keys saved from the setup screen take effect without a
22 +// server restart. Construction is local work only; no provider is contacted.
23 +export function createLazySttProvider(): SttProvider {
24 + return {
25 + transcribe: (audio, mimeType) => createSttProvider().transcribe(audio, mimeType),
26 + }
27 +}
28 +
29 +export function createLazyInterviewLlm(): InterviewLlm {
30 + return {
31 + nextTurn: (context) => createInterviewLlm().nextTurn(context),
32 + generateFile: (request) => createInterviewLlm().generateFile(request),
33 + }
34 +}
modified server/providers/llmAnthropic.ts +4 −3
@@ -1,8 +1,9 @@
1 1 import Anthropic from '@anthropic-ai/sdk'
2 2 import { zodOutputFormat } from '@anthropic-ai/sdk/helpers/zod'
3 3 import { CATEGORY_IDS, InterviewTurnSchema, type InterviewTurn } from '../../shared/types'
4 +import { readEnvKey } from '../config/runtimeConfig'
4 5 import { weakestCategory } from '../engine/coverage'
5 -import type { GenerateFileRequest, InterviewContext, InterviewLlm } from './types'
6 +import { ProviderNotConfiguredError, type GenerateFileRequest, type InterviewContext, type InterviewLlm } from './types'
6 7
7 8 const DEFAULT_MODEL = 'claude-opus-4-8'
8 9 const INTERVIEW_MAX_TOKENS = 16000
@@ -37,8 +38,8 @@export function buildInterviewUserMessage(context: InterviewContext): string {
37 38 }
38 39
39 40 export function createLlmAnthropic(): InterviewLlm {
40 - const apiKey = process.env.ANTHROPIC_API_KEY
41 - if (!apiKey) throw new Error('ANTHROPIC_API_KEY is not set')
41 + const apiKey = readEnvKey('ANTHROPIC_API_KEY')
42 + if (!apiKey) throw new ProviderNotConfiguredError('Anthropic interview')
42 43 const model = process.env.ANTHROPIC_MODEL ?? DEFAULT_MODEL
43 44 const client = new Anthropic({ apiKey })
44 45
modified server/providers/sttOpenai.ts +4 −3
@@ -1,4 +1,5 @@
1 -import { EmptyTranscriptError, type SttProvider } from './types'
1 +import { readEnvKey } from '../config/runtimeConfig'
2 +import { EmptyTranscriptError, ProviderNotConfiguredError, type SttProvider } from './types'
2 3
3 4 const DEFAULT_STT_MODEL = 'gpt-4o-mini-transcribe'
4 5
@@ -12,8 +13,8 @@function audioFileName(mimeType: string): string {
12 13 }
13 14
14 15 export function createSttOpenai(): SttProvider {
15 - const apiKey = process.env.OPENAI_API_KEY
16 - if (!apiKey) throw new Error('OPENAI_API_KEY is not set')
16 + const apiKey = readEnvKey('OPENAI_API_KEY')
17 + if (!apiKey) throw new ProviderNotConfiguredError('OpenAI speech-to-text')
17 18 const model = process.env.STT_MODEL ?? DEFAULT_STT_MODEL
18 19
19 20 return {
modified server/providers/types.ts +6 −0
@@ -6,6 +6,12 @@export class EmptyTranscriptError extends Error {
6 6 }
7 7 }
8 8
9 +export class ProviderNotConfiguredError extends Error {
10 + constructor(what: string) {
11 + super(`No ${what} key yet. Open the VoiceTask home screen and add your keys, or switch to the offline demo.`)
12 + }
13 +}
14 +
9 15 export interface SttProvider {
10 16 transcribe(audio: Buffer, mimeType: string): Promise<string>
11 17 }
modified server/routes/audio.ts +12 −1
@@ -1,6 +1,11 @@
1 1 import type { FastifyInstance } from 'fastify'
2 2 import { SessionNotFoundError, submitAnswer } from '../engine/turn'
3 -import { EmptyTranscriptError, type InterviewLlm, type SttProvider } from '../providers/types'
3 +import {
4 + EmptyTranscriptError,
5 + ProviderNotConfiguredError,
6 + type InterviewLlm,
7 + type SttProvider,
8 +} from '../providers/types'
4 9 import type { SessionStore } from '../store/sessionStore'
5 10
6 11 export interface AudioRouteDeps {
@@ -28,6 +33,9 @@export function registerAudioRoutes(app: FastifyInstance, deps: AudioRouteDeps):
28 33 if (err instanceof EmptyTranscriptError) {
29 34 return reply.code(400).send({ error: 'empty transcript' })
30 35 }
36 + if (err instanceof ProviderNotConfiguredError) {
37 + return reply.code(503).send({ error: err.message })
38 + }
31 39 return reply.code(502).send({ error: err instanceof Error ? err.message : 'STT provider error' })
32 40 }
33 41
@@ -38,6 +46,9 @@export function registerAudioRoutes(app: FastifyInstance, deps: AudioRouteDeps):
38 46 if (err instanceof SessionNotFoundError) {
39 47 return reply.code(404).send({ error: 'session not found' })
40 48 }
49 + if (err instanceof ProviderNotConfiguredError) {
50 + return reply.code(503).send({ error: err.message })
51 + }
41 52 throw err
42 53 }
43 54 })
added server/routes/config.test.ts +135 −0
@@ -0,0 +1,135 @@
1 +import { mkdtemp, readFile, rm } from 'node:fs/promises'
2 +import { tmpdir } from 'node:os'
3 +import path from 'node:path'
4 +import type { FastifyInstance } from 'fastify'
5 +import { afterEach, beforeEach, describe, expect, it } from 'vitest'
6 +import type { ConfigStatus, ConfigUpdateResponse } from '../../shared/types'
7 +import { buildApp } from '../app'
8 +import { createLlmMock } from '../providers/llmMock'
9 +import { createSttMock } from '../providers/sttMock'
10 +import { SessionStore } from '../store/sessionStore'
11 +import { isTrustedOrigin } from './config'
12 +
13 +describe('config routes', () => {
14 + let storeDir: string
15 + let envDir: string
16 + let envPath: string
17 + let env: NodeJS.ProcessEnv
18 + let app: FastifyInstance
19 +
20 + function build(demoLocked = false): FastifyInstance {
21 + return buildApp({
22 + store: new SessionStore(storeDir),
23 + llm: createLlmMock(),
24 + stt: createSttMock(),
25 + demoLocked,
26 + envPath,
27 + env,
28 + })
29 + }
30 +
31 + beforeEach(async () => {
32 + storeDir = await mkdtemp(path.join(tmpdir(), 'voicetask-store-'))
33 + envDir = await mkdtemp(path.join(tmpdir(), 'voicetask-cfg-'))
34 + envPath = path.join(envDir, '.env')
35 + env = {}
36 + app = build()
37 + })
38 +
39 + afterEach(async () => {
40 + await app.close()
41 + await rm(storeDir, { recursive: true, force: true })
42 + await rm(envDir, { recursive: true, force: true })
43 + })
44 +
45 + it('reports an unconfigured server without leaking key values', async () => {
46 + env.ANTHROPIC_API_KEY = 'sk-ant-value-abcd'
47 + const res = await app.inject({ method: 'GET', url: '/api/config' })
48 + expect(res.statusCode).toBe(200)
49 + const body = res.json<ConfigStatus>()
50 + expect(body.mode).toBe('unconfigured')
51 + expect(body.anthropicKeySet).toBe(true)
52 + expect(body.openaiKeySet).toBe(false)
53 + expect(res.payload).not.toContain('sk-ant-value-abcd')
54 + expect(body.anthropicKeyHint).toBe('...abcd')
55 + })
56 +
57 + it('saves both keys and switches the server to real providers', async () => {
58 + const res = await app.inject({
59 + method: 'POST',
60 + url: '/api/config',
61 + payload: { mode: 'real', anthropicKey: 'sk-ant-1234abcd', openaiKey: 'sk-1234wxyz' },
62 + })
63 + expect(res.statusCode).toBe(200)
64 + const body = res.json<ConfigUpdateResponse>()
65 + expect(body.status.mode).toBe('real')
66 + expect(body.restartRequired).toBe(false)
67 + expect(env.ANTHROPIC_API_KEY).toBe('sk-ant-1234abcd')
68 + expect(await readFile(envPath, 'utf8')).toContain('OPENAI_API_KEY=sk-1234wxyz')
69 + })
70 +
71 + it('switches to the offline demo without any key', async () => {
72 + const res = await app.inject({ method: 'POST', url: '/api/config', payload: { mode: 'demo' } })
73 + expect(res.statusCode).toBe(200)
74 + expect(res.json<ConfigUpdateResponse>().status.mode).toBe('demo')
75 + expect(env.MOCK_PROVIDERS).toBe('1')
76 + })
77 +
78 + it('rejects real mode when a key is still missing', async () => {
79 + const res = await app.inject({
80 + method: 'POST',
81 + url: '/api/config',
82 + payload: { mode: 'real', anthropicKey: 'sk-ant-1234abcd' },
83 + })
84 + expect(res.statusCode).toBe(400)
85 + expect(res.json<{ error: string }>().error).toContain('OpenAI')
86 + expect(env.ANTHROPIC_API_KEY).toBeUndefined()
87 + })
88 +
89 + it('rejects a key that contains spaces', async () => {
90 + const res = await app.inject({
91 + method: 'POST',
92 + url: '/api/config',
93 + payload: { mode: 'real', anthropicKey: 'sk-ant abcd', openaiKey: 'sk-1234wxyz' },
94 + })
95 + expect(res.statusCode).toBe(400)
96 + })
97 +
98 + it('keeps a demo-started server on mocks and asks for a restart', async () => {
99 + await app.close()
100 + app = build(true)
101 + env.MOCK_PROVIDERS = '1'
102 +
103 + const res = await app.inject({
104 + method: 'POST',
105 + url: '/api/config',
106 + payload: { mode: 'real', anthropicKey: 'sk-ant-1234abcd', openaiKey: 'sk-1234wxyz' },
107 + })
108 + expect(res.statusCode).toBe(200)
109 + const body = res.json<ConfigUpdateResponse>()
110 + expect(body.restartRequired).toBe(true)
111 + expect(body.status.mode).toBe('demo')
112 + expect(env.ANTHROPIC_API_KEY).toBeUndefined()
113 + })
114 +
115 + it('refuses writes from another origin', async () => {
116 + const res = await app.inject({
117 + method: 'POST',
118 + url: '/api/config',
119 + headers: { origin: 'https://example.com', host: 'localhost:3001' },
120 + payload: { mode: 'demo' },
121 + })
122 + expect(res.statusCode).toBe(403)
123 + expect(env.MOCK_PROVIDERS).toBeUndefined()
124 + })
125 +
126 + it('accepts local and same-host origins only', () => {
127 + expect(isTrustedOrigin(undefined, 'localhost:3001')).toBe(true)
128 + expect(isTrustedOrigin('http://localhost:5173', 'localhost:3001')).toBe(true)
129 + expect(isTrustedOrigin('http://127.0.0.1:3001', 'localhost:3001')).toBe(true)
130 + expect(isTrustedOrigin('http://192.168.1.4:3001', '192.168.1.4:3001')).toBe(true)
131 + expect(isTrustedOrigin('http://192.168.1.4:3001', 'localhost:3001')).toBe(false)
132 + expect(isTrustedOrigin('https://example.com', 'localhost:3001')).toBe(false)
133 + expect(isTrustedOrigin('not-a-url', 'localhost:3001')).toBe(false)
134 + })
135 +})
added server/routes/config.ts +62 −0
@@ -0,0 +1,62 @@
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 +}
10 +
11 +// Keys are writable from the app, so only the local UI may post here. A page on
12 +// another origin gets a browser-supplied Origin that never matches.
13 +export function isTrustedOrigin(origin: string | undefined, host: string | undefined): boolean {
14 + if (!origin) return true
15 + let url: URL
16 + try {
17 + url = new URL(origin)
18 + } catch {
19 + return false
20 + }
21 + const hostname = url.hostname.replace(/^\[|\]$/g, '')
22 + if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') return true
23 + return host !== undefined && url.host === host
24 +}
25 +
26 +export function registerConfigRoutes(app: FastifyInstance, deps: ConfigRouteDeps): void {
27 + const env = deps.env ?? process.env
28 +
29 + app.get('/api/config', async () => configStatus(deps.demoLocked, env))
30 +
31 + app.post('/api/config', async (request, reply) => {
32 + if (!isTrustedOrigin(request.headers.origin, request.headers.host)) {
33 + return reply.code(403).send({ error: 'settings can only be changed from VoiceTask on this computer' })
34 + }
35 +
36 + const parsed = ConfigUpdateRequestSchema.safeParse(request.body)
37 + if (!parsed.success) {
38 + return reply.code(400).send({ error: parsed.error.issues[0]?.message ?? 'invalid settings' })
39 + }
40 +
41 + if (parsed.data.mode === 'real') {
42 + const anthropic = parsed.data.anthropicKey ?? readEnvKey('ANTHROPIC_API_KEY', env)
43 + const openai = parsed.data.openaiKey ?? readEnvKey('OPENAI_API_KEY', env)
44 + const missing: string[] = []
45 + if (!anthropic) missing.push('an Anthropic key for the interview')
46 + if (!openai) missing.push('an OpenAI key for speech-to-text')
47 + if (missing.length > 0) {
48 + return reply.code(400).send({ error: `Still need ${missing.join(' and ')}.` })
49 + }
50 + }
51 +
52 + let result: ConfigUpdateResponse
53 + try {
54 + result = applyConfig(parsed.data, { demoLocked: deps.demoLocked, envPath: deps.envPath, env })
55 + } catch (err) {
56 + const detail = err instanceof Error ? err.message : 'unknown error'
57 + return reply.code(500).send({ error: `Could not save the settings file: ${detail}` })
58 + }
59 +
60 + return reply.send(result)
61 + })
62 +}
modified server/routes/generate.ts +4 −1
@@ -1,7 +1,7 @@
1 1 import type { FastifyInstance } from 'fastify'
2 2 import { GenerateRequestSchema } from '../../shared/types'
3 3 import { GenerateFilesExistError, generateSpecPack } from '../generator/generate'
4 -import type { InterviewLlm } from '../providers/types'
4 +import { ProviderNotConfiguredError, type InterviewLlm } from '../providers/types'
5 5 import type { SessionStore } from '../store/sessionStore'
6 6
7 7 export interface GenerateRouteDeps {
@@ -28,6 +28,9 @@export function registerGenerateRoutes(app: FastifyInstance, deps: GenerateRoute
28 28 if (err instanceof GenerateFilesExistError) {
29 29 return reply.code(409).send({ error: err.message })
30 30 }
31 + if (err instanceof ProviderNotConfiguredError) {
32 + return reply.code(503).send({ error: err.message })
33 + }
31 34 return reply.code(400).send({ error: err instanceof Error ? err.message : 'generation failed' })
32 35 }
33 36 })
modified server/routes/sessions.ts +4 −1
@@ -1,7 +1,7 @@
1 1 import type { FastifyInstance } from 'fastify'
2 2 import { AnswerRequestSchema, CreateSessionRequestSchema } from '../../shared/types'
3 3 import { SessionNotFoundError, submitAnswer } from '../engine/turn'
4 -import type { InterviewLlm } from '../providers/types'
4 +import { ProviderNotConfiguredError, type InterviewLlm } from '../providers/types'
5 5 import type { SessionStore } from '../store/sessionStore'
6 6
7 7 export interface SessionRouteDeps {
@@ -50,6 +50,9 @@export function registerSessionRoutes(app: FastifyInstance, deps: SessionRouteDe
50 50 if (err instanceof SessionNotFoundError) {
51 51 return reply.code(404).send({ error: 'session not found' })
52 52 }
53 + if (err instanceof ProviderNotConfiguredError) {
54 + return reply.code(503).send({ error: err.message })
55 + }
53 56 throw err
54 57 }
55 58 })
modified shared/types.ts +35 −0
@@ -118,6 +118,41 @@export const ErrorResponseSchema = z.object({
118 118 })
119 119 export type ErrorResponse = z.infer<typeof ErrorResponseSchema>
120 120
121 +export const ProviderModeSchema = z.enum(['demo', 'real', 'unconfigured'])
122 +export type ProviderMode = z.infer<typeof ProviderModeSchema>
123 +
124 +export const ConfigStatusSchema = z.object({
125 + mode: ProviderModeSchema,
126 + demoLocked: z.boolean(),
127 + anthropicKeySet: z.boolean(),
128 + openaiKeySet: z.boolean(),
129 + anthropicKeyHint: z.string().nullable(),
130 + openaiKeyHint: z.string().nullable(),
131 +})
132 +export type ConfigStatus = z.infer<typeof ConfigStatusSchema>
133 +
134 +const ApiKeySchema = z
135 + .string()
136 + .trim()
137 + .min(8, 'that key looks too short')
138 + .regex(/^\S+$/, 'a key cannot contain spaces')
139 +
140 +export const ConfigUpdateRequestSchema = z.discriminatedUnion('mode', [
141 + z.object({ mode: z.literal('demo') }),
142 + z.object({
143 + mode: z.literal('real'),
144 + anthropicKey: ApiKeySchema.optional(),
145 + openaiKey: ApiKeySchema.optional(),
146 + }),
147 +])
148 +export type ConfigUpdateRequest = z.infer<typeof ConfigUpdateRequestSchema>
149 +
150 +export const ConfigUpdateResponseSchema = z.object({
151 + status: ConfigStatusSchema,
152 + restartRequired: z.boolean(),
153 +})
154 +export type ConfigUpdateResponse = z.infer<typeof ConfigUpdateResponseSchema>
155 +
121 156 export const FsDirectoryEntrySchema = z.object({
122 157 name: z.string(),
123 158 path: z.string(),
modified spec/PLAN.md +7 −4
@@ -8,7 +8,8 @@
8 8 - LLM: `@anthropic-ai/sdk`. STT: OpenAI REST API via `fetch` inside the provider only (no OpenAI SDK dependency).
9 9 - Validation/schemas: `zod` (shared between API validation and LLM structured output).
10 10 - Tests: `vitest`. Typecheck: `tsc --noEmit`.
11 -- Local demo: `npm run demo` passes an explicit demo flag to the server. The flag is applied after `.env` is read, so it always forces deterministic mock providers.
11 +- Local demo: `npm run demo` passes an explicit demo flag to the server. The flag is applied after `.env` is read, so it always forces deterministic mock providers, and it locks that process on mocks even if keys are saved from the setup screen while it runs.
12 +- Provider setup: keys can be entered in the app. Providers are resolved per request instead of once at boot, so a saved key takes effect without a restart, and a server with no key still starts and serves the setup screen.
12 13
13 14 No other runtime dependencies without a BLOCKED.md entry.
14 15
@@ -20,11 +21,13 @@shared/ types + zod schemas (Session, Segment, Coverage, API payloads)
20 21 the transcript download route and the client's copy-to-clipboard button
21 22 server/
22 23 index.ts Fastify bootstrap, serves client build in prod
23 - routes/ sessions.ts, audio.ts, generate.ts, blockers.ts, fs.ts, export.ts
24 + routes/ sessions.ts, audio.ts, generate.ts, blockers.ts, fs.ts, export.ts, config.ts
25 + config/
26 + runtimeConfig.ts provider mode status, .env merge/write, live switch of process env
24 27 store/ sessionStore.ts (JSON files under data/sessions/<id>/session.json)
25 28 providers/
26 29 types.ts SttProvider, InterviewLlm interfaces
27 - factory.ts env-based selection, MOCK_PROVIDERS=1 forces mocks
30 + factory.ts env-based selection, MOCK_PROVIDERS=1 forces mocks, lazy per-request wrappers
28 31 sttOpenai.ts sttMock.ts
29 32 llmAnthropic.ts llmMock.ts
30 33 engine/
@@ -47,7 +50,7 @@client/
47 50 src/tts.ts speechSynthesis wrapper with browser-locale voice selection
48 51 src/labels.ts plain-language copy for coverage category ids
49 52 src/components/ Transcript, CoveragePanel, QuestionCard, GeneratePanel, FolderBrowser,
50 - TranscriptExport, OutcomePreview
53 + TranscriptExport, OutcomePreview, ProviderSetup
51 54 ```
52 55
53 56 ## Product experience direction
modified spec/SPEC.md +11 −1
@@ -25,6 +25,7 @@P1 (must have):
25 25 - US-12: As a user, I can click once to start recording and click again to send, while still having the Space key as a hold-to-talk shortcut.
26 26 - US-13: As a user, progress is explained in plain words and does not depend on color alone.
27 27 - US-14: As a user, when my build brief is ready, I can download it, copy a ready-to-send handoff message, and understand who to send it to.
28 +- US-17: As a first-time user, I can choose between my own API keys and the offline demo inside the app, without editing a file, running a setup script, or restarting the server.
28 29
29 30 P2 (should have):
30 31 - US-5: As a user, I hear the interviewer's question spoken aloud so I can keep my eyes off the screen.
@@ -78,7 +79,14 @@Blocker loop:
78 79
79 80 Modes and safety:
80 81 - FR-017: With `MOCK_PROVIDERS=1`, both STT and the LLM are replaced by deterministic mocks and the full flow (record or type, interview, generate) works offline with no API keys.
81 -- FR-018: Provider API keys are read from environment variables only and never appear in logs, session files, or generated output.
82 +- FR-018: Provider API keys are read from environment variables only, whether they were set by hand, by `npm run setup`, or by the in-app setup screen, and never appear in logs, session files, or generated output.
83 +
84 +Setup and provider keys:
85 +- FR-031: The server starts even when no provider key is set. It never exits because keys are missing; it reports the unconfigured state and serves the app so setup can finish in the browser.
86 +- FR-032: `GET /api/config` reports the provider mode (`demo`, `real`, or `unconfigured`), whether each key is set, and at most the last four characters of a saved key. Key values are never sent to the client.
87 +- FR-033: `POST /api/config` accepts either the offline demo or one or both provider keys, writes them to the local `.env` while preserving the rest of that file, and applies them to the running server without a restart. It is accepted only from the local app origin. When the server was started with `npm run demo`, keys are saved for the next start, the running process stays on mocks, and the response reports that a restart is needed.
88 +- FR-034: The home screen shows the setup step in place of the create form until a mode is chosen, and shows the current mode in the header with a control to change it at any time.
89 +- FR-035: A request that needs a provider key that is not set fails with a plain-language message pointing at the setup screen, not a generic server error.
82 90
83 91 ## Edge cases
84 92
@@ -95,6 +103,8 @@Modes and safety:
95 103 - Clipboard access denied: keep the download actions available and show a clear copy failure message.
96 104 - A completed session opened after a refresh: detect an existing spec pack and show the ready state without regenerating it.
97 105 - Browser speech voice list is empty on the first question: speak with the browser default and the browser language instead of failing.
106 +- Only one of the two keys is submitted from the setup screen: the missing one is named in the error and nothing is written unless the other one is already saved.
107 +- The local `.env` cannot be written (permissions, read-only checkout): the setup screen shows the write error and the running mode is unchanged.
98 108
99 109 ## Out of scope (v1)
100 110
modified spec/TASKS.md +5 −0
@@ -106,3 +106,8 @@Work strictly in order unless a task's Depends line allows otherwise. One task a
106 106 - Distinguish audio sending from typed-answer waiting in microphone copy. Select speech voices by browser locale and default status instead of hardcoding English. Announce new questions, expose focus on the read-aloud switch, and contain and restore focus in the folder dialog.
107 107 - Depends: T20
108 108 - Verify: `npm test`, `npm run typecheck`, and `npm run build` exit 0. Voice-selection tests cover locale match, default fallback, and an empty voice list.
109 +
110 +- [x] T22 In-app provider setup
111 + - Start the server without keys instead of exiting. Add `GET /api/config` and `POST /api/config` (`server/routes/config.ts`, `server/config/runtimeConfig.ts`): report the provider mode and key hints, save the offline demo or one or both keys into the local `.env` without losing the rest of that file, and apply them to the running process. Resolve providers per request so a saved key works without a restart. A server started with `npm run demo` stores keys but stays on mocks and reports that a restart is needed. Accept writes only from the local app origin. Add `client/src/components/ProviderSetup.tsx`, show it in place of the create form until a mode is chosen, and show the current mode in the home header. Turn a missing key into a plain-language 503 instead of a generic server error.
112 + - Depends: T21
113 + - Verify: `npm test`, `npm run typecheck`, and `npm run build` exit 0. Tests cover the .env merge, demo-locked saving, key hints that never contain the key, the missing-key error, and origin rejection. Manual: with no `.env`, `npm run dev` starts, the browser asks for setup, and saving keys starts an interview without restarting the server.
modified spec/VERIFICATION.md +2 −1
@@ -21,6 +21,7 @@This must complete typechecking, all tests, and the production build with exit c
21 21
22 22 ## Human smoke check
23 23
24 +0. With no `.env` present, run `npm run dev` and confirm the server starts, the home screen asks for setup, and choosing the demo starts an interview without a restart.
24 25 1. Run `npm run demo` and open the printed URL in current Chrome or Edge.
25 26 2. Confirm the home screen explains the three-part flow, local storage, and the source-backed result before the form.
26 27 3. Create a session with a temporary target folder.
@@ -35,7 +36,7 @@This must complete typechecking, all tests, and the production build with exit c
35 36
36 37 ## Optional real-provider check
37 38
38 -With real keys selected by `npm run setup`, run `npm run dev`. Record one sentence and confirm it is transcribed, then confirm the next question is relevant. This check can call paid provider APIs and is not part of automated verification.
39 +With real keys entered on the setup screen or selected by `npm run setup`, run `npm run dev`. Record one sentence and confirm it is transcribed, then confirm the next question is relevant. This check can call paid provider APIs and is not part of automated verification.
39 40
40 41 ## Per-task verification
41 42