profileShare

rasmusjy / voicetask

Read-only snapshot

No repository description.

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

Commit

Add the public demo mode and deployment for voicetask.rasmusj.com

The public instance runs on the offline mock providers, so it has no keys to
lose and settings are read-only there. The deploy target is not in the
repository: set VPS=user@host in your shell before running deploy.sh.
commit 633291b

9 changed files with +336 and −9

Jump to a changed file
  1. Dockerfile +45 −0
  2. deploy.sh +56 −0
  3. docker-compose.yml +38 −0
  4. server/app.ts +44 −2
  5. server/index.ts +14 −4
  6. server/publicMode.test.ts +108 −0
  7. server/routes/config.ts +7 −0
  8. server/routes/sessions.ts +17 −2
  9. server/runtimeMode.ts +7 −1
added Dockerfile +45 −0
@@ -0,0 +1,45 @@
1 +# syntax=docker/dockerfile:1
2 +
3 +# VoiceTask as a public demo: one container serving the built client and the API
4 +# from a single origin, on the deterministic offline mock providers.
5 +#
6 +# The server runs from TypeScript through tsx rather than from tsc output. The
7 +# compiled files keep their extensionless imports, which Node's ESM loader does
8 +# not resolve, and tsx is the same path `npm run demo` uses locally.
9 +
10 +FROM node:24-bookworm-slim AS build
11 +WORKDIR /app
12 +COPY package*.json ./
13 +RUN npm ci
14 +COPY . .
15 +RUN npm run build:client
16 +
17 +FROM node:24-bookworm-slim AS runner
18 +WORKDIR /app
19 +ENV NODE_ENV=production
20 +ENV PORT=3000
21 +# Public mode: no filesystem routes, no settings writes, sessions confined to
22 +# the sandbox below, and the offline mocks locked on.
23 +ENV VOICETASK_PUBLIC=1
24 +ENV VOICETASK_SANDBOX=/app/data/public-sessions
25 +ENV VOICETASK_CLIENT_DIR=/app/dist/client
26 +
27 +COPY package*.json ./
28 +RUN npm ci --omit=dev \
29 + && npm install --no-save "tsx@$(node -p "require('./package.json').devDependencies.tsx")" \
30 + && npm cache clean --force
31 +
32 +COPY server ./server
33 +COPY shared ./shared
34 +COPY --from=build /app/dist/client ./dist/client
35 +
36 +# Every visitor's spec pack is written under here, and nowhere else.
37 +RUN mkdir -p /app/data/public-sessions && chown -R node:node /app/data
38 +
39 +USER node
40 +EXPOSE 3000
41 +
42 +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
43 + CMD node -e "fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
44 +
45 +CMD ["npx", "tsx", "server/index.ts"]
added deploy.sh +56 −0
@@ -0,0 +1,56 @@
1 +#!/usr/bin/env bash
2 +# One-command redeploy for voicetask.rasmusj.com.
3 +#
4 +# Ships the source to the VPS, builds the image there and restarts the
5 +# container. Run from Git Bash on Windows: ./deploy.sh
6 +#
7 +# The shared reverse proxy at /opt/caddy is never touched, so a routine redeploy
8 +# cannot take the other sites on the box down with it.
9 +#
10 +# There is no .env: the public instance runs on the offline mock providers and
11 +# has no keys to lose.
12 +set -euo pipefail
13 +
14 +# Deploy target. Kept out of the repository on purpose: this code is shared
15 +# read-only with people outside the project, and the server address and
16 +# login are not theirs to have. Set it once in your shell:
17 +#
18 +# export VPS=user@host
19 +#
20 +VPS=${VPS:?set VPS=user@host before deploying}
21 +DEST=/opt/projects/voicetask
22 +
23 +cd "$(dirname "$0")"
24 +
25 +echo "==> Running the test suite before shipping anything..."
26 +npm test
27 +
28 +echo "==> Ensuring VPS project dir exists..."
29 +ssh "$VPS" "mkdir -p '$DEST'"
30 +
31 +echo "==> Uploading source (node_modules, dist, data and .env stay out of it)..."
32 +tar --exclude=node_modules --exclude=dist --exclude=data --exclude=.env \
33 + --exclude=.git --exclude=coverage -czf - . | ssh "$VPS" "
34 + rm -rf '$DEST/src.tmp' &&
35 + mkdir -p '$DEST/src.tmp' &&
36 + tar -C '$DEST/src.tmp' -xzf - &&
37 + rm -rf '$DEST/app' &&
38 + mv '$DEST/src.tmp' '$DEST/app'
39 +"
40 +
41 +echo "==> Building and restarting the container..."
42 +ssh "$VPS" "cd '$DEST/app' && docker compose up -d --build"
43 +
44 +echo "==> Waiting for the health check to go green..."
45 +ssh "$VPS" "
46 + for i in \$(seq 1 20); do
47 + state=\$(docker inspect -f '{{.State.Health.Status}}' voicetask 2>/dev/null || echo starting)
48 + [ \"\$state\" = healthy ] && echo ' healthy' && exit 0
49 + sleep 3
50 + done
51 + echo ' still not healthy, check: docker logs voicetask'
52 + exit 1
53 +"
54 +
55 +echo ""
56 +echo "==> Done. Live at https://voicetask.rasmusj.com"
added docker-compose.yml +38 −0
@@ -0,0 +1,38 @@
1 +# voicetask.rasmusj.com
2 +#
3 +# Caddy already runs on the external `web` network and terminates TLS, so
4 +# nothing is published to the host and the domain lives in /opt/caddy/Caddyfile.
5 +#
6 +# No API keys are set, and none can be added over HTTP: the container runs in
7 +# public mode, which locks the offline mock providers on. So the demo shows the
8 +# whole interview-to-spec flow and costs nothing to leave running.
9 +
10 +name: voicetask
11 +
12 +services:
13 + voicetask:
14 + build: .
15 + container_name: voicetask
16 + restart: unless-stopped
17 + environment:
18 + PORT: '3000'
19 + volumes:
20 + # Sessions are throwaway, so they live in a volume rather than the image
21 + # layer and can be cleared by removing it.
22 + - voicetask-sessions:/app/data
23 + networks:
24 + - web
25 + expose:
26 + - '3000'
27 + logging:
28 + driver: json-file
29 + options:
30 + max-size: '10m'
31 + max-file: '3'
32 +
33 +volumes:
34 + voicetask-sessions:
35 +
36 +networks:
37 + web:
38 + external: true
modified server/app.ts +44 −2
@@ -1,5 +1,6 @@
1 1 import cors from '@fastify/cors'
2 2 import multipart from '@fastify/multipart'
3 +import fastifyStatic from '@fastify/static'
3 4 import Fastify, { type FastifyInstance } from 'fastify'
4 5 import { createLazyInterviewLlm, createLazySttProvider } from './providers/factory'
5 6 import type { InterviewLlm, SttProvider } from './providers/types'
@@ -20,6 +21,19 @@export interface AppDeps {
20 21 // Overridable so tests never write the real .env of this repository.
21 22 envPath?: string
22 23 env?: NodeJS.ProcessEnv
24 + /**
25 + * Public deployment mode: the app is served to strangers on the internet
26 + * rather than to the one person sitting at the machine it runs on.
27 + *
28 + * Everything VoiceTask does with the local disk assumes the second case, so
29 + * this mode takes those powers away: no folder browsing, no writing to a path
30 + * the browser chose, no editing the provider keys, and no CORS reflection.
31 + */
32 + publicDemo: boolean
33 + /** Sandbox every session writes into. Required when publicDemo is set. */
34 + sandboxRoot?: string
35 + /** Built client to serve from the same origin as the API. */
36 + clientDir?: string
23 37 }
24 38
25 39 export function buildApp(deps: Partial<AppDeps> = {}): FastifyInstance {
@@ -30,24 +44,52 @@export function buildApp(deps: Partial<AppDeps> = {}): FastifyInstance {
30 44 demoLocked: deps.demoLocked ?? false,
31 45 envPath: deps.envPath,
32 46 env: deps.env,
47 + publicDemo: deps.publicDemo ?? false,
48 + sandboxRoot: deps.sandboxRoot,
49 + clientDir: deps.clientDir,
50 + }
51 +
52 + if (resolved.publicDemo && !resolved.sandboxRoot) {
53 + throw new Error('publicDemo needs a sandboxRoot; refusing to let the browser pick a directory')
33 54 }
34 55
35 56 const app = Fastify({ logger: false })
36 - void app.register(cors, { origin: true })
57 + // A public instance is served from one origin and needs no cross-origin
58 + // access at all; reflecting every Origin back would let any page on the web
59 + // drive it from a visitor's browser.
60 + void app.register(cors, { origin: resolved.publicDemo ? false : true })
37 61 void app.register(multipart, { limits: { fileSize: 25 * 1024 * 1024 } })
38 62
39 63 app.get('/api/health', async () => ({ ok: true }))
40 64 registerSessionRoutes(app, resolved)
41 65 registerAudioRoutes(app, resolved)
42 66 registerGenerateRoutes(app, resolved)
43 67 registerBlockerRoutes(app, resolved)
44 - registerFsRoutes(app)
68 + // Browsing and creating folders is a local convenience. On a public host it
69 + // is an unauthenticated read of the container filesystem, so it is left out.
70 + if (!resolved.publicDemo) registerFsRoutes(app)
45 71 registerExportRoutes(app, resolved)
46 72 registerConfigRoutes(app, {
47 73 demoLocked: resolved.demoLocked,
48 74 envPath: resolved.envPath,
49 75 env: resolved.env,
76 + publicDemo: resolved.publicDemo,
50 77 })
51 78
79 + if (resolved.clientDir) registerClient(app, resolved.clientDir)
80 +
52 81 return app
53 82 }
83 +
84 +/**
85 + * Serve the built client beside the API so both live on one origin. Unknown
86 + * non-API paths fall back to index.html, which is what a single-page app needs
87 + * for a deep link to survive a reload.
88 + */
89 +function registerClient(app: FastifyInstance, root: string): void {
90 + void app.register(fastifyStatic, { root, wildcard: false })
91 + app.setNotFoundHandler((request, reply) => {
92 + if (request.url.startsWith('/api/')) return reply.code(404).send({ error: 'not found' })
93 + return reply.sendFile('index.html')
94 + })
95 +}
modified server/index.ts +14 −4
@@ -1,7 +1,8 @@
1 -import { existsSync, readFileSync } from 'node:fs'
1 +import { existsSync, mkdirSync, readFileSync } from 'node:fs'
2 +import path from 'node:path'
2 3 import { buildApp } from './app'
3 4 import { configStatus } from './config/runtimeConfig'
4 -import { applyDemoMode } from './runtimeMode'
5 +import { applyDemoMode, isPublicDemo } from './runtimeMode'
5 6
6 7 // .env wins over inherited shell values, so a stale MOCK_PROVIDERS=1 in some
7 8 // old terminal cannot silently force mock mode.
@@ -27,9 +28,18 @@function startupMode(): string {
27 28 }
28 29
29 30 async function main() {
30 - const app = buildApp({ demoLocked })
31 + const publicDemo = isPublicDemo(process.env)
32 + let sandboxRoot: string | undefined
33 + if (publicDemo) {
34 + sandboxRoot = process.env.VOICETASK_SANDBOX ?? path.join(process.cwd(), 'data', 'public-sessions')
35 + mkdirSync(sandboxRoot, { recursive: true })
36 + }
37 + const clientDir = process.env.VOICETASK_CLIENT_DIR
38 +
39 + const app = buildApp({ demoLocked, publicDemo, sandboxRoot, clientDir })
31 40 await app.listen({ port: PORT, host: '0.0.0.0' })
32 - console.log(`VoiceTask server listening on :${PORT} using ${startupMode()}`)
41 + const shape = publicDemo ? 'public demo (sandboxed, read-only settings)' : startupMode()
42 + console.log(`VoiceTask server listening on :${PORT} using ${shape}`)
33 43 }
34 44
35 45 main().catch((err) => {
added server/publicMode.test.ts +108 −0
@@ -0,0 +1,108 @@
1 +import { mkdtemp, rm, stat } 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 { buildApp } from './app'
6 +import { applyDemoMode, isPublicDemo } from './runtimeMode'
7 +import { SessionStore } from './store/sessionStore'
8 +import { createLlmMock } from './providers/llmMock'
9 +import { createSttMock } from './providers/sttMock'
10 +
11 +/**
12 + * The public instance is the same app with its local-machine powers removed.
13 + * These tests pin the three that would otherwise be handed to strangers: the
14 + * filesystem routes, the settings writer, and the caller-chosen output folder.
15 + */
16 +describe('public demo mode', () => {
17 + let baseDir: string
18 + let sandboxRoot: string
19 +
20 + async function makeApp(publicDemo: boolean) {
21 + const app = buildApp({
22 + store: new SessionStore(path.join(baseDir, 'sessions')),
23 + llm: createLlmMock(),
24 + stt: createSttMock(),
25 + demoLocked: publicDemo,
26 + publicDemo,
27 + sandboxRoot: publicDemo ? sandboxRoot : undefined,
28 + env: { MOCK_PROVIDERS: '1' },
29 + envPath: path.join(baseDir, '.env'),
30 + })
31 + await app.ready()
32 + return app
33 + }
34 +
35 + beforeEach(async () => {
36 + baseDir = await mkdtemp(path.join(tmpdir(), 'voicetask-public-'))
37 + sandboxRoot = path.join(baseDir, 'sandbox')
38 + })
39 +
40 + afterEach(async () => {
41 + await rm(baseDir, { recursive: true, force: true })
42 + })
43 +
44 + it('does not expose the filesystem routes', async () => {
45 + const app = await makeApp(true)
46 + const browse = await app.inject({ method: 'GET', url: '/api/fs/browse?path=/' })
47 + const mkdirRes = await app.inject({ method: 'POST', url: '/api/fs/mkdir', payload: { path: '/tmp', name: 'x' } })
48 + expect(browse.statusCode).toBe(404)
49 + expect(mkdirRes.statusCode).toBe(404)
50 + await app.close()
51 + })
52 +
53 + it('keeps the filesystem routes for a local run', async () => {
54 + const app = await makeApp(false)
55 + const browse = await app.inject({ method: 'GET', url: `/api/fs/browse?path=${encodeURIComponent(baseDir)}` })
56 + expect(browse.statusCode).toBe(200)
57 + await app.close()
58 + })
59 +
60 + it('refuses to change the provider settings', async () => {
61 + const app = await makeApp(true)
62 + const res = await app.inject({
63 + method: 'POST',
64 + url: '/api/config',
65 + payload: { mode: 'real', anthropicKey: 'sk-test', openaiKey: 'sk-test' },
66 + })
67 + expect(res.statusCode).toBe(403)
68 + await expect(stat(path.join(baseDir, '.env'))).rejects.toThrow()
69 + await app.close()
70 + })
71 +
72 + it('writes sessions into the sandbox and ignores the requested folder', async () => {
73 + const app = await makeApp(true)
74 + const res = await app.inject({
75 + method: 'POST',
76 + url: '/api/sessions',
77 + payload: { name: 'demo', targetDir: '/etc' },
78 + })
79 + expect(res.statusCode).toBe(201)
80 + const session = res.json() as { targetDir: string }
81 + expect(session.targetDir.startsWith(sandboxRoot)).toBe(true)
82 + expect((await stat(session.targetDir)).isDirectory()).toBe(true)
83 + await app.close()
84 + })
85 +
86 + it('answers no cross-origin request', async () => {
87 + const app = await makeApp(true)
88 + const res = await app.inject({
89 + method: 'GET',
90 + url: '/api/health',
91 + headers: { origin: 'https://attacker.example' },
92 + })
93 + expect(res.headers['access-control-allow-origin']).toBeUndefined()
94 + await app.close()
95 + })
96 +
97 + it('locks a public instance to the offline mocks', () => {
98 + const env: NodeJS.ProcessEnv = { VOICETASK_PUBLIC: '1' }
99 + expect(applyDemoMode([], env)).toBe(true)
100 + expect(env.MOCK_PROVIDERS).toBe('1')
101 + expect(isPublicDemo(env)).toBe(true)
102 + expect(isPublicDemo({})).toBe(false)
103 + })
104 +
105 + it('refuses to start a public instance without a sandbox', () => {
106 + expect(() => buildApp({ publicDemo: true })).toThrow(/sandboxRoot/)
107 + })
108 +})
modified server/routes/config.ts +7 −0
@@ -6,6 +6,8 @@export interface ConfigRouteDeps {
6 6 demoLocked: boolean
7 7 envPath?: string
8 8 env?: NodeJS.ProcessEnv
9 + /** A public instance answers with its mode but never lets anyone change it. */
10 + publicDemo?: boolean
9 11 }
10 12
11 13 // Keys are writable from the app, so only the local UI may post here. A page on
@@ -29,6 +31,11 @@export function registerConfigRoutes(app: FastifyInstance, deps: ConfigRouteDeps
29 31 app.get('/api/config', async () => configStatus(deps.demoLocked, env))
30 32
31 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 + }
32 39 if (!isTrustedOrigin(request.headers.origin, request.headers.host)) {
33 40 return reply.code(403).send({ error: 'settings can only be changed from VoiceTask on this computer' })
34 41 }
modified server/routes/sessions.ts +17 −2
@@ -1,3 +1,6 @@
1 +import { randomUUID } from 'node:crypto'
2 +import { mkdir } from 'node:fs/promises'
3 +import path from 'node:path'
1 4 import type { FastifyInstance } from 'fastify'
2 5 import { AnswerRequestSchema, CreateSessionRequestSchema } from '../../shared/types'
3 6 import { SessionNotFoundError, submitAnswer } from '../engine/turn'
@@ -7,17 +10,29 @@import type { SessionStore } from '../store/sessionStore'
7 10 export interface SessionRouteDeps {
8 11 store: SessionStore
9 12 llm: InterviewLlm
13 + /**
14 + * When set, the spec pack is written to a fresh folder inside this directory
15 + * and the `targetDir` the browser sent is ignored. Locally that field is the
16 + * point - you pick where your project lives - but on a public host it is a
17 + * request to write files anywhere the server can reach.
18 + */
19 + sandboxRoot?: string
10 20 }
11 21
12 22 export function registerSessionRoutes(app: FastifyInstance, deps: SessionRouteDeps): void {
13 - const { store, llm } = deps
23 + const { store, llm, sandboxRoot } = deps
14 24
15 25 app.post('/api/sessions', async (request, reply) => {
16 26 const parsed = CreateSessionRequestSchema.safeParse(request.body)
17 27 if (!parsed.success) {
18 28 return reply.code(400).send({ error: parsed.error.message })
19 29 }
20 - const session = await store.createSession(parsed.data.name, parsed.data.targetDir)
30 + let targetDir = parsed.data.targetDir
31 + if (sandboxRoot !== undefined) {
32 + targetDir = path.join(sandboxRoot, randomUUID())
33 + await mkdir(targetDir, { recursive: true })
34 + }
35 + const session = await store.createSession(parsed.data.name, targetDir)
21 36 return reply.code(201).send(session)
22 37 })
23 38
modified server/runtimeMode.ts +7 −1
@@ -1,5 +1,11 @@
1 1 export function applyDemoMode(argv: readonly string[], env: NodeJS.ProcessEnv): boolean {
2 - const enabled = argv.includes('--demo')
2 + // A public instance is always locked to the offline mocks: there are no
3 + // provider keys on it, and nothing a visitor sends can add any.
4 + const enabled = argv.includes('--demo') || env.VOICETASK_PUBLIC === '1'
3 5 if (enabled) env.MOCK_PROVIDERS = '1'
4 6 return enabled
5 7 }
8 +
9 +export function isPublicDemo(env: NodeJS.ProcessEnv): boolean {
10 + return env.VOICETASK_PUBLIC === '1'
11 +}