Commit
Add the public demo mode and deployment for voicetask.rasmusj.com
commit
633291b
9 changed files with +336 and −9
Jump to a changed file
- Dockerfile +45 −0
- deploy.sh +56 −0
- docker-compose.yml +38 −0
- server/app.ts +44 −2
- server/index.ts +14 −4
- server/publicMode.test.ts +108 −0
- server/routes/config.ts +7 −0
- server/routes/sessions.ts +17 −2
- 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 | +} |