From f8e68dda96732947ee537000cc923a3e0e4980c6 Mon Sep 17 00:00:00 2001 From: Bennett Wu <57691028+bennettrwu@users.noreply.github.com> Date: Tue, 29 Apr 2025 21:16:15 -0500 Subject: [PATCH 01/14] node-server docker container --- node-server/Dockerfile | 17 +++++++++++++ node-server/src/index.ts | 14 +++++++++++ .../src/shared/config/config_schema.ts | 24 +++++++++---------- node-server/src/shared/config/load_config.ts | 2 +- node-server/template.env | 1 + 5 files changed, 45 insertions(+), 13 deletions(-) create mode 100644 node-server/Dockerfile diff --git a/node-server/Dockerfile b/node-server/Dockerfile new file mode 100644 index 0000000..6dfc50f --- /dev/null +++ b/node-server/Dockerfile @@ -0,0 +1,17 @@ +FROM node:20 + +WORKDIR /app + +COPY . . + +RUN npm install +RUN npm run build + +WORKDIR /app/build/src + +ENV NODE_ENV="production" +ENV LOG_DIR="/app/logs" +ENV KEY_FILEPATH="/app/cert/key.pem" +ENV CERTIFICATE_FILEPATH="/app/cert/cert.pem" + +CMD ["node", "index.js"] \ No newline at end of file diff --git a/node-server/src/index.ts b/node-server/src/index.ts index 03a7656..174a502 100644 --- a/node-server/src/index.ts +++ b/node-server/src/index.ts @@ -24,6 +24,20 @@ async function init() { logger.fatal({msg: 'Failed to start webserver', err}); throw err; // terminate if fails to start } + + // Close connection and server on SIGTERM/SIGINT for a graceful exit + process.on('SIGTERM', async () => { + logger.info('SIGTERM received, exiting gracefully.'); + await server.close(); + // eslint-disable-next-line n/no-process-exit + process.exit(); + }); + process.on('SIGINT', async () => { + logger.info('SIGINT received, exiting gracefully.'); + await server.close(); + // eslint-disable-next-line n/no-process-exit + process.exit(); + }); } await init(); diff --git a/node-server/src/shared/config/config_schema.ts b/node-server/src/shared/config/config_schema.ts index ee51999..bd04c6c 100644 --- a/node-server/src/shared/config/config_schema.ts +++ b/node-server/src/shared/config/config_schema.ts @@ -17,27 +17,27 @@ export enum LogLevel { // Define environment schema export const SCHEMA = Type.Object({ - NODE_ENV: Type.Enum(NodeEnv), + NODE_ENV: Type.Enum(NodeEnv, {default: NodeEnv.Production}), - LOG_LEVEL: Type.Enum(LogLevel), + LOG_LEVEL: Type.Enum(LogLevel, {default: LogLevel.Info}), HOST: Type.String({default: 'localhost'}), - PORT: Type.Number({default: 8000}), + PORT: Type.Number({default: 8080}), USE_HTTPS: Type.Boolean({default: false}), KEY_FILEPATH: Type.String({default: ''}), CERTIFICATE_FILEPATH: Type.String({default: ''}), - CORS_ORIGIN: Type.String(), - SERVER_ADDRESS: Type.String(), + CORS_ORIGIN: Type.String({default: '*'}), + SERVER_ADDRESS: Type.String({default: '127.0.0.1:8080'}), WHISPER_SERVICE_ENDPOINT: Type.String(), - WHISPER_RECONNECT_INTERVAL: Type.Number({default: 1000}), + WHISPER_RECONNECT_INTERVAL_SEC: Type.Number({default: 1}), - REQUIRE_AUTH: Type.Boolean(), - ACCESS_TOKEN_BYTES: Type.Number(), - ACCESS_TOKEN_REFRESH_INTERVAL_SEC: Type.Number(), - ACCESS_TOKEN_VALID_PERIOD_SEC: Type.Number(), - SESSION_TOKEN_BYTES: Type.Number(), - SESSION_LENGTH_SEC: Type.Number(), + REQUIRE_AUTH: Type.Boolean({default: true}), + ACCESS_TOKEN_BYTES: Type.Number({default: 32}), + ACCESS_TOKEN_REFRESH_INTERVAL_SEC: Type.Number({default: 150}), + ACCESS_TOKEN_VALID_PERIOD_SEC: Type.Number({default: 300}), + SESSION_TOKEN_BYTES: Type.Number({default: 8}), + SESSION_LENGTH_SEC: Type.Number({default: 5400}), }); export type ConfigType = Readonly<{ diff --git a/node-server/src/shared/config/load_config.ts b/node-server/src/shared/config/load_config.ts index fe1919e..f815009 100644 --- a/node-server/src/shared/config/load_config.ts +++ b/node-server/src/shared/config/load_config.ts @@ -33,7 +33,7 @@ export default function loadConfig(path?: string): ConfigType { }, whisper: { endpoint: env.WHISPER_SERVICE_ENDPOINT, - reconnectInterval: env.WHISPER_RECONNECT_INTERVAL, + reconnectInterval: env.WHISPER_RECONNECT_INTERVAL_SEC * 1000, }, auth: { required: env.REQUIRE_AUTH, diff --git a/node-server/template.env b/node-server/template.env index 7893082..b6aa25f 100644 --- a/node-server/template.env +++ b/node-server/template.env @@ -13,6 +13,7 @@ SERVER_ADDRESS="127.0.0.1:8080" #### URL to whisper service WHISPER_SERVICE_ENDPOINT="ws://127.0.0.1:8000/whisper?api_key=CHANGEME&model_key=faster-whisper:cpu-tiny-en" +WHISPER_RECONNECT_INTERVAL_SEC=1 #### Authentication settings # Enable or disable authentication From cb2bf6063bc040196b1a925b187d93d36bee69f6 Mon Sep 17 00:00:00 2001 From: Bennett Wu <57691028+bennettrwu@users.noreply.github.com> Date: Tue, 29 Apr 2025 21:16:28 -0500 Subject: [PATCH 02/14] whisper-service docker container --- whisper-service/Dockerfile_CPU | 9 +++++++++ .../models/faster_whisper_model_requirements.txt | 2 -- whisper-service/requirements.txt | 6 +++++- 3 files changed, 14 insertions(+), 3 deletions(-) create mode 100644 whisper-service/Dockerfile_CPU delete mode 100644 whisper-service/models/faster_whisper_model_requirements.txt diff --git a/whisper-service/Dockerfile_CPU b/whisper-service/Dockerfile_CPU new file mode 100644 index 0000000..aba2801 --- /dev/null +++ b/whisper-service/Dockerfile_CPU @@ -0,0 +1,9 @@ +FROM python:3.12 + +WORKDIR /app + +COPY . . + +RUN pip install -r requirements.txt + +CMD ["python", "index.py"] \ No newline at end of file diff --git a/whisper-service/models/faster_whisper_model_requirements.txt b/whisper-service/models/faster_whisper_model_requirements.txt deleted file mode 100644 index 6ccc366..0000000 --- a/whisper-service/models/faster_whisper_model_requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -faster-whisper==1.1.1 -ctranslate2==4.4.0 diff --git a/whisper-service/requirements.txt b/whisper-service/requirements.txt index a291c31..c3e2b9c 100644 --- a/whisper-service/requirements.txt +++ b/whisper-service/requirements.txt @@ -8,4 +8,8 @@ pytest==8.3.5 pytest-cov==6.1.1 pylint==3.3.6 httpx==0.28.1 -pytest-asyncio==0.25.3 \ No newline at end of file +pytest-asyncio==0.25.3 + +# faster-whisper models +faster-whisper==1.1.1 +ctranslate2==4.4.0 From e28744ec80048294d7c14f3070aa5b2e1fa849b9 Mon Sep 17 00:00:00 2001 From: Bennett Wu <57691028+bennettrwu@users.noreply.github.com> Date: Tue, 29 Apr 2025 21:25:27 -0500 Subject: [PATCH 03/14] whisper-server dockerfile for cuda --- whisper-service/Dockerfile_CUDA | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 whisper-service/Dockerfile_CUDA diff --git a/whisper-service/Dockerfile_CUDA b/whisper-service/Dockerfile_CUDA new file mode 100644 index 0000000..f6feb14 --- /dev/null +++ b/whisper-service/Dockerfile_CUDA @@ -0,0 +1,12 @@ +FROM nvidia/cuda:12.8.1-cudnn-runtime-ubuntu24.04 + +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update && apt-get install -y python3 python3-pip + +WORKDIR /app + +COPY . . + +RUN pip install -r requirements.txt + +CMD ["python3", "index.py"] \ No newline at end of file From 7bd6815b925e4c8f0538a80e24ae00d79b3569ba Mon Sep 17 00:00:00 2001 From: Bennett Wu <57691028+bwu1324@users.noreply.github.com> Date: Tue, 29 Apr 2025 22:37:52 -0500 Subject: [PATCH 04/14] fix dockerfile for cuda --- node-server/.dockerignore | 133 +++++++++++++++++++++++ whisper-service/.dockerignore | 174 +++++++++++++++++++++++++++++++ whisper-service/Dockerfile_CUDA | 2 +- whisper-service/create_server.py | 4 +- whisper-service/model_factory.py | 21 +--- 5 files changed, 315 insertions(+), 19 deletions(-) create mode 100644 node-server/.dockerignore create mode 100644 whisper-service/.dockerignore diff --git a/node-server/.dockerignore b/node-server/.dockerignore new file mode 100644 index 0000000..e908e4e --- /dev/null +++ b/node-server/.dockerignore @@ -0,0 +1,133 @@ +build/ +coverage/ + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* \ No newline at end of file diff --git a/whisper-service/.dockerignore b/whisper-service/.dockerignore new file mode 100644 index 0000000..1800114 --- /dev/null +++ b/whisper-service/.dockerignore @@ -0,0 +1,174 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc \ No newline at end of file diff --git a/whisper-service/Dockerfile_CUDA b/whisper-service/Dockerfile_CUDA index f6feb14..31ac24b 100644 --- a/whisper-service/Dockerfile_CUDA +++ b/whisper-service/Dockerfile_CUDA @@ -1,4 +1,4 @@ -FROM nvidia/cuda:12.8.1-cudnn-runtime-ubuntu24.04 +FROM nvidia/cuda:12.2.2-cudnn8-runtime-ubuntu22.04 ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update && apt-get install -y python3 python3-pip diff --git a/whisper-service/create_server.py b/whisper-service/create_server.py index cf9be2d..444490e 100644 --- a/whisper-service/create_server.py +++ b/whisper-service/create_server.py @@ -10,13 +10,13 @@ from typing import Annotated, Callable from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query from model_bases.transcription_model_base import TranscriptionModelBase -from model_factory import ModelKey, model_factory +from model_factory import model_factory from load_config import AppConfig, load_config def create_server( config: AppConfig, - model_factory_func: Callable[[ModelKey, WebSocket], TranscriptionModelBase] + model_factory_func: Callable[[str, WebSocket], TranscriptionModelBase] ) -> FastAPI: ''' Instanciates FastAPI webserver. diff --git a/whisper-service/model_factory.py b/whisper-service/model_factory.py index 7ea6063..1ac92d5 100644 --- a/whisper-service/model_factory.py +++ b/whisper-service/model_factory.py @@ -8,36 +8,25 @@ ModelKey ''' # pylint: disable=import-outside-toplevel -from enum import StrEnum from fastapi import WebSocket from model_bases.transcription_model_base import TranscriptionModelBase - -class ModelKey(StrEnum): - ''' - Unique identifiers for supported whisper models - ''' - MOCK_TRANSCRIPTION_DURATION = "mock-transcription-duration" - FASTER_WHISPER_GPU_LARGE_V3 = "faster-whisper:gpu-large-v3" - FASTER_WHISPER_CPU_TINY_EN = "faster-whisper:cpu-tiny-en" - - -def model_factory(model_key: ModelKey, websocket: WebSocket) -> TranscriptionModelBase: +def model_factory(model_key: str, websocket: WebSocket) -> TranscriptionModelBase: ''' Instantiates model with corresponding ModelKey. Parameters: - model_key (ModelKey) : Unique identifier for model to instantiate + model_key (str) : Unique identifier for model to instantiate websocket (WebSocket): Websocket requesting model Returns: A TranscriptionModelBase instance ''' match model_key: - case ModelKey.MOCK_TRANSCRIPTION_DURATION: + case 'mock-transcription-duration': from models.mock_transcription_duration import MockTranscribeDuration return MockTranscribeDuration(websocket) - case ModelKey.FASTER_WHISPER_GPU_LARGE_V3: + case 'faster-whisper:gpu-large-v3': from models.faster_whisper_model import FasterWhisperModel return FasterWhisperModel( websocket, @@ -46,7 +35,7 @@ def model_factory(model_key: ModelKey, websocket: WebSocket) -> TranscriptionMod local_agree_dim=2, min_new_samples=FasterWhisperModel.SAMPLE_RATE * 3 ) - case ModelKey.FASTER_WHISPER_CPU_TINY_EN: + case "faster-whisper:cpu-tiny-en": from models.faster_whisper_model import FasterWhisperModel return FasterWhisperModel( websocket, From f81f57b368433981666403e5aa964343b67a9849 Mon Sep 17 00:00:00 2001 From: Bennett Wu <57691028+bennettrwu@users.noreply.github.com> Date: Wed, 30 Apr 2025 05:16:37 -0500 Subject: [PATCH 05/14] update readme --- node-server/README.md | 116 +++++++++++++++++++++++------------------- 1 file changed, 63 insertions(+), 53 deletions(-) diff --git a/node-server/README.md b/node-server/README.md index 7ccb08f..2315017 100644 --- a/node-server/README.md +++ b/node-server/README.md @@ -1,79 +1,89 @@ # ScribeAR Node Server -A backend service for ScribeAR to handle authenticating and rebroadcasting transcription events. Built in Node.js with Fastify. +A backend service for ScribeAR to handle authenticating and rebroadcasting transcription events. Users connect to node-server via websocket to send audio and/or receive transcriptions. When node-server receives wav audio chunks from the frontend it sends them to whisper-service to be transcribed. When it receives transcriptions back, it forwards them to all clients that are listening for transcriptions. -## Getting Started +# Getting Started -### Prerequisites +node-server can either be run via Docker or locally. See [Running via Docker](#running-via-docker) to run with docker or [Running Locally](#running-locally) to run locally. If you'd like to develop node-server, see [Developing](#developing). -* Node.js (tested with Node 20) +# Usage -### Installing dependencies +## Running via Docker -1. Install dependencies - ``` - npm install - ``` +* TODO: Instructions for pulling container image and running -### Configuration +## Running Locally -* Make a copy of `template.env` and name it `.env` -* Edit `.env` to configure server. See [Configuration Options](#configuration-options) for details. +### Local Setup -## Developing +1. Make sure you have Node.js installed. node-service has been tested to work with Node 20. +2. Install Node dependencies + ``` + npm install + ``` +3. Make a copy of `template.env` and name it `.env` +4. Edit `.env` to configure server. See [Configuration Options](#configuration-options) for details. -**Running in development mode** +### Running Server -1. Ensure dependencies are installed -2. Configure service in `.env` -3. Start webserver +1. Ensure that the app is installed and configured locally (See [Local Setup](#local-setup)) +2. If you want to run node-server ``` - npm run dev + npm start ``` -**Running unit tests** +# Developing + +## Running in Development Mode -1. Ensure dependencies are installed -2. Configure service in `.env` -3. Run tests +You can run node-server in development mode for easy to read logs and so that the server is automatically restarted when you save changes. This uses `tsc` to build the app, `tsc-watch` to watch for changes, and `pino-pretty` to output nice looking logs. + +1. Ensure that the app is installed and configured locally (See [Local Setup](#local-setup)) +2. Start webserver ``` - npm run test + npm run dev ``` -## Usage +## Running Unit Tests -**Running in production mode** +Unit tests are run via `vitest` with code coverage via istanbul. See `vitest.config.ts` for test configuration. -1. Ensure dependencies are installed -2. Configure service in `.env` -3. Start webserver +1. Ensure dependencies are installed (See [Local Setup](#local-setup)) +2. Run tests ``` - npm start + npm run test ``` -## Documentation - -### Configuration Options - -| Option | Options | Description | -| - | - | - | -| `NODE_ENV` | `development`, `production`, `test` | Indicates the environment service is running in. Currently unused. (TODO: API documentation endpoint in dev mode) | -| `LOG_LEVEL` | `error`, `warn`, `info`, `debug`, `trace`, `silent` | Sets the verbosity of logging. | -| `HOST` | `ip address` | The socket the whisper service will bind to. Use `0.0.0.0` to make available to local network, `127.0.0.1` to localhost only. | -| `PORT` | `number` | Port number that whisper service will listen for connections on. Should match the port node server is trying to connect to. | -| `USE_HTTPS` | `boolean` | Whether to use HTTPS or not | -| `KEY_FILEPATH` | `string` | File path to the private key file for use in when `USE_HTTPS` is set to `true` | -| `CERTIFICATE_FILEPATH` | `string` | File path to the certificate file for use in when `USE_HTTPS` is set to `true` | -| `CORS_ORIGIN` | `string` | Cors origin configuration for node server. | -| `SERVER_ADDRESS` | `string` | Address the node server is reachable at. Used for ScribeAR QR code to allow other device to connect. | -| `WHISPER_SERVICE_ENDPOINT` | `websocket address` | Websocket address for whisper service endpoint. Should be in the format:
`ws://{ADDRESS}:{PORT}/whisper?api_key={API_KEY}&model_key={MODEL_KEY}`
ADDRESS is the address or ip of the whisper service. For an all-in-one deployment, this is `127.0.0.1` (localhost).
`PORT` is the port the whisper service is listening on. This should match what the whisper service is configured to use.
`API_KEY` is the api key for the whisper service. This should match what the whisper service is configured to use.
`MODEL_KEY` is the model key of the model that the whisper service should run. See [Model Implementations and Model Keys](../whisper-service/README.md#model-implementations-and-model-keys) for more information. | -| `REQUIRE_AUTH` | `true`, `false` | If `true`, requires authentication to connect to node server api, otherwise no authentication is used. See [Authentication](#authentication) for details. | -| `ACCESS_TOKEN_REFRESH_INTERVAL_SEC` | `number` | Number of seconds to wait before generating a new refresh token. See [Authentication](#authentication) for details. | -| `ACCESS_TOKEN_BYTES` | `number` | The number of random bytes used to generate access tokens | -| `ACCESS_TOKEN_VALID_PERIOD_SEC` | `number` | Number of seconds a newly generated refresh token is valid for. See [Authentication](#authentication) for details. | -| `SESSION_TOKEN_BYTES` | `number` | The number of random bytes used to generate session tokens | -| `SESSION_LENGTH_SEC` | `number` | Number of seconds a newly generated session token is valid for. See [Authentication](#authentication) for details. | - -### Authentication +# Documentation + +## Configuration Options + +The following options can be configured via environment variable. + +| Option | Values | Default | Description | +| - | - | - | - | +|**Runtime Options**||| +| `NODE_ENV` | `development`, `production`, `test` | `production` | Indicates the environment service is running in. | +| `LOG_LEVEL` | `error`, `warn`, `info`, `debug`, `trace`, `silent` | `info` | Sets the verbosity of logging. | +|**Server Options**||| +| `HOST` | `ip address` | `127.0.0.1` | The socket the whisper service will bind to. Use `0.0.0.0` to make available to local network, `127.0.0.1` to localhost only. | +| `PORT` | `number` | `8080` | Port number that whisper service will listen for connections on. Should match the port node server is trying to connect to. | +| `CORS_ORIGIN` | `string` | `*` | Cors origin configuration for node server. | +| `SERVER_ADDRESS` | `string` | `127.0.0.1:8080` | Address the node server is reachable at. Used for ScribeAR QR code to allow other device to connect. | +| `USE_HTTPS` | `boolean` | `false` | Whether to use HTTPS or not | +| `KEY_FILEPATH` | `string` | Empty string | File path to the private key file for use in when `USE_HTTPS` is set to `true` | +| `CERTIFICATE_FILEPATH` | `string` | Empty string | File path to the certificate file for use in when `USE_HTTPS` is set to `true` | +|**Whisper Service Options**||| +| `WHISPER_SERVICE_ENDPOINT` | `websocket address` | Required, no default value | Websocket address for whisper service endpoint. Should be in the format:
`ws://{ADDRESS}:{PORT}/whisper?api_key={API_KEY}&model_key={MODEL_KEY}`
ADDRESS is the address or ip of the whisper service. For an all-in-one deployment, this is `127.0.0.1` (localhost).
`PORT` is the port the whisper service is listening on. This should match what the whisper service is configured to use.
`API_KEY` is the api key for the whisper service. This should match what the whisper service is configured to use.
`MODEL_KEY` is the model key of the model that the whisper service should run. See [Model Implementations and Model Keys](../whisper-service/README.md#model-implementations-and-model-keys) for more information. | +| `WHISPER_RECONNECT_INTERVAL` | `number` | `1` | Number of seconds to wait before attempting to reconnect to whisper service. Server implements exponential backoff, so interval will double each time connection fails up to a maximum of 30 seconds. | +|**Authentication Options**||| +| `REQUIRE_AUTH` | `true`, `false` | `true` | If `true`, requires authentication to connect to node server api, otherwise no authentication is used. See [Authentication](#authentication) for details. | +| `ACCESS_TOKEN_REFRESH_INTERVAL_SEC` | `number` | `150` | Number of seconds to wait before generating a new refresh token. See [Authentication](#authentication) for details. | +| `ACCESS_TOKEN_BYTES` | `number` | `32` | The number of random bytes used to generate access tokens | +| `ACCESS_TOKEN_VALID_PERIOD_SEC` | `number` | `300` | Number of seconds a newly generated refresh token is valid for. See [Authentication](#authentication) for details. | +| `SESSION_TOKEN_BYTES` | `number` | `8` | The number of random bytes used to generate session tokens | +| `SESSION_LENGTH_SEC` | `number` | `5400` | Number of seconds a newly generated session token is valid for. See [Authentication](#authentication) for details. | + +## Authentication * TODO: Explain authentication strategy here. \ No newline at end of file From 39f47276055e23b0ce8db1fb16827a3aaa523ed0 Mon Sep 17 00:00:00 2001 From: Bennett Wu <57691028+bennettrwu@users.noreply.github.com> Date: Wed, 30 Apr 2025 05:17:01 -0500 Subject: [PATCH 06/14] update config loading to better check types --- node-server/package-lock.json | 217 ++++++++---------- node-server/package.json | 2 +- node-server/src/index.ts | 23 ++ .../services/request_authorizer.test.ts | 20 +- .../src/server/services/request_authorizer.ts | 30 ++- .../services/transcription_engine.test.ts | 2 +- .../server/services/transcription_engine.ts | 9 +- node-server/src/server/start_server.ts | 2 +- .../src/shared/config/config_schema.ts | 88 ++++--- node-server/src/shared/config/load_config.ts | 32 ++- 10 files changed, 246 insertions(+), 179 deletions(-) diff --git a/node-server/package-lock.json b/node-server/package-lock.json index e7816ab..c06c476 100644 --- a/node-server/package-lock.json +++ b/node-server/package-lock.json @@ -14,9 +14,9 @@ "@fastify/sensible": "6.0.3", "@fastify/websocket": "11.0.2", "@sinclair/typebox": "0.34.15", + "ajv": "8.17.1", "axios": "1.7.9", "dotenv": "16.4.7", - "env-schema": "6.0.1", "fastify": "5.2.1", "http": "0.0.1-security", "pino": "9.6.0", @@ -819,6 +819,30 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/@eslint/js": { "version": "9.19.0", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.19.0.tgz", @@ -860,26 +884,6 @@ "fast-uri": "^3.0.0" } }, - "node_modules/@fastify/ajv-compiler/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@fastify/ajv-compiler/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" - }, "node_modules/@fastify/cors": { "version": "11.0.1", "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.0.1.tgz", @@ -1968,15 +1972,15 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -1999,26 +2003,6 @@ } } }, - "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" - }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -2520,6 +2504,7 @@ "version": "16.4.7", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "license": "BSD-2-Clause", "engines": { "node": ">=12" }, @@ -2527,14 +2512,6 @@ "url": "https://dotenvx.com" } }, - "node_modules/dotenv-expand": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-10.0.0.tgz", - "integrity": "sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==", - "engines": { - "node": ">=12" - } - }, "node_modules/duplexer": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", @@ -2578,46 +2555,6 @@ "once": "^1.4.0" } }, - "node_modules/env-schema": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/env-schema/-/env-schema-6.0.1.tgz", - "integrity": "sha512-WRD40Q25pP4NUbI3g3CNU5PPzcaiX7YYcPwiCZlfR4qGsKmTlckRixgHww0/fOXiXSNKA87pwshzq0ULTK/48A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "dependencies": { - "ajv": "^8.12.0", - "dotenv": "^16.4.5", - "dotenv-expand": "10.0.0" - } - }, - "node_modules/env-schema/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/env-schema/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" - }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -2919,6 +2856,23 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/eslint/node_modules/eslint-visitor-keys": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", @@ -2931,6 +2885,13 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/espree": { "version": "10.3.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", @@ -3126,7 +3087,8 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/fast-json-stringify": { "version": "6.0.0", @@ -3142,36 +3104,11 @@ "rfdc": "^1.2.0" } }, - "node_modules/fast-json-stringify/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/fast-json-stringify/node_modules/ajv/node_modules/fast-uri": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.3.tgz", - "integrity": "sha512-aLrHthzCjH5He4Z2H9YZ+v6Ujb9ocRuW6ZzkJQOrTxleEijANq4v1TsaPaVG1PZcuurEzrLcWRyYBYXD5cEiaw==" - }, "node_modules/fast-json-stringify/node_modules/fast-uri": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-2.4.0.tgz", "integrity": "sha512-ypuAmmMKInk5q7XcepxlnUWDLWv4GFtaJqAzWKqn62IpQ3pejtr5dTVbt3vwqVaMKmkNR55sTT+CqUKIaT21BA==" }, - "node_modules/fast-json-stringify/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" - }, "node_modules/fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", @@ -3852,6 +3789,23 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/gts/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/gts/node_modules/eslint": { "version": "8.57.1", "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", @@ -4004,6 +3958,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gts/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/gts/node_modules/type-fest": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", @@ -4474,10 +4435,10 @@ } }, "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", @@ -5295,6 +5256,7 @@ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -6469,6 +6431,7 @@ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" } diff --git a/node-server/package.json b/node-server/package.json index ce8d234..6c20ff4 100644 --- a/node-server/package.json +++ b/node-server/package.json @@ -46,9 +46,9 @@ "@fastify/sensible": "6.0.3", "@fastify/websocket": "11.0.2", "@sinclair/typebox": "0.34.15", + "ajv": "8.17.1", "axios": "1.7.9", "dotenv": "16.4.7", - "env-schema": "6.0.1", "fastify": "5.2.1", "http": "0.0.1-security", "pino": "9.6.0", diff --git a/node-server/src/index.ts b/node-server/src/index.ts index 174a502..aafdd92 100644 --- a/node-server/src/index.ts +++ b/node-server/src/index.ts @@ -5,6 +5,7 @@ import createLogger from './shared/logger/logger.js'; async function init() { const config = loadConfig(); const logger = createLogger(config); + if (config.isDevelopment) logger.debug({msg: 'App configuration', config}); const server = createServer(config, logger); process.on('uncaughtException', err => { @@ -41,3 +42,25 @@ async function init() { } await init(); + +// import {Type} from '@sinclair/typebox'; +// import {Value} from '@sinclair/typebox/value'; + +// const TEST = Type.Intersect([ +// Type.Union([ +// Type.Object({ +// flag: Type.Literal(false), +// }), +// Type.Object({ +// flag: Type.Literal(true), +// key: Type.String({minLength: 1}), +// }), +// ]), +// Type.Object({ +// other: Type.String(), +// }), +// ]); + +// const env = Value.Convert(TEST, {flag: false, key: '90', other: 'asdf'}); + +// console.log(env); diff --git a/node-server/src/server/services/request_authorizer.test.ts b/node-server/src/server/services/request_authorizer.test.ts index 719b360..95bfb08 100644 --- a/node-server/src/server/services/request_authorizer.test.ts +++ b/node-server/src/server/services/request_authorizer.test.ts @@ -13,10 +13,10 @@ describe('Request authorizer', () => { auth: { required: true, accessTokenBytes: 8, - accessTokenRefreshIntervalMS: 1_000, - accessTokenValidPeriodMS: 10_000, + accessTokenRefreshIntervalSec: 1, + accessTokenValidPeriodSec: 10, sessionTokenBytes: 32, - sessionLengthMS: 30_000, + sessionLengthSec: 30, }, } as ConfigType, fakeLogger(), @@ -200,7 +200,10 @@ describe('Request authorizer', () => { describe('Authorization override', it => { it('always accepts access tokens', () => { - const ra = new RequestAuthorizer({auth: {required: false, accessTokenBytes: 8}} as ConfigType, fakeLogger()); + const ra = new RequestAuthorizer( + {auth: {required: false, accessTokenBytes: 8}} as unknown as ConfigType, + fakeLogger(), + ); const {accessToken} = ra.getAccessToken(); @@ -210,7 +213,7 @@ describe('Request authorizer', () => { it('always accepts session tokens', () => { const ra = new RequestAuthorizer( - {auth: {required: false, accessTokenBytes: 8, sessionTokenBytes: 32}} as ConfigType, + {auth: {required: false, accessTokenBytes: 8, sessionTokenBytes: 32}} as unknown as ConfigType, fakeLogger(), ); @@ -221,7 +224,10 @@ describe('Request authorizer', () => { }); it('overrides localhost authorizer', async () => { - const ra = new RequestAuthorizer({auth: {required: false, accessTokenBytes: 8}} as ConfigType, fakeLogger()); + const ra = new RequestAuthorizer( + {auth: {required: false, accessTokenBytes: 8}} as unknown as ConfigType, + fakeLogger(), + ); const fastify = Fastify(); fastify.decorate('requestAuthorizer', ra); @@ -235,7 +241,7 @@ describe('Request authorizer', () => { it('overrides session token authorizer', async () => { const ra = new RequestAuthorizer( - {auth: {required: false, accessTokenBytes: 8, sessionTokenBytes: 32}} as ConfigType, + {auth: {required: false, accessTokenBytes: 8, sessionTokenBytes: 32}} as unknown as ConfigType, fakeLogger(), ); const fastify = Fastify(); diff --git a/node-server/src/server/services/request_authorizer.ts b/node-server/src/server/services/request_authorizer.ts index 6582629..85976d8 100644 --- a/node-server/src/server/services/request_authorizer.ts +++ b/node-server/src/server/services/request_authorizer.ts @@ -3,6 +3,8 @@ import type {Logger} from '@shared/logger/logger.js'; import type {DoneFuncWithErrOrRes, FastifyReply, FastifyRequest} from 'fastify'; import crypto from 'node:crypto'; +const MAX_TIMESTAMP = 8640000000000000; + export default class RequestAuthorizer { private _validAccessTokens: {[key: string]: Date} = {}; private _validSessionTokens: {[key: string]: Date} = {}; @@ -14,9 +16,11 @@ export default class RequestAuthorizer { ) { this._updateAccessTokens(); - setInterval(() => { - this._updateAccessTokens(); - }, this._config.auth.accessTokenRefreshIntervalMS); + if (this._config.auth.required) { + setInterval(() => { + this._updateAccessTokens(); + }, this._config.auth.accessTokenRefreshIntervalSec * 1000); + } this.authorizeLocalhost = this.authorizeLocalhost.bind(this); this.authorizeSessionToken = this.authorizeSessionToken.bind(this); @@ -27,10 +31,11 @@ export default class RequestAuthorizer { * Updates this._currentAccessToken, this._validAccessTokens, and this._validSessionTokens */ private _updateAccessTokens() { + if (!this._config.auth.required) return; this._log.debug('Updating access tokens'); this._currentAccessToken = crypto.randomBytes(this._config.auth.accessTokenBytes).toString('base64url'); - const expiry = new Date(Date.now() + this._config.auth.accessTokenValidPeriodMS); + const expiry = new Date(Date.now() + this._config.auth.accessTokenValidPeriodSec * 1000); this._validAccessTokens[this._currentAccessToken] = expiry; this._log.trace({msg: 'Created new access token', accessToken: this._currentAccessToken, expiry}); @@ -48,6 +53,17 @@ export default class RequestAuthorizer { } } + /** + * Computes the expiration date of a new session token + * @returns expiry date + */ + private _computeNewSessionExpiry() { + if (!this._config.auth.required) { + return new Date(MAX_TIMESTAMP); + } + return new Date(Date.now() + this._config.auth.sessionLengthSec * 1000); + } + /** * Gets the currently active access token and the expiration of said access token * @returns object containg access token and expiry date @@ -112,11 +128,15 @@ export default class RequestAuthorizer { * @returns created session token */ createSessionToken() { + if (!this._config.auth.required) { + return {sessionToken: '', expires: this._computeNewSessionExpiry()}; + } let sessionToken = crypto.randomBytes(this._config.auth.sessionTokenBytes).toString('base64url'); while (sessionToken in this._validSessionTokens) { sessionToken = crypto.randomBytes(this._config.auth.sessionTokenBytes).toString('base64url'); } - const expires = new Date(Date.now() + this._config.auth.sessionLengthMS); + + const expires = this._computeNewSessionExpiry(); this._validSessionTokens[sessionToken] = expires; this._log.trace({msg: 'Creating new session token', sessionToken, expires}); diff --git a/node-server/src/server/services/transcription_engine.test.ts b/node-server/src/server/services/transcription_engine.test.ts index 98e9ddc..257982b 100644 --- a/node-server/src/server/services/transcription_engine.test.ts +++ b/node-server/src/server/services/transcription_engine.test.ts @@ -32,7 +32,7 @@ describe('Transcription engine', it => { { whisper: { endpoint: address, - reconnectInterval: 1_000, + reconnectIntervalSec: 1, }, } as ConfigType, fakeLogger(), diff --git a/node-server/src/server/services/transcription_engine.ts b/node-server/src/server/services/transcription_engine.ts index ed7b455..2b944cd 100644 --- a/node-server/src/server/services/transcription_engine.ts +++ b/node-server/src/server/services/transcription_engine.ts @@ -22,13 +22,14 @@ export type AudioTranscriptEvents = { export default class TranscriptionEngine extends TypedEmitter { private _ws?: WebSocket; private _reconnectInterval: number; + private _resetReconnectIntervalTimeout?: NodeJS.Timeout; constructor( private _config: ConfigType, private _log: Logger, ) { super(); - this._reconnectInterval = this._config.whisper.reconnectInterval; + this._reconnectInterval = this._config.whisper.reconnectIntervalSec * 1000; this._connectWhisperService(); } @@ -36,6 +37,7 @@ export default class TranscriptionEngine extends TypedEmitter { @@ -51,7 +53,10 @@ export default class TranscriptionEngine extends TypedEmitter { this._log.info('Connected to whisper service'); - this._reconnectInterval = this._config.whisper.reconnectInterval; + + this._resetReconnectIntervalTimeout = setTimeout(() => { + this._reconnectInterval = this._config.whisper.reconnectIntervalSec * 1000; + }, 30_000); }); this._ws.on('message', data => { diff --git a/node-server/src/server/start_server.ts b/node-server/src/server/start_server.ts index fee8e47..01537e8 100644 --- a/node-server/src/server/start_server.ts +++ b/node-server/src/server/start_server.ts @@ -30,7 +30,7 @@ export default function createServer(config: ConfigType, logger: Logger) { loggerInstance: logger, https: { key: fs.readFileSync(config.server.keyPath), - cert: fs.readFileSync(config.server.certificatePath), + cert: fs.readFileSync(config.server.certPath), }, }) as unknown as FastifyInstance; } else { diff --git a/node-server/src/shared/config/config_schema.ts b/node-server/src/shared/config/config_schema.ts index bd04c6c..fe9485a 100644 --- a/node-server/src/shared/config/config_schema.ts +++ b/node-server/src/shared/config/config_schema.ts @@ -16,30 +16,58 @@ export enum LogLevel { } // Define environment schema -export const SCHEMA = Type.Object({ +const RUNTIME_CONFIG = Type.Object({ NODE_ENV: Type.Enum(NodeEnv, {default: NodeEnv.Production}), - LOG_LEVEL: Type.Enum(LogLevel, {default: LogLevel.Info}), +}); +const SERVER_CONFIG = Type.Object({ HOST: Type.String({default: 'localhost'}), PORT: Type.Number({default: 8080}), - USE_HTTPS: Type.Boolean({default: false}), - KEY_FILEPATH: Type.String({default: ''}), - CERTIFICATE_FILEPATH: Type.String({default: ''}), CORS_ORIGIN: Type.String({default: '*'}), SERVER_ADDRESS: Type.String({default: '127.0.0.1:8080'}), +}); - WHISPER_SERVICE_ENDPOINT: Type.String(), - WHISPER_RECONNECT_INTERVAL_SEC: Type.Number({default: 1}), +const HTTPS_CONFIG = Type.Union([ + Type.Object({ + USE_HTTPS: Type.Literal(false), + KEY_FILEPATH: Type.Any(), + CERTIFICATE_FILEPATH: Type.Any(), + }), + Type.Object({ + USE_HTTPS: Type.Literal(true), + KEY_FILEPATH: Type.String({minLength: 1}), + CERTIFICATE_FILEPATH: Type.String({minLength: 1}), + }), +]); - REQUIRE_AUTH: Type.Boolean({default: true}), - ACCESS_TOKEN_BYTES: Type.Number({default: 32}), - ACCESS_TOKEN_REFRESH_INTERVAL_SEC: Type.Number({default: 150}), - ACCESS_TOKEN_VALID_PERIOD_SEC: Type.Number({default: 300}), - SESSION_TOKEN_BYTES: Type.Number({default: 8}), - SESSION_LENGTH_SEC: Type.Number({default: 5400}), +const WHISPER_CONFIG = Type.Object({ + WHISPER_SERVICE_ENDPOINT: Type.String({minLength: 1}), + WHISPER_RECONNECT_INTERVAL_SEC: Type.Number({default: 1}), }); +const AUTH_CONFIG = Type.Union([ + Type.Object({ + REQUIRE_AUTH: Type.Const(false), + ACCESS_TOKEN_BYTES: Type.Any(), + ACCESS_TOKEN_REFRESH_INTERVAL_SEC: Type.Any(), + ACCESS_TOKEN_VALID_PERIOD_SEC: Type.Any(), + SESSION_TOKEN_BYTES: Type.Any(), + SESSION_LENGTH_SEC: Type.Any(), + }), + Type.Object({ + REQUIRE_AUTH: Type.Const(true), + ACCESS_TOKEN_BYTES: Type.Number({minimum: 1}), + ACCESS_TOKEN_REFRESH_INTERVAL_SEC: Type.Number({minimum: 1}), + ACCESS_TOKEN_VALID_PERIOD_SEC: Type.Number({minimum: 1}), + SESSION_TOKEN_BYTES: Type.Number({minimum: 1}), + SESSION_LENGTH_SEC: Type.Number({minimum: 1}), + }), +]); + +// Merge all configs into one schema +export const SCHEMA = Type.Intersect([RUNTIME_CONFIG, SERVER_CONFIG, HTTPS_CONFIG, WHISPER_CONFIG, AUTH_CONFIG]); + export type ConfigType = Readonly<{ nodeEnv: NodeEnv; isDevelopment: boolean; @@ -50,22 +78,30 @@ export type ConfigType = Readonly<{ server: { host: string; port: number; - useHttps: boolean; - keyPath: string; - certificatePath: string; corsOrigin: string; serverAddress: string; - }; + } & ( + | { + useHttps: false; + } + | { + useHttps: true; + keyPath: string; + certPath: string; + } + ); whisper: { endpoint: string; - reconnectInterval: number; - }; - auth: { - required: boolean; - accessTokenBytes: number; - accessTokenRefreshIntervalMS: number; - accessTokenValidPeriodMS: number; - sessionTokenBytes: number; - sessionLengthMS: number; + reconnectIntervalSec: number; }; + auth: + | {required: false} + | { + required: true; + accessTokenBytes: number; + accessTokenRefreshIntervalSec: number; + accessTokenValidPeriodSec: number; + sessionTokenBytes: number; + sessionLengthSec: number; + }; }>; diff --git a/node-server/src/shared/config/load_config.ts b/node-server/src/shared/config/load_config.ts index f815009..47e9720 100644 --- a/node-server/src/shared/config/load_config.ts +++ b/node-server/src/shared/config/load_config.ts @@ -1,6 +1,7 @@ +import {Ajv} from 'ajv'; import type {Static} from '@sinclair/typebox'; import {NodeEnv, SCHEMA, type ConfigType} from './config_schema.js'; -import envSchema from 'env-schema'; +import {configDotenv} from 'dotenv'; /** * Loads environment configuration file from privated path @@ -9,11 +10,24 @@ import envSchema from 'env-schema'; * @returns configuration */ export default function loadConfig(path?: string): ConfigType { - const env = envSchema>({ - dotenv: {path}, - schema: SCHEMA, + // Load from .env file + configDotenv({path}); + + const ajv = new Ajv({ + allErrors: true, + useDefaults: true, + coerceTypes: true, + allowUnionTypes: true, }); + const env = Object.assign({}, process.env) as unknown as Static; + const valid = ajv.validate(SCHEMA, env); + if (!valid) { + const error = new Error('Invalid Configuration! ' + ajv.errorsText()); + (error as unknown as {errors: unknown}).errors = ajv.errors; + throw error; + } + // Application configuration object const config: ConfigType = Object.freeze({ nodeEnv: env.NODE_ENV, @@ -27,21 +41,21 @@ export default function loadConfig(path?: string): ConfigType { port: env.PORT, useHttps: env.USE_HTTPS, keyPath: env.KEY_FILEPATH, - certificatePath: env.CERTIFICATE_FILEPATH, + certPath: env.CERTIFICATE_FILEPATH, corsOrigin: env.CORS_ORIGIN, serverAddress: env.SERVER_ADDRESS, }, whisper: { endpoint: env.WHISPER_SERVICE_ENDPOINT, - reconnectInterval: env.WHISPER_RECONNECT_INTERVAL_SEC * 1000, + reconnectIntervalSec: env.WHISPER_RECONNECT_INTERVAL_SEC, }, auth: { required: env.REQUIRE_AUTH, accessTokenBytes: env.ACCESS_TOKEN_BYTES, - accessTokenRefreshIntervalMS: env.ACCESS_TOKEN_REFRESH_INTERVAL_SEC * 1000, - accessTokenValidPeriodMS: env.ACCESS_TOKEN_VALID_PERIOD_SEC * 1000, + accessTokenRefreshIntervalSec: env.ACCESS_TOKEN_REFRESH_INTERVAL_SEC, + accessTokenValidPeriodSec: env.ACCESS_TOKEN_VALID_PERIOD_SEC, sessionTokenBytes: env.SESSION_TOKEN_BYTES, - sessionLengthMS: env.SESSION_LENGTH_SEC * 1000, + sessionLengthSec: env.SESSION_LENGTH_SEC, }, }); From 964484b589aa9771af8358687bdefb75c0f0b75a Mon Sep 17 00:00:00 2001 From: Bennett Wu <57691028+bennettrwu@users.noreply.github.com> Date: Wed, 30 Apr 2025 05:41:50 -0500 Subject: [PATCH 07/14] update readme for docker containers --- node-server/README.md | 34 +++++++++- whisper-service/README.md | 138 +++++++++++++++++++++----------------- 2 files changed, 109 insertions(+), 63 deletions(-) diff --git a/node-server/README.md b/node-server/README.md index 2315017..b54b3f8 100644 --- a/node-server/README.md +++ b/node-server/README.md @@ -4,7 +4,7 @@ A backend service for ScribeAR to handle authenticating and rebroadcasting trans # Getting Started -node-server can either be run via Docker or locally. See [Running via Docker](#running-via-docker) to run with docker or [Running Locally](#running-locally) to run locally. If you'd like to develop node-server, see [Developing](#developing). +node-server can either be run via Docker or locally. See [Running via Docker](#running-via-docker) to run with docker or [Running Locally](#running-locally) to run locally. If you'd like to develop node-server, see [Developing](#developing). Additional documentation can be found at [Documentation](#documentation). # Usage @@ -27,7 +27,7 @@ node-server can either be run via Docker or locally. See [Running via Docker](#r ### Running Server 1. Ensure that the app is installed and configured locally (See [Local Setup](#local-setup)) -2. If you want to run node-server +2. Run node-server ``` npm start ``` @@ -46,7 +46,7 @@ You can run node-server in development mode for easy to read logs and so that th ## Running Unit Tests -Unit tests are run via `vitest` with code coverage via istanbul. See `vitest.config.ts` for test configuration. +Unit tests are run via `vitest` with code coverage via istanbul. See `vitest.config.ts` for test configuration. A Github Action runs unit tests when a PR is created, make sure tests pass before creating a PR. 1. Ensure dependencies are installed (See [Local Setup](#local-setup)) 2. Run tests @@ -54,6 +54,34 @@ Unit tests are run via `vitest` with code coverage via istanbul. See `vitest.con npm run test ``` +## Running Linter + +`eslint` is used to lint code to ensure consistent code style. A Github Action runs linter when a PR is created to enforce this, make sure linter is successful before creating a PR. + +1. Ensure that the app is installed and configured locally (See [Local Setup](#local-setup)) +2. Run linter + ``` + npm run lint + ``` +3. `eslint` can attempt to automatically fix linter issues + ``` + npm run fix + ``` + +## Build Docker Container + +To test your local changes in a Docker container you can build it locally. A Github Action builds Docker container when a PR is created, make sure build is successful before creating a PR. + +1. Ensure you have Docker installed. +2. Build container + ``` + docker -t scribear-node-server -f ./Dockerfile . + ``` +3. Run container (additional configuration can be passed in via `--env` flag) + ``` + docker run --env WHISPER_SERVICE_ENDPOINT=[your-whisper-service-endpoint] --env HOST=0.0.0.0 -p 8080:8080 scribear-node-server:latest + ``` + # Documentation ## Configuration Options diff --git a/whisper-service/README.md b/whisper-service/README.md index 04004f5..046c79b 100644 --- a/whisper-service/README.md +++ b/whisper-service/README.md @@ -1,54 +1,70 @@ # ScribeAR Whisper Service -A backend service for ScribeAR to generate transcriptions from a WAV audio stream. Supports several whisper implementations. Flexible interface allows easy integration with new models. Built in Python with FastAPI. +A backend service for ScribeAR to generate transcriptions from a WAV audio stream. Uses a flexible interface Python to allow for multiple transcription backends. -## Getting Started +# Getting Started -### Prerequisites +whisper-service can either be run via Docker or locally. See [Running via Docker](#running-via-docker) to run with docker or [Running Locally](#running-locally) to run locally. If you'd like to develop node-server, see [Developing](#developing). Additional documentation can be found at [Documentation](#documentation). -* Python 3 (tested with Python 3.12) +# Usage -### Installing dependencies +## Running via Docker -0. (optional) Create python virtual environment -1. Install global python dependencies - ``` - pip install -r requirements.txt - ``` +* TODO: Instructions for pulling container image and running -2. Per model implementation dependencies. Each model implementation has its own `{model_implementation}_requirements.txt` found in the `/models` directory. Install the dependencies for the models you'd like to run. See [Model Implementations and Model Keys](#model-implementations-and-model-keys) for details. +## Running Locally + +### Local Setup + +1. Make sure you ahve Python 3 installed. whisper-service has been tested to work with with Python 3.12. +2. (optional) Create python virtual environment +3. Install global python dependencies ``` - pip install -r models/{model_implementation}_requirements.txt + pip install -r requirements.txt ``` +4. Make a copy of `template.env` and name it `.env` +5. Edit `.env` to configure server. See [Configuration Options](#configuration-options) for details. -### Configuration +### Running Server -* Make a copy of `template.env` and name it `.env` -* Edit `.env` to configure server. See [Configuration Options](#configuration-options) for details. +1. Ensure that the app is installed and configured locally (See [Local Setup](#local-setup)) +2. Run whisper-service + ``` + python index.py + ``` -## Developing +# Developing -**Run unit tests** +## Running in Development Mode -1. Ensure that you have installed global dependencies dependencies -2. Run tests with pytest +You can run whisper-service in development mode so that the server is automatically restarted when you save changes. + +1. Ensure that the app is installed and configured locally (See [Local Setup](#local-setup)) +2. Start webserver ``` - pytest + fastapi dev create_server.py ``` -**Test coverage** +## Running Unit Tests -1. Ensure that you have installed global dependencies dependencies -2. Run tests with pytest - ``` - pytest --cov=. --cov-report=html - ``` -3. Coverage results can be found in `htmlcov` folder +Unit tests are run using `pytest`. Test coverage stats can be seen by passing additional arguments to `pytest`. A Github Action runs unit tests when a PR is created, make sure tests pass before creating a PR. -**Linting** +1. Ensure that the app is installed and configured locally (See [Local Setup](#local-setup)) +2. + * Run tests with pytest without code coverage + ``` + pytest + ``` + * Run tests with pytest with code coverage. Coverage results can be found in `htmlcov` folder + ``` + pytest --cov=. --cov-report=html + ``` -1. Ensure that you have installed global dependencies dependencies +## Running Linter +`pylint` is used to lint code to ensure consistent code style. A Github Action runs linter when a PR is created to enforce this, make sure linter is successful before creating a PR. + +1. Ensure that the app is installed and configured locally (See [Local Setup](#local-setup)) 2. * For a single file: ``` @@ -59,46 +75,48 @@ A backend service for ScribeAR to generate transcriptions from a WAV audio strea pylint $(git ls-files '*.py') ``` +## Build Docker Container -**Implementing a new model** +To test your local changes in a Docker container you can build it locally. A Github Action builds Docker container when a PR is created, make sure build is successful before creating a PR. + +1. Ensure you have Docker installed. +2. Build container + * For CPU only container + ``` + docker -t scribear-whisper-service -f ./Dockerfile_CPU . + ``` + * For CUDA support + ``` + docker -t scribear-whisper-service-cuda -f ./Dockerfile_CUDA . + ``` +3. Run container (additional configuration can be passed in via `--env` flag) + * For CPU only container + ``` + docker run --env PORT=8080 --env HOST=0.0.0.0 --env API_KEY=CHANGEME -p 8080:8080 scribear-whisper-service:latest + ``` + * For CUDA support + ``` + docker run --env PORT=8080 --env HOST=0.0.0.0 --env API_KEY=CHANGEME -p 8080:8080 --gpus all scribear-whisper-service-cuda:latest + ``` +## Implementing a New Model 1. Implement `TranscriptionModelBase`. See `/model_bases/transcription_model_base.py` for required methods and usage. * Other model bases found in `/model_bases` can be helpful for implementing commonly used functions. -2. Create `{model_implementation}_requirements.txt` and populate with python dependencies for your model. +2. Add python dependencies for your model to `requirements.txt`. 3. Associate [model key(s)](#model-implementations-and-model-keys) to your model implementation in `model_factory.py`. -**Running in development mode** - -1. Ensure dependencies are installed -2. Configure service in `.env` -3. Start webserver - ``` - fastapi dev create_server.py - ``` - -## Usage - -**Running in production mode** - -1. Ensure dependencies are installed -2. Configure service in `.env` -3. Start webserver - ``` - python index.py - ``` - -## Documentation +# Documentation -### Configuration Options +## Configuration Options -| Option | Options | Description | -|-------------|--------------------------|----------------------------------------------------------------------------------------------------------------------------------------------| -| `LOG_LEVEL` | `info`, `debug`, `trace` | Sets the verbosity of logging. | +| Option | Options | Description | +|-------------|--------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------| +| `LOG_LEVEL` | `info`, `debug`, `trace` | Sets the verbosity of logging. | | `API_KEY` | `string` | The api key that must be passed to whisper-service in order to establish a connection. Should match `api_key=` url parameter for node server. | -| `HOST` | `ip address` | The socket the whisper service will bind to. Use `0.0.0.0` to make available to local network, `127.0.0.1` to localhost only. | -| `PORT` | `number` | Port number that whisper service will listen for connections on. Should match the port node server is trying to connect to. | +| `HOST` | `ip address` | The socket the whisper service will bind to. Use `0.0.0.0` to make available to local network, `127.0.0.1` to localhost only. | +| `PORT` | `number` | Port number that whisper service will listen for connections on. Should match the port node server is trying to connect to. | -### Model Implementations and Model Keys +## Model Implementations and Model Keys Each model key (e.g. `faster-whisper:cpu-tiny-en`, `faster-whisper:gpu-tiny-en`) must be mapped to an model implementation (e.g. `faster-whisper`, `mock-transcription-duration`). @@ -111,5 +129,5 @@ Below is a table of model keys and model implementations | Model Key | Model Implementation | Description | |-------------------------------|-------------------------------|---------------------------------------------------------------------------------------------------------------------------| | `mock_transcription_duration` | `mock-transcription-duration` | This model does not perform transcriptions. It returns the transcription blocks describing the duration of audio recieved | -| `faster-whisper:gpu-large-v3` | `faster-whisper` | Run faster whisper using large-v3 model with 2-dim local agreement in 3 second chunks on gpu | +| `faster-whisper:gpu-large-v3` | `faster-whisper` | Run faster whisper using large-v3 model with 2-dim local agreement in 3 second chunks on gpu | | `faster-whisper:cpu-tiny-en` | `faster-whisper` | Run faster whisper using tiny.en model with 2-dim local agreement in 3 second chunks on cpu | From 8c08a91dcd3a7b7d40a5384b83d6d079ae1edc35 Mon Sep 17 00:00:00 2001 From: Bennett Wu <57691028+bennettrwu@users.noreply.github.com> Date: Wed, 30 Apr 2025 06:39:17 -0500 Subject: [PATCH 08/14] create docker compose file --- .env | 31 ++++++++++++++ README.md | 36 +++++++++++++--- compose_cpu.yaml | 41 +++++++++++++++++++ compose_cuda.yaml | 41 +++++++++++++++++++ node-server/Dockerfile | 2 - node-server/README.md | 14 +++---- .../src/shared/config/config_schema.ts | 7 ---- node-server/src/shared/config/load_config.ts | 6 ++- node-server/template.env | 6 +-- 9 files changed, 157 insertions(+), 27 deletions(-) create mode 100644 .env create mode 100644 compose_cpu.yaml create mode 100644 compose_cuda.yaml diff --git a/.env b/.env new file mode 100644 index 0000000..9b85a44 --- /dev/null +++ b/.env @@ -0,0 +1,31 @@ +NODE_ENV="production" +LOG_LEVEL="info" + +#### Host and port API webserver should listen on +HOST="0.0.0.0" +PORT=8080 +CORS_ORIGIN='*' +SERVER_ADDRESS="127.0.0.1:8080" +# Enable or disable HTTPS +USE_HTTPS=false +KEY_FILEPATH='' +CERTIFICATE_FILEPATH='' + +WHISPER_RECONNECT_INTERVAL_SEC=1 + +#### Authentication settings +# Enable or disable authentication +REQUIRE_AUTH=false +# How many bytes of random data should be used to generate access token +ACCESS_TOKEN_BYTES=8 +# How often access token should be refreshed in seconds +ACCESS_TOKEN_REFRESH_INTERVAL_SEC=150 +# How long a single access token is valid for after generation in seconds +ACCESS_TOKEN_VALID_PERIOD_SEC=300 +# How many bytes of random data should be used to generate session token +SESSION_TOKEN_BYTES=32 +# How long a single session token is valid for after generation in seconds +SESSION_LENGTH_SEC=5400 + +MODEL_KEY="faster-whisper:cpu-tiny-en" +API_KEY="CHANGEME" \ No newline at end of file diff --git a/README.md b/README.md index e9f4a38..22e8f46 100644 --- a/README.md +++ b/README.md @@ -2,17 +2,41 @@ Backend for ScribeAR. Handles transcribing audio stream and rebroadcasting transcriptions to multiple devices. -## Getting Started +# Getting Started -* See `README.md` in `/node-server` and `/whisper-service` folders for detailed instructions for installation, development, and usage. +See the `README.md` files in `/node-server` and `/whisper-service` folders for detailed instructions for installation, configuration, development, and usage of node-server and whisper-service. See [Setup](#setup) -## Usage +# Setup ScribeAR Server -### All-in-one Deployment +## Docker Deployment -Deploys node-server, whisper-service, and ScribeAR frontend to be running on the same system. +1. Install Docker using official methods + * https://www.docker.com/ + * If you'd like to use CUDA, make sure you have the Nvidia Container Toolkit installed as well + * https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/index.html +2. Clone this repository + ``` + git clone https://github.com/scribear/ScribeAR-NodeServer + ``` +3. Make a copy of `template.env` and name it `.env` +4. Edit `.env` to configure deployment. + * Configuration matches that of [Node Server Configuration](./node-server/README.md#configuration-options) and [Whisper Service Configuration](./whisper-service/README.md#configuration-options) with a few exceptions, noted below. + * `HOST` and `PORT` for whisper-service and node-server are disabled. + * `MODEL_KEY` is added for node-server. This is the [model key](#model-implementations-and-model-keys) that node-server will use when connecting to whisper-service. + * `WHISPER_SERVICE_ENDPOINT` for node-server is removed and instead automatically generated using `MODEL_KEY` and `API_KEY`. +5. Start containers via Docker compose + * For CPU only deployment + ``` + docker compose -f ./compose_cpu.yaml up -d + ``` + * For CUDA deployment + ``` + docker compose -f ./compose_cpu.yaml up -d + ``` -**Setup** +## Local All-in-one Deployment + +Deploys node-server, whisper-service, and ScribeAR frontend to be running on the same system. 1. Install Node 20, Python 3, and Google Chrome using official methods * https://nodejs.org/en/download diff --git a/compose_cpu.yaml b/compose_cpu.yaml new file mode 100644 index 0000000..a64f373 --- /dev/null +++ b/compose_cpu.yaml @@ -0,0 +1,41 @@ +name: 'scribear-server' +services: + node-server: + build: + context: ./node-server + dockerfile: Dockerfile + environment: + - NODE_ENV=${NODE_ENV} + - LOG_LEVEL=${LOG_LEVEL} + - HOST=0.0.0.0 + - PORT=8080 + - USE_HTTPS=${USE_HTTPS} + - CORS_ORIGIN=${CORS_ORIGIN} + - SERVER_ADDRESS=${SERVER_ADDRESS} + - WHISPER_SERVICE_ENDPOINT=ws://whisper-service:8000/whisper?api_key=${API_KEY}&model_key=${MODEL_KEY} + - WHISPER_RECONNECT_INTERVAL_SEC=${WHISPER_RECONNECT_INTERVAL_SEC} + - REQUIRE_AUTH=${REQUIRE_AUTH} + - ACCESS_TOKEN_BYTES=${ACCESS_TOKEN_BYTES} + - ACCESS_TOKEN_REFRESH_INTERVAL_SEC=${ACCESS_TOKEN_REFRESH_INTERVAL_SEC} + - ACCESS_TOKEN_VALID_PERIOD_SEC=${ACCESS_TOKEN_VALID_PERIOD_SEC} + - SESSION_TOKEN_BYTES=${SESSION_TOKEN_BYTES} + - SESSION_LENGTH_SEC=${SESSION_LENGTH_SEC} + ports: + - ${PORT}:8080 + volumes: + - ${KEY_FILEPATH:-/dev/null}:/app/cert/key.pem + - ${CERTIFICATE_FILEPATH:-/dev/null}:/app/cert/key.pem + restart: unless-stopped + + whisper-service: + build: + context: ./whisper-service + dockerfile: Dockerfile_CPU + environment: + - LOG_LEVEL=${LOG_LEVEL} + - API_KEY=${API_KEY} + - HOST=0.0.0.0 + - PORT=8000 + expose: + - 8000 + restart: unless-stopped \ No newline at end of file diff --git a/compose_cuda.yaml b/compose_cuda.yaml new file mode 100644 index 0000000..462918f --- /dev/null +++ b/compose_cuda.yaml @@ -0,0 +1,41 @@ +name: 'scribear-server' +services: + node-server: + build: + context: ./node-server + dockerfile: Dockerfile + environment: + - NODE_ENV=${NODE_ENV} + - LOG_LEVEL=${LOG_LEVEL} + - HOST=0.0.0.0 + - PORT=8080 + - USE_HTTPS=${USE_HTTPS} + - CORS_ORIGIN=${CORS_ORIGIN} + - SERVER_ADDRESS=${SERVER_ADDRESS} + - WHISPER_SERVICE_ENDPOINT=ws://whisper-service:8000/whisper?api_key=${API_KEY}&model_key=${MODEL_KEY} + - WHISPER_RECONNECT_INTERVAL_SEC=${WHISPER_RECONNECT_INTERVAL_SEC} + - REQUIRE_AUTH=${REQUIRE_AUTH} + - ACCESS_TOKEN_BYTES=${ACCESS_TOKEN_BYTES} + - ACCESS_TOKEN_REFRESH_INTERVAL_SEC=${ACCESS_TOKEN_REFRESH_INTERVAL_SEC} + - ACCESS_TOKEN_VALID_PERIOD_SEC=${ACCESS_TOKEN_VALID_PERIOD_SEC} + - SESSION_TOKEN_BYTES=${SESSION_TOKEN_BYTES} + - SESSION_LENGTH_SEC=${SESSION_LENGTH_SEC} + ports: + - ${PORT}:8080 + volumes: + - ${KEY_FILEPATH:-/dev/null}:/app/cert/key.pem + - ${CERTIFICATE_FILEPATH:-/dev/null}:/app/cert/key.pem + restart: unless-stopped + + whisper-service: + build: + context: ./whisper-service + dockerfile: Dockerfile_CUDA + environment: + - LOG_LEVEL=${LOG_LEVEL} + - API_KEY=${API_KEY} + - HOST=0.0.0.0 + - PORT=8000 + expose: + - 8000 + restart: unless-stopped \ No newline at end of file diff --git a/node-server/Dockerfile b/node-server/Dockerfile index 6dfc50f..51bb7bd 100644 --- a/node-server/Dockerfile +++ b/node-server/Dockerfile @@ -9,8 +9,6 @@ RUN npm run build WORKDIR /app/build/src -ENV NODE_ENV="production" -ENV LOG_DIR="/app/logs" ENV KEY_FILEPATH="/app/cert/key.pem" ENV CERTIFICATE_FILEPATH="/app/cert/cert.pem" diff --git a/node-server/README.md b/node-server/README.md index b54b3f8..c5d0830 100644 --- a/node-server/README.md +++ b/node-server/README.md @@ -99,18 +99,18 @@ The following options can be configured via environment variable. | `CORS_ORIGIN` | `string` | `*` | Cors origin configuration for node server. | | `SERVER_ADDRESS` | `string` | `127.0.0.1:8080` | Address the node server is reachable at. Used for ScribeAR QR code to allow other device to connect. | | `USE_HTTPS` | `boolean` | `false` | Whether to use HTTPS or not | -| `KEY_FILEPATH` | `string` | Empty string | File path to the private key file for use in when `USE_HTTPS` is set to `true` | -| `CERTIFICATE_FILEPATH` | `string` | Empty string | File path to the certificate file for use in when `USE_HTTPS` is set to `true` | +| `KEY_FILEPATH` | `string` | Required if `USE_HTTPS=true`, no default value | File path to the private key file for use in when `USE_HTTPS` is set to `true` | +| `CERTIFICATE_FILEPATH` | `string` | Required if `USE_HTTPS=true`, no default value | File path to the certificate file for use in when `USE_HTTPS` is set to `true` | |**Whisper Service Options**||| | `WHISPER_SERVICE_ENDPOINT` | `websocket address` | Required, no default value | Websocket address for whisper service endpoint. Should be in the format:
`ws://{ADDRESS}:{PORT}/whisper?api_key={API_KEY}&model_key={MODEL_KEY}`
ADDRESS is the address or ip of the whisper service. For an all-in-one deployment, this is `127.0.0.1` (localhost).
`PORT` is the port the whisper service is listening on. This should match what the whisper service is configured to use.
`API_KEY` is the api key for the whisper service. This should match what the whisper service is configured to use.
`MODEL_KEY` is the model key of the model that the whisper service should run. See [Model Implementations and Model Keys](../whisper-service/README.md#model-implementations-and-model-keys) for more information. | | `WHISPER_RECONNECT_INTERVAL` | `number` | `1` | Number of seconds to wait before attempting to reconnect to whisper service. Server implements exponential backoff, so interval will double each time connection fails up to a maximum of 30 seconds. | |**Authentication Options**||| | `REQUIRE_AUTH` | `true`, `false` | `true` | If `true`, requires authentication to connect to node server api, otherwise no authentication is used. See [Authentication](#authentication) for details. | -| `ACCESS_TOKEN_REFRESH_INTERVAL_SEC` | `number` | `150` | Number of seconds to wait before generating a new refresh token. See [Authentication](#authentication) for details. | -| `ACCESS_TOKEN_BYTES` | `number` | `32` | The number of random bytes used to generate access tokens | -| `ACCESS_TOKEN_VALID_PERIOD_SEC` | `number` | `300` | Number of seconds a newly generated refresh token is valid for. See [Authentication](#authentication) for details. | -| `SESSION_TOKEN_BYTES` | `number` | `8` | The number of random bytes used to generate session tokens | -| `SESSION_LENGTH_SEC` | `number` | `5400` | Number of seconds a newly generated session token is valid for. See [Authentication](#authentication) for details. | +| `ACCESS_TOKEN_REFRESH_INTERVAL_SEC` | `number` | Required if `REQUIRED_AUTH=true`, no default value | Number of seconds to wait before generating a new refresh token. See [Authentication](#authentication) for details. | +| `ACCESS_TOKEN_BYTES` | `number` | Required if `REQUIRED_AUTH=true`, no default value | The number of random bytes used to generate access tokens | +| `ACCESS_TOKEN_VALID_PERIOD_SEC` | `number` | Required if `REQUIRED_AUTH=true`, no default value | Number of seconds a newly generated refresh token is valid for. See [Authentication](#authentication) for details. | +| `SESSION_TOKEN_BYTES` | `number` | Required if `REQUIRED_AUTH=true`, no default value | The number of random bytes used to generate session tokens | +| `SESSION_LENGTH_SEC` | `number` | Required if `REQUIRED_AUTH=true`, no default value | Number of seconds a newly generated session token is valid for. See [Authentication](#authentication) for details. | ## Authentication diff --git a/node-server/src/shared/config/config_schema.ts b/node-server/src/shared/config/config_schema.ts index fe9485a..829f78b 100644 --- a/node-server/src/shared/config/config_schema.ts +++ b/node-server/src/shared/config/config_schema.ts @@ -31,8 +31,6 @@ const SERVER_CONFIG = Type.Object({ const HTTPS_CONFIG = Type.Union([ Type.Object({ USE_HTTPS: Type.Literal(false), - KEY_FILEPATH: Type.Any(), - CERTIFICATE_FILEPATH: Type.Any(), }), Type.Object({ USE_HTTPS: Type.Literal(true), @@ -49,11 +47,6 @@ const WHISPER_CONFIG = Type.Object({ const AUTH_CONFIG = Type.Union([ Type.Object({ REQUIRE_AUTH: Type.Const(false), - ACCESS_TOKEN_BYTES: Type.Any(), - ACCESS_TOKEN_REFRESH_INTERVAL_SEC: Type.Any(), - ACCESS_TOKEN_VALID_PERIOD_SEC: Type.Any(), - SESSION_TOKEN_BYTES: Type.Any(), - SESSION_LENGTH_SEC: Type.Any(), }), Type.Object({ REQUIRE_AUTH: Type.Const(true), diff --git a/node-server/src/shared/config/load_config.ts b/node-server/src/shared/config/load_config.ts index 47e9720..842b825 100644 --- a/node-server/src/shared/config/load_config.ts +++ b/node-server/src/shared/config/load_config.ts @@ -1,4 +1,5 @@ import {Ajv} from 'ajv'; +import {Value} from '@sinclair/typebox/value'; import type {Static} from '@sinclair/typebox'; import {NodeEnv, SCHEMA, type ConfigType} from './config_schema.js'; import {configDotenv} from 'dotenv'; @@ -20,7 +21,8 @@ export default function loadConfig(path?: string): ConfigType { allowUnionTypes: true, }); - const env = Object.assign({}, process.env) as unknown as Static; + const defaults = Value.Default(SCHEMA, {}) as Static; + const env = Object.assign(defaults, process.env); const valid = ajv.validate(SCHEMA, env); if (!valid) { const error = new Error('Invalid Configuration! ' + ajv.errorsText()); @@ -57,7 +59,7 @@ export default function loadConfig(path?: string): ConfigType { sessionTokenBytes: env.SESSION_TOKEN_BYTES, sessionLengthSec: env.SESSION_LENGTH_SEC, }, - }); + }) as unknown as ConfigType; return config; } diff --git a/node-server/template.env b/node-server/template.env index b6aa25f..d012af3 100644 --- a/node-server/template.env +++ b/node-server/template.env @@ -4,12 +4,12 @@ LOG_LEVEL="debug" #### Host and port API webserver should listen on HOST="0.0.0.0" PORT=8080 +CORS_ORIGIN='*' +SERVER_ADDRESS="127.0.0.1:8080" # Enable or disable HTTPS -USE_HTTPS=true +USE_HTTPS=false KEY_FILEPATH='' CERTIFICATE_FILEPATH='' -CORS_ORIGIN='*' -SERVER_ADDRESS="127.0.0.1:8080" #### URL to whisper service WHISPER_SERVICE_ENDPOINT="ws://127.0.0.1:8000/whisper?api_key=CHANGEME&model_key=faster-whisper:cpu-tiny-en" From 20c7c75867563886f5a9cd69498935592f54893d Mon Sep 17 00:00:00 2001 From: Bennett Wu <57691028+bennettrwu@users.noreply.github.com> Date: Wed, 30 Apr 2025 07:50:17 -0500 Subject: [PATCH 09/14] make disabled auth actually work --- .env | 2 +- node-server/Dockerfile | 5 +++- .../src/server/routes/websocket_handler.ts | 11 +++++---- .../services/request_authorizer.test.ts | 23 ++++++------------- .../src/server/services/request_authorizer.ts | 9 ++++++++ whisper-service/Dockerfile_CPU | 4 +++- 6 files changed, 31 insertions(+), 23 deletions(-) diff --git a/.env b/.env index 9b85a44..59a3711 100644 --- a/.env +++ b/.env @@ -5,7 +5,7 @@ LOG_LEVEL="info" HOST="0.0.0.0" PORT=8080 CORS_ORIGIN='*' -SERVER_ADDRESS="127.0.0.1:8080" +SERVER_ADDRESS="192.168.10.160:8080" # Enable or disable HTTPS USE_HTTPS=false KEY_FILEPATH='' diff --git a/node-server/Dockerfile b/node-server/Dockerfile index 51bb7bd..42fed2b 100644 --- a/node-server/Dockerfile +++ b/node-server/Dockerfile @@ -2,9 +2,12 @@ FROM node:20 WORKDIR /app -COPY . . +COPY package*.json . RUN npm install + +COPY . . + RUN npm run build WORKDIR /app/build/src diff --git a/node-server/src/server/routes/websocket_handler.ts b/node-server/src/server/routes/websocket_handler.ts index 5bdd4cb..821fa13 100644 --- a/node-server/src/server/routes/websocket_handler.ts +++ b/node-server/src/server/routes/websocket_handler.ts @@ -74,10 +74,13 @@ export default function websocketHandler(fastify: FastifyInstance) { registerSink(fastify, ws); // Close websocket when session expires - const expirationTimeout = setTimeout(() => { - fastify.log.info('Session token expired, closing socket.'); - ws.close(3000); - }, expiration.getTime() - new Date().getTime()); + let expirationTimeout: NodeJS.Timeout | undefined; + if (fastify.config.auth.required) { + expirationTimeout = setTimeout(() => { + fastify.log.info('Session token expired, closing socket.'); + ws.close(3000); + }, expiration.getTime() - new Date().getTime()); + } ws.on('close', code => { clearTimeout(expirationTimeout); diff --git a/node-server/src/server/services/request_authorizer.test.ts b/node-server/src/server/services/request_authorizer.test.ts index 95bfb08..fd1e58c 100644 --- a/node-server/src/server/services/request_authorizer.test.ts +++ b/node-server/src/server/services/request_authorizer.test.ts @@ -4,6 +4,8 @@ import fakeLogger from '../../../test/fakes/fake_logger.js'; import type {ConfigType} from '@shared/config/config_schema.js'; import Fastify from 'fastify'; +const MAX_TIMESTAMP = 8640000000000000; + describe('Request authorizer', () => { function setupTest() { vi.useFakeTimers(); @@ -200,10 +202,7 @@ describe('Request authorizer', () => { describe('Authorization override', it => { it('always accepts access tokens', () => { - const ra = new RequestAuthorizer( - {auth: {required: false, accessTokenBytes: 8}} as unknown as ConfigType, - fakeLogger(), - ); + const ra = new RequestAuthorizer({auth: {required: false}} as unknown as ConfigType, fakeLogger()); const {accessToken} = ra.getAccessToken(); @@ -212,22 +211,17 @@ describe('Request authorizer', () => { }); it('always accepts session tokens', () => { - const ra = new RequestAuthorizer( - {auth: {required: false, accessTokenBytes: 8, sessionTokenBytes: 32}} as unknown as ConfigType, - fakeLogger(), - ); + const ra = new RequestAuthorizer({auth: {required: false}} as unknown as ConfigType, fakeLogger()); const {sessionToken} = ra.createSessionToken(); expect(ra.accessTokenIsValid(sessionToken)).toBeTruthy(); expect(ra.sessionTokenIsValid('invalid')).toBeTruthy(); + expect(ra.getSessionTokenExpiry(sessionToken)).toEqual(new Date(MAX_TIMESTAMP)); }); it('overrides localhost authorizer', async () => { - const ra = new RequestAuthorizer( - {auth: {required: false, accessTokenBytes: 8}} as unknown as ConfigType, - fakeLogger(), - ); + const ra = new RequestAuthorizer({auth: {required: false}} as unknown as ConfigType, fakeLogger()); const fastify = Fastify(); fastify.decorate('requestAuthorizer', ra); @@ -240,10 +234,7 @@ describe('Request authorizer', () => { }); it('overrides session token authorizer', async () => { - const ra = new RequestAuthorizer( - {auth: {required: false, accessTokenBytes: 8, sessionTokenBytes: 32}} as unknown as ConfigType, - fakeLogger(), - ); + const ra = new RequestAuthorizer({auth: {required: false}} as unknown as ConfigType, fakeLogger()); const fastify = Fastify(); fastify.decorate('requestAuthorizer', ra); diff --git a/node-server/src/server/services/request_authorizer.ts b/node-server/src/server/services/request_authorizer.ts index 85976d8..da44cc6 100644 --- a/node-server/src/server/services/request_authorizer.ts +++ b/node-server/src/server/services/request_authorizer.ts @@ -69,6 +69,13 @@ export default class RequestAuthorizer { * @returns object containg access token and expiry date */ getAccessToken() { + if (!this._config.auth.required) { + return { + accessToken: this._currentAccessToken, + expires: new Date(MAX_TIMESTAMP), + }; + } + return { accessToken: this._currentAccessToken, expires: this._validAccessTokens[this._currentAccessToken], @@ -100,6 +107,8 @@ export default class RequestAuthorizer { * @returns expiry date or undefined if no valid session token found */ getSessionTokenExpiry(sessionToken: string | undefined) { + if (!this._config.auth.required) return new Date(MAX_TIMESTAMP); + if (typeof sessionToken !== 'string' || !(sessionToken in this._validSessionTokens)) return undefined; return this._validSessionTokens[sessionToken]; } diff --git a/whisper-service/Dockerfile_CPU b/whisper-service/Dockerfile_CPU index aba2801..4277ece 100644 --- a/whisper-service/Dockerfile_CPU +++ b/whisper-service/Dockerfile_CPU @@ -2,8 +2,10 @@ FROM python:3.12 WORKDIR /app -COPY . . +COPY requirements.txt . RUN pip install -r requirements.txt +COPY . . + CMD ["python", "index.py"] \ No newline at end of file From d4f8ff41b583d06cab951323195b349eca001a29 Mon Sep 17 00:00:00 2001 From: Bennett Wu <57691028+bennettrwu@users.noreply.github.com> Date: Wed, 30 Apr 2025 08:05:49 -0500 Subject: [PATCH 10/14] add docker build actions --- .github/workflows/node-server-ci.yml | 16 +++++++++++ .github/workflows/whisper-service-ci.yml | 34 ++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/.github/workflows/node-server-ci.yml b/.github/workflows/node-server-ci.yml index b8d8a26..8eaf9ad 100644 --- a/.github/workflows/node-server-ci.yml +++ b/.github/workflows/node-server-ci.yml @@ -73,3 +73,19 @@ jobs: - name: Run build working-directory: 'node-server' run: npm run build + + build-container-node-server: + runs-on: ubuntu-latest + steps: + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: ./node-server + file: Dockerfile + push: false diff --git a/.github/workflows/whisper-service-ci.yml b/.github/workflows/whisper-service-ci.yml index d6d7ce0..d42e8fa 100644 --- a/.github/workflows/whisper-service-ci.yml +++ b/.github/workflows/whisper-service-ci.yml @@ -54,3 +54,37 @@ jobs: - name: Run tests working-directory: "whisper-service" run: pylint --disable=import-error $(git ls-files '*.py') + + + build-cpu-container-whisper-service: + runs-on: ubuntu-latest + steps: + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: ./whisper-service + file: Dockerfile_CPU + push: false + + build-cuda-container-whisper-service: + runs-on: ubuntu-latest + steps: + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: ./whisper-service + file: Dockerfile_CUDA + push: false + \ No newline at end of file From 151c7825beed288d0f97f18f15016515bafe6368 Mon Sep 17 00:00:00 2001 From: Bennett Wu <57691028+bennettrwu@users.noreply.github.com> Date: Wed, 30 Apr 2025 08:08:43 -0500 Subject: [PATCH 11/14] actually pull repo --- .github/workflows/node-server-ci.yml | 21 ++++++++++++--------- .github/workflows/whisper-service-ci.yml | 8 ++++++-- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/.github/workflows/node-server-ci.yml b/.github/workflows/node-server-ci.yml index 8eaf9ad..8e20765 100644 --- a/.github/workflows/node-server-ci.yml +++ b/.github/workflows/node-server-ci.yml @@ -29,13 +29,13 @@ jobs: node-version: ${{ env.node-version }} - name: Install dependencies - working-directory: 'node-server' + working-directory: "node-server" run: npm ci - name: Run tests - working-directory: 'node-server' + working-directory: "node-server" run: npm run ci-test - + lint-node-server: runs-on: ubuntu-latest steps: @@ -48,11 +48,11 @@ jobs: node-version: ${{ env.node-version }} - name: Install dependencies - working-directory: 'node-server' - run: npm ci + working-directory: "node-server" + run: npm ci - name: Run linter - working-directory: 'node-server' + working-directory: "node-server" run: npm run lint build-node-server: @@ -67,16 +67,19 @@ jobs: node-version: ${{ env.node-version }} - name: Install dependencies - working-directory: 'node-server' + working-directory: "node-server" run: npm ci - + - name: Run build - working-directory: 'node-server' + working-directory: "node-server" run: npm run build build-container-node-server: runs-on: ubuntu-latest steps: + - name: Set up Git repository + uses: actions/checkout@v4 + - name: Set up QEMU uses: docker/setup-qemu-action@v3 diff --git a/.github/workflows/whisper-service-ci.yml b/.github/workflows/whisper-service-ci.yml index d42e8fa..f145d40 100644 --- a/.github/workflows/whisper-service-ci.yml +++ b/.github/workflows/whisper-service-ci.yml @@ -55,10 +55,12 @@ jobs: working-directory: "whisper-service" run: pylint --disable=import-error $(git ls-files '*.py') - build-cpu-container-whisper-service: runs-on: ubuntu-latest steps: + - name: Set up Git repository + uses: actions/checkout@v4 + - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -75,6 +77,9 @@ jobs: build-cuda-container-whisper-service: runs-on: ubuntu-latest steps: + - name: Set up Git repository + uses: actions/checkout@v4 + - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -87,4 +92,3 @@ jobs: context: ./whisper-service file: Dockerfile_CUDA push: false - \ No newline at end of file From 9ff1f666590d986ec53b4ddb383df2275d46c4f7 Mon Sep 17 00:00:00 2001 From: Bennett Wu <57691028+bennettrwu@users.noreply.github.com> Date: Wed, 30 Apr 2025 08:10:23 -0500 Subject: [PATCH 12/14] use correct? dockerfile path --- .github/workflows/node-server-ci.yml | 2 +- .github/workflows/whisper-service-ci.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/node-server-ci.yml b/.github/workflows/node-server-ci.yml index 8e20765..428d4f2 100644 --- a/.github/workflows/node-server-ci.yml +++ b/.github/workflows/node-server-ci.yml @@ -90,5 +90,5 @@ jobs: uses: docker/build-push-action@v6 with: context: ./node-server - file: Dockerfile + file: ./whisper-service/Dockerfile push: false diff --git a/.github/workflows/whisper-service-ci.yml b/.github/workflows/whisper-service-ci.yml index f145d40..1628797 100644 --- a/.github/workflows/whisper-service-ci.yml +++ b/.github/workflows/whisper-service-ci.yml @@ -71,7 +71,7 @@ jobs: uses: docker/build-push-action@v6 with: context: ./whisper-service - file: Dockerfile_CPU + file: ./whisper-service/Dockerfile_CPU push: false build-cuda-container-whisper-service: @@ -90,5 +90,5 @@ jobs: uses: docker/build-push-action@v6 with: context: ./whisper-service - file: Dockerfile_CUDA + file: ./whisper-service/Dockerfile_CUDA push: false From 6d6f3ef94d1e942165eced55d887bb6f97d17fc7 Mon Sep 17 00:00:00 2001 From: Bennett Wu <57691028+bennettrwu@users.noreply.github.com> Date: Wed, 30 Apr 2025 08:29:29 -0500 Subject: [PATCH 13/14] add steps to push to docker hub --- .github/workflows/node-server-ci.yml | 50 +++++++++++++--- .github/workflows/whisper-service-ci.yml | 74 +++++++++++++++++++++--- 2 files changed, 108 insertions(+), 16 deletions(-) diff --git a/.github/workflows/node-server-ci.yml b/.github/workflows/node-server-ci.yml index 428d4f2..7686315 100644 --- a/.github/workflows/node-server-ci.yml +++ b/.github/workflows/node-server-ci.yml @@ -6,6 +6,9 @@ on: - node-server/** branches: - main + - staging + tags: + - "v*.*.*" pull_request: workflow_dispatch: @@ -14,7 +17,8 @@ permissions: contents: read env: - node-version: 20 + NODE_VERSION: 20 + DOCKERHUB_ORG: 'scribear' jobs: test-node-server: @@ -23,10 +27,10 @@ jobs: - name: Set up Git repository uses: actions/checkout@v4 - - name: Set up Node.js ${{ env.node-version }} + - name: Set up Node.js ${{ env.NODE_VERSION }} uses: actions/setup-node@v4 with: - node-version: ${{ env.node-version }} + node-version: ${{ env.NODE_VERSION }} - name: Install dependencies working-directory: "node-server" @@ -42,10 +46,10 @@ jobs: - name: Set up Git repository uses: actions/checkout@v4 - - name: Set up Node.js ${{ env.node-version }} + - name: Set up Node.js ${{ env.NODE_VERSION }} uses: actions/setup-node@v4 with: - node-version: ${{ env.node-version }} + node-version: ${{ env.NODE_VERSION }} - name: Install dependencies working-directory: "node-server" @@ -61,10 +65,10 @@ jobs: - name: Set up Git repository uses: actions/checkout@v4 - - name: Set up Node.js ${{ env.node-version }} + - name: Set up Node.js ${{ env.NODE_VERSION }} uses: actions/setup-node@v4 with: - node-version: ${{ env.node-version }} + node-version: ${{ env.NODE_VERSION }} - name: Install dependencies working-directory: "node-server" @@ -80,15 +84,43 @@ jobs: - name: Set up Git repository uses: actions/checkout@v4 + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.DOCKERHUB_ORG }}/node-server + tags: | + type=schedule + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + - name: Login to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + - name: Build and push uses: docker/build-push-action@v6 with: context: ./node-server - file: ./whisper-service/Dockerfile - push: false + file: ./node-server/Dockerfile + push: true + platforms: "linux/amd64,linux/arm64" + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=registry,ref=${{ env.DOCKERHUB_ORG }}/node-server:buildcache + cache-to: type=registry,ref=${{ env.DOCKERHUB_ORG }}/node-server:buildcache,mode=max + build-args: | + BRANCH=${{ steps.meta.outputs.version }} + BUILDNUMBER=${{ github.run_number }} + ARG GITSHA1=${{ github.sha }} \ No newline at end of file diff --git a/.github/workflows/whisper-service-ci.yml b/.github/workflows/whisper-service-ci.yml index 1628797..6d24151 100644 --- a/.github/workflows/whisper-service-ci.yml +++ b/.github/workflows/whisper-service-ci.yml @@ -6,6 +6,9 @@ on: - whisper-service/** branches: - main + - staging + tags: + - "v*.*.*" pull_request: workflow_dispatch: @@ -14,7 +17,8 @@ permissions: contents: read env: - python-version: 3.12 + PYTHON_VERSION: 3.12 + DOCKERHUB_ORG: 'scribear' jobs: test-whisper-service: @@ -23,10 +27,10 @@ jobs: - name: Set up Git repository uses: actions/checkout@v4 - - name: Set up Python ${{ env.python-version }} + - name: Set up Python ${{ env.PYTHON_VERSION }} uses: actions/setup-python@v4 with: - python-version: ${{ env.python-version }} + python-version: ${{ env.PYTHON_VERSION }} - name: Install dependencies working-directory: "whisper-service" @@ -42,10 +46,10 @@ jobs: - name: Set up Git repository uses: actions/checkout@v4 - - name: Set up Python ${{ env.python-version }} + - name: Set up Python ${{ env.PYTHON_VERSION }} uses: actions/setup-python@v4 with: - python-version: ${{ env.python-version }} + python-version: ${{ env.PYTHON_VERSION }} - name: Install dependencies working-directory: "whisper-service" @@ -61,18 +65,46 @@ jobs: - name: Set up Git repository uses: actions/checkout@v4 + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.DOCKERHUB_ORG }}/whisper-service-cpu + tags: | + type=schedule + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + - name: Login to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + - name: Build and push uses: docker/build-push-action@v6 with: context: ./whisper-service file: ./whisper-service/Dockerfile_CPU - push: false + push: true + platforms: "linux/amd64,linux/arm64" + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=registry,ref=${{ env.DOCKERHUB_ORG }}/whisper-service-cpu:buildcache + cache-to: type=registry,ref=${{ env.DOCKERHUB_ORG }}/whisper-service-cpu:buildcache,mode=max + build-args: | + BRANCH=${{ steps.meta.outputs.version }} + BUILDNUMBER=${{ github.run_number }} + ARG GITSHA1=${{ github.sha }} build-cuda-container-whisper-service: runs-on: ubuntu-latest @@ -80,15 +112,43 @@ jobs: - name: Set up Git repository uses: actions/checkout@v4 + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.DOCKERHUB_ORG }}/whisper-service-cuda + tags: | + type=schedule + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + - name: Login to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + - name: Build and push uses: docker/build-push-action@v6 with: context: ./whisper-service file: ./whisper-service/Dockerfile_CUDA - push: false + push: true + platforms: "linux/amd64,linux/arm64" + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=registry,ref=${{ env.DOCKERHUB_ORG }}/whisper-service-cuda:buildcache + cache-to: type=registry,ref=${{ env.DOCKERHUB_ORG }}/whisper-service-cuda:buildcache,mode=max + build-args: | + BRANCH=${{ steps.meta.outputs.version }} + BUILDNUMBER=${{ github.run_number }} + ARG GITSHA1=${{ github.sha }} From 1add683e4465a48cf2fd7c72137ff8008c140212 Mon Sep 17 00:00:00 2001 From: Bennett Wu <57691028+bennettrwu@users.noreply.github.com> Date: Wed, 30 Apr 2025 10:39:10 -0500 Subject: [PATCH 14/14] implement sourceTokens --- .gitignore | 3 +- compose_cpu.yaml | 1 + compose_cuda.yaml | 1 + node-server/README.md | 3 +- .../hooks/create_authorize_hook.test.ts | 262 ++++++++++++++++++ .../src/server/hooks/create_authorize_hook.ts | 63 +++++ .../src/server/routes/session_auth_handler.ts | 45 +-- .../src/server/routes/websocket_handler.ts | 87 +++--- ...thorizer.test.ts => token_service.test.ts} | 100 +------ ...request_authorizer.ts => token_service.ts} | 43 +-- node-server/src/server/start_server.ts | 10 +- .../src/shared/config/config_schema.ts | 2 + node-server/src/shared/config/load_config.ts | 1 + node-server/template.env | 8 +- .env => template.env | 2 + 15 files changed, 441 insertions(+), 190 deletions(-) create mode 100644 node-server/src/server/hooks/create_authorize_hook.test.ts create mode 100644 node-server/src/server/hooks/create_authorize_hook.ts rename node-server/src/server/services/{request_authorizer.test.ts => token_service.test.ts} (60%) rename node-server/src/server/services/{request_authorizer.ts => token_service.ts} (77%) rename .env => template.env (92%) diff --git a/.gitignore b/.gitignore index cd3d225..51a7fbb 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -logs \ No newline at end of file +logs +.env \ No newline at end of file diff --git a/compose_cpu.yaml b/compose_cpu.yaml index a64f373..600b6b3 100644 --- a/compose_cpu.yaml +++ b/compose_cpu.yaml @@ -15,6 +15,7 @@ services: - WHISPER_SERVICE_ENDPOINT=ws://whisper-service:8000/whisper?api_key=${API_KEY}&model_key=${MODEL_KEY} - WHISPER_RECONNECT_INTERVAL_SEC=${WHISPER_RECONNECT_INTERVAL_SEC} - REQUIRE_AUTH=${REQUIRE_AUTH} + - SOURCE_TOKEN=${SOURCE_TOKEN} - ACCESS_TOKEN_BYTES=${ACCESS_TOKEN_BYTES} - ACCESS_TOKEN_REFRESH_INTERVAL_SEC=${ACCESS_TOKEN_REFRESH_INTERVAL_SEC} - ACCESS_TOKEN_VALID_PERIOD_SEC=${ACCESS_TOKEN_VALID_PERIOD_SEC} diff --git a/compose_cuda.yaml b/compose_cuda.yaml index 462918f..3864003 100644 --- a/compose_cuda.yaml +++ b/compose_cuda.yaml @@ -15,6 +15,7 @@ services: - WHISPER_SERVICE_ENDPOINT=ws://whisper-service:8000/whisper?api_key=${API_KEY}&model_key=${MODEL_KEY} - WHISPER_RECONNECT_INTERVAL_SEC=${WHISPER_RECONNECT_INTERVAL_SEC} - REQUIRE_AUTH=${REQUIRE_AUTH} + - SOURCE_TOKEN=${SOURCE_TOKEN} - ACCESS_TOKEN_BYTES=${ACCESS_TOKEN_BYTES} - ACCESS_TOKEN_REFRESH_INTERVAL_SEC=${ACCESS_TOKEN_REFRESH_INTERVAL_SEC} - ACCESS_TOKEN_VALID_PERIOD_SEC=${ACCESS_TOKEN_VALID_PERIOD_SEC} diff --git a/node-server/README.md b/node-server/README.md index c5d0830..a7a852b 100644 --- a/node-server/README.md +++ b/node-server/README.md @@ -106,7 +106,8 @@ The following options can be configured via environment variable. | `WHISPER_RECONNECT_INTERVAL` | `number` | `1` | Number of seconds to wait before attempting to reconnect to whisper service. Server implements exponential backoff, so interval will double each time connection fails up to a maximum of 30 seconds. | |**Authentication Options**||| | `REQUIRE_AUTH` | `true`, `false` | `true` | If `true`, requires authentication to connect to node server api, otherwise no authentication is used. See [Authentication](#authentication) for details. | -| `ACCESS_TOKEN_REFRESH_INTERVAL_SEC` | `number` | Required if `REQUIRED_AUTH=true`, no default value | Number of seconds to wait before generating a new refresh token. See [Authentication](#authentication) for details. | +| `SOURCE_TOKEN` | `string` | Required if `REQUIRED_AUTH=true`, no default value | The key used by frontend to connect as audio source. See [Authentication](#authentication) for details. | +| `ACCESS_TOKEN_REFRESH_INTERVAL_SEC` | `number` | Required if `REQUIRED_AUTH=true`, no default value | Number of seconds to wait before generating a new refresh token. See [Authentication](#authentication) for details. | | `ACCESS_TOKEN_BYTES` | `number` | Required if `REQUIRED_AUTH=true`, no default value | The number of random bytes used to generate access tokens | | `ACCESS_TOKEN_VALID_PERIOD_SEC` | `number` | Required if `REQUIRED_AUTH=true`, no default value | Number of seconds a newly generated refresh token is valid for. See [Authentication](#authentication) for details. | | `SESSION_TOKEN_BYTES` | `number` | Required if `REQUIRED_AUTH=true`, no default value | The number of random bytes used to generate session tokens | diff --git a/node-server/src/server/hooks/create_authorize_hook.test.ts b/node-server/src/server/hooks/create_authorize_hook.test.ts new file mode 100644 index 0000000..57fca9e --- /dev/null +++ b/node-server/src/server/hooks/create_authorize_hook.test.ts @@ -0,0 +1,262 @@ +import type TokenService from '@server/services/token_service.js'; +import type {ConfigType} from '@shared/config/config_schema.js'; +import Fastify from 'fastify'; +import {describe, expect, vi} from 'vitest'; +import createAuthorizeHook, {Identities} from './create_authorize_hook.js'; +import formatTestNames from '@test/utils/format_test_names.js'; + +describe('createAuthorizeHook', it => { + const validAccessToken = 'valid-access-token'; + const validSessionToken = 'valid-session-token'; + const validSourceToken = 'valid-source-token'; + + const fakeConfig = { + auth: { + required: true, + sourceToken: validSourceToken, + }, + } as ConfigType; + + const fakeTokenService = { + accessTokenIsValid: vi.fn(token => { + return token === validAccessToken; + }), + sessionTokenIsValid: vi.fn(token => { + return token === validSessionToken; + }), + getSessionTokenExpiry: vi.fn(() => { + return new Date(Date.now() + 10_000); + }), + } as unknown as TokenService; + + function setupTest(allowedIdentities: Array) { + const fastify = Fastify(); + const reqHandlerSpy = vi.fn((req, reply) => reply.code(200).send('hi')); + fastify.post( + '/test', + {preHandler: createAuthorizeHook(fakeConfig, fakeTokenService, allowedIdentities)}, + reqHandlerSpy, + ); + + return {fastify, reqHandlerSpy}; + } + + it.for( + formatTestNames([ + {name: 'AccessToken', identity: Identities.AccessToken, param: `accessToken=${validAccessToken}`}, + {name: 'SessionToken', identity: Identities.SessionToken, param: `sessionToken=${validSessionToken}`}, + {name: 'SourceToken', identity: Identities.SourceToken, param: `sourceToken=${validSourceToken}`}, + ]), + )('authorizes valid tokens in request query string for identity: %s', async ([, {identity, param}]) => { + const {fastify, reqHandlerSpy} = setupTest([identity]); + + const reply = await fastify.inject({ + method: 'post', + url: `/test?${param}`, + }); + + expect(reply.statusCode).toBe(200); + expect(reqHandlerSpy).toHaveBeenCalled(); + }); + + it.for( + formatTestNames([ + {name: 'AccessToken', identity: Identities.AccessToken, body: {accessToken: validAccessToken}}, + {name: 'SessionToken', identity: Identities.SessionToken, body: {sessionToken: validSessionToken}}, + {name: 'SourceToken', identity: Identities.SourceToken, body: {sourceToken: validSourceToken}}, + ]), + )('authorizes valid tokens in request body for identity: %s', async ([, {identity, body}]) => { + const {fastify, reqHandlerSpy} = setupTest([identity]); + + const reply = await fastify.inject({ + method: 'post', + url: '/test', + body, + }); + + expect(reply.statusCode).toBe(200); + expect(reqHandlerSpy).toHaveBeenCalled(); + }); + + it.for( + formatTestNames([ + {name: 'AccessToken', identity: Identities.AccessToken, param: `accessToken=${validSessionToken}`}, + {name: 'SessionToken', identity: Identities.SessionToken, param: `sessionToken=${validAccessToken}`}, + {name: 'SourceToken', identity: Identities.SourceToken, param: `sourceToken=${validAccessToken}`}, + ]), + )('rejects invalid tokens in request query string for identity: %s', async ([, {identity, param}]) => { + const {fastify, reqHandlerSpy} = setupTest([identity]); + + const reply = await fastify.inject({ + method: 'post', + url: `/test?${param}`, + }); + + expect(reply.statusCode).toBe(403); + expect(reqHandlerSpy).not.toHaveBeenCalled(); + }); + + it.for( + formatTestNames([ + {name: 'AccessToken', identity: Identities.AccessToken, body: {accessToken: validSourceToken}}, + {name: 'SessionToken', identity: Identities.SessionToken, body: {sessionToken: validSourceToken}}, + {name: 'SourceToken', identity: Identities.SourceToken, body: {sourceToken: validSessionToken}}, + ]), + )('rejects invalid tokens in request body for identity: %s', async ([, {identity, body}]) => { + const {fastify, reqHandlerSpy} = setupTest([identity]); + + const reply = await fastify.inject({ + method: 'post', + url: '/test', + body, + }); + + expect(reply.statusCode).toBe(403); + expect(reqHandlerSpy).not.toHaveBeenCalled(); + }); + + it.for( + formatTestNames([ + { + name: 'AccessToken', + identity: Identities.AccessToken, + param: `accessToken=${validAccessToken}`, + body: {accessToken: 'invalid-token'}, + }, + { + name: 'SessionToken', + identity: Identities.SessionToken, + param: `sessionToken=${validSessionToken}`, + body: {sessionToken: 'invalid-token'}, + }, + { + name: 'SourceToken', + identity: Identities.SourceToken, + param: `sourceToken=${validSourceToken}`, + body: {sourceToken: 'invalid-token'}, + }, + ]), + )('prioritizes tokens in request body for identity (invalid): %s', async ([, {identity, param, body}]) => { + const {fastify, reqHandlerSpy} = setupTest([identity]); + + const reply = await fastify.inject({ + method: 'post', + url: `/test?${param}`, + body, + }); + + expect(reply.statusCode).toBe(403); + expect(reqHandlerSpy).not.toHaveBeenCalled(); + }); + + it.for( + formatTestNames([ + { + name: 'AccessToken', + identity: Identities.AccessToken, + param: 'accessToken=invalid-token', + body: {accessToken: validAccessToken}, + }, + { + name: 'SessionToken', + identity: Identities.SessionToken, + param: 'sessionToken=invalid-token', + body: {sessionToken: validSessionToken}, + }, + { + name: 'SourceToken', + identity: Identities.SourceToken, + param: 'sourceToken=invalid-token', + body: {sourceToken: validSourceToken}, + }, + ]), + )('prioritizes tokens in request body for identity (valid): %s', async ([, {identity, param, body}]) => { + const {fastify, reqHandlerSpy} = setupTest([identity]); + + const reply = await fastify.inject({ + method: 'post', + url: `/test?${param}`, + body, + }); + + expect(reply.statusCode).toBe(200); + expect(reqHandlerSpy).toHaveBeenCalled(); + }); + + it.for( + formatTestNames([ + { + name: 'AccessToken, SessionToken', + identities: [Identities.AccessToken, Identities.SessionToken], + expected: [true, true, false], + }, + { + name: 'AccessToken, SourceToken', + identities: [Identities.AccessToken, Identities.SourceToken], + expected: [true, false, true], + }, + { + name: 'SessionToken, SourceToken', + identities: [Identities.SessionToken, Identities.SourceToken], + expected: [false, true, true], + }, + { + name: 'AccessToken, SessionToken, SourceToken', + identities: [Identities.AccessToken, Identities.SessionToken, Identities.SourceToken], + expected: [true, true, true], + }, + ]), + )('authorizes multiple identities: %s', async ([, {identities, expected}]) => { + const {fastify, reqHandlerSpy} = setupTest(identities); + + const toTest = [ + {accessToken: validAccessToken}, + {sessionToken: validSessionToken}, + {sourceToken: validSourceToken}, + ]; + + for (let i = 0; i < toTest.length; i++) { + reqHandlerSpy.mockClear(); + + const reply = await fastify.inject({ + method: 'post', + url: '/test', + body: toTest[i], + }); + + if (expected[i]) { + expect(reply.statusCode).toBe(200); + expect(reqHandlerSpy).toHaveBeenCalled(); + } else { + expect(reply.statusCode).toBe(403); + expect(reqHandlerSpy).not.toHaveBeenCalled(); + } + } + }); + + it('authorizes if auth is disabled for identity', async () => { + const fastify = Fastify(); + const reqHandlerSpy = vi.fn((req, reply) => reply.code(200).send('hi')); + fastify.post( + '/test', + {preHandler: createAuthorizeHook({auth: {required: false}} as ConfigType, fakeTokenService, [])}, + reqHandlerSpy, + ); + + const reply = await fastify.inject({ + method: 'post', + url: '/test', + }); + + expect(reply.statusCode).toBe(200); + expect(reqHandlerSpy).toHaveBeenCalled(); + }); + + it('provides session expiration for sessionTokens', async () => { + const {fastify, reqHandlerSpy} = setupTest([Identities.SessionToken]); + + await fastify.inject({method: 'POST', path: '/test', body: {sessionToken: validSessionToken}}); + + expect(Math.abs(reqHandlerSpy.mock.calls[0][0]['authorizationExpiryTimeout'] - 10_000)).toBeLessThan(100); + }); +}); diff --git a/node-server/src/server/hooks/create_authorize_hook.ts b/node-server/src/server/hooks/create_authorize_hook.ts new file mode 100644 index 0000000..79bf0fd --- /dev/null +++ b/node-server/src/server/hooks/create_authorize_hook.ts @@ -0,0 +1,63 @@ +import type RequestAuthorizer from '@server/services/token_service.js'; +import type {ConfigType} from '@shared/config/config_schema.js'; +import type {DoneFuncWithErrOrRes, FastifyReply, FastifyRequest} from 'fastify'; + +export enum Identities { + SourceToken, + AccessToken, + SessionToken, +} + +/** + * Creates a fastify preHandler hook to handle authorizing requests + * Checks request query string for sessionToken or sourceToken and checks if they are valid + * A valid sessionToken corresponds to a TranscriptionSink identity + * A valid sourceToken corresponds to a Kiosk identity + * If the parsed identity is in allowedIdentities, request is authorized, otherwise it is rejected + * @param config server config object + * @param requestAuthorizer requestAuthorizer instance + * @param allowedIdentities array of identities that should be accepted + * @returns fastify preHandler hook + */ +export default function createAuthorizeHook( + config: ConfigType, + requestAuthorizer: RequestAuthorizer, + allowedIdentities: Array, +) { + return ( + request: FastifyRequest<{ + Querystring: {accessToken?: string; sessionToken?: string; sourceToken?: string}; + Body?: {accessToken?: string; sessionToken?: string; sourceToken?: string}; + }>, + reply: FastifyReply, + done: DoneFuncWithErrOrRes, + ) => { + if (!config.auth.required) return done(); + + const accessToken = request.body?.accessToken ? request.body?.accessToken : request.query.accessToken; + if (allowedIdentities.includes(Identities.AccessToken) && requestAuthorizer.accessTokenIsValid(accessToken)) { + request.log.debug('Request authorized via access token'); + return done(); + } + + const sessionToken = request.body?.sessionToken ? request.body?.sessionToken : request.query.sessionToken; + if (allowedIdentities.includes(Identities.SessionToken) && requestAuthorizer.sessionTokenIsValid(sessionToken)) { + const expiration = requestAuthorizer.getSessionTokenExpiry(sessionToken); + if (expiration) { + request.log.debug('Request authorized via session token'); + + request.authorizationExpiryTimeout = expiration.getTime() - new Date().getTime(); + return done(); + } + } + + const sourceToken = request.body?.sourceToken ? request.body?.sourceToken : request.query.sourceToken; + if (allowedIdentities.includes(Identities.SourceToken) && sourceToken === config.auth.sourceToken) { + request.log.debug('Request authorized via source token'); + return done(); + } + + reply.code(403); + done(new Error('Unauthorized')); + }; +} diff --git a/node-server/src/server/routes/session_auth_handler.ts b/node-server/src/server/routes/session_auth_handler.ts index 330cc2b..3e84729 100644 --- a/node-server/src/server/routes/session_auth_handler.ts +++ b/node-server/src/server/routes/session_auth_handler.ts @@ -1,28 +1,37 @@ +import createAuthorizeHook, {Identities} from '@server/hooks/create_authorize_hook.js'; import {FastifyInstance} from 'fastify'; /** * Registers access token and session token endpoints for managing authentication - * @param fastify + * @param fastify fastify webserver instance */ export default function sessionAuthHandler(fastify: FastifyInstance) { - fastify.get('/accessToken', {preHandler: fastify.requestAuthorizer.authorizeLocalhost}, (request, reply) => { - const {accessToken, expires} = fastify.requestAuthorizer.getAccessToken(); - return reply.send({ - accessToken, - serverAddress: fastify.config.server.serverAddress, - expires: expires.toISOString(), - }); - }); + fastify.post( + '/accessToken', + {preHandler: createAuthorizeHook(fastify.config, fastify.tokenService, [Identities.SourceToken])}, + (request, reply) => { + const {accessToken, expires} = fastify.tokenService.getAccessToken(); + return reply.send({ + accessToken, + serverAddress: fastify.config.server.serverAddress, + expires: expires.toISOString(), + }); + }, + ); - fastify.post('/startSession', (request, reply) => { - if (typeof request.body !== 'object') return reply.code(400).send(); - const {accessToken} = request.body as {accessToken?: string}; + fastify.post( + '/startSession', + {preHandler: createAuthorizeHook(fastify.config, fastify.tokenService, [Identities.AccessToken])}, + (request, reply) => { + if (typeof request.body !== 'object') return reply.code(400).send(); + const {accessToken} = request.body as {accessToken?: string}; - if (!fastify.requestAuthorizer.accessTokenIsValid(accessToken)) { - return reply.code(401).send(); - } + if (!fastify.tokenService.accessTokenIsValid(accessToken)) { + return reply.code(401).send(); + } - const {sessionToken, expires} = fastify.requestAuthorizer.createSessionToken(); - return reply.send({sessionToken, expires: expires.toISOString()}); - }); + const {sessionToken, expires} = fastify.tokenService.createSessionToken(); + return reply.send({sessionToken, expires: expires.toISOString()}); + }, + ); } diff --git a/node-server/src/server/routes/websocket_handler.ts b/node-server/src/server/routes/websocket_handler.ts index 821fa13..cd966c8 100644 --- a/node-server/src/server/routes/websocket_handler.ts +++ b/node-server/src/server/routes/websocket_handler.ts @@ -1,3 +1,4 @@ +import createAuthorizeHook, {Identities} from '@server/hooks/create_authorize_hook.js'; import type {BackendTranscriptBlock} from '@server/services/transcription_engine.js'; import {FastifyInstance} from 'fastify'; import WebSocket from 'ws'; @@ -47,44 +48,62 @@ function registerSource(fastify: FastifyInstance, ws: WebSocket) { * @param fastify */ export default function websocketHandler(fastify: FastifyInstance) { - fastify.get('/sourcesink', {websocket: true, preHandler: fastify.requestAuthorizer.authorizeLocalhost}, (ws, req) => { - registerSink(fastify, ws); - registerSource(fastify, ws); + fastify.get( + '/sourcesink', + { + websocket: true, + preHandler: createAuthorizeHook(fastify.config, fastify.tokenService, [Identities.SourceToken]), + }, + (ws, req) => { + registerSink(fastify, ws); + registerSource(fastify, ws); - ws.on('close', code => { - req.log.info({msg: 'Websocket closed', code}); - }); - }); - - fastify.get('/source', {websocket: true, preHandler: fastify.requestAuthorizer.authorizeLocalhost}, (ws, req) => { - registerSource(fastify, ws); + ws.on('close', code => { + req.log.info({msg: 'Websocket closed', code}); + }); + }, + ); - ws.on('close', code => { - req.log.info({msg: 'Websocket closed', code}); - }); - }); + fastify.get( + '/source', + { + websocket: true, + preHandler: createAuthorizeHook(fastify.config, fastify.tokenService, [Identities.SourceToken]), + }, + (ws, req) => { + registerSource(fastify, ws); - fastify.get('/sink', {websocket: true, preHandler: fastify.requestAuthorizer.authorizeSessionToken}, (ws, req) => { - const expiration = fastify.requestAuthorizer.getSessionTokenExpiry(req.query.sessionToken); - if (expiration === undefined) { - ws.close(); - return; - } + ws.on('close', code => { + req.log.info({msg: 'Websocket closed', code}); + }); + }, + ); - registerSink(fastify, ws); + fastify.get( + '/sink', + { + websocket: true, + preHandler: createAuthorizeHook(fastify.config, fastify.tokenService, [ + Identities.SourceToken, + Identities.SessionToken, + ]), + }, + (ws, req) => { + registerSink(fastify, ws); - // Close websocket when session expires - let expirationTimeout: NodeJS.Timeout | undefined; - if (fastify.config.auth.required) { - expirationTimeout = setTimeout(() => { - fastify.log.info('Session token expired, closing socket.'); - ws.close(3000); - }, expiration.getTime() - new Date().getTime()); - } + // Close websocket when session expires + let expirationTimeout: NodeJS.Timeout | undefined; + if (req.authorizationExpiryTimeout) { + expirationTimeout = setTimeout(() => { + fastify.log.info('Session token expired, closing socket.'); + ws.close(3000); + }, req.authorizationExpiryTimeout); + } - ws.on('close', code => { - clearTimeout(expirationTimeout); - req.log.info({msg: 'Websocket closed', code}); - }); - }); + ws.on('close', code => { + clearTimeout(expirationTimeout); + req.log.info({msg: 'Websocket closed', code}); + }); + }, + ); } diff --git a/node-server/src/server/services/request_authorizer.test.ts b/node-server/src/server/services/token_service.test.ts similarity index 60% rename from node-server/src/server/services/request_authorizer.test.ts rename to node-server/src/server/services/token_service.test.ts index fd1e58c..1f2c8ea 100644 --- a/node-server/src/server/services/request_authorizer.test.ts +++ b/node-server/src/server/services/token_service.test.ts @@ -1,12 +1,11 @@ import {describe, expect, vi} from 'vitest'; -import RequestAuthorizer from './request_authorizer.js'; +import RequestAuthorizer from './token_service.js'; import fakeLogger from '../../../test/fakes/fake_logger.js'; import type {ConfigType} from '@shared/config/config_schema.js'; -import Fastify from 'fastify'; const MAX_TIMESTAMP = 8640000000000000; -describe('Request authorizer', () => { +describe('Token service', () => { function setupTest() { vi.useFakeTimers(); vi.setSystemTime(new Date(0)); @@ -136,71 +135,15 @@ describe('Request authorizer', () => { }); }); - describe('Fastify authentication middleware', it => { - function setupFastify() { - const ra = setupTest(); - vi.useRealTimers(); - - const fastify = Fastify(); - fastify.decorate('requestAuthorizer', ra); - - fastify.get('/localhost', {preHandler: fastify.requestAuthorizer.authorizeLocalhost}, (req, reply) => { - return reply.code(200).send(); - }); - fastify.get('/sessionToken', {preHandler: fastify.requestAuthorizer.authorizeSessionToken}, (req, reply) => { - return reply.code(200).send(); - }); - - return fastify; - } - - it('localhost authorizer accepts localhost request', async () => { - const fastify = setupFastify(); - - const result = await fastify.inject({method: 'GET', url: '/localhost', remoteAddress: '127.0.0.1'}); - - expect(result.statusCode).toBe(200); - }); - - it('localhost authorizer rejects non localhost request', async () => { - const fastify = setupFastify(); - - const result = await fastify.inject({method: 'GET', url: '/localhost', remoteAddress: '192.168.0.1'}); - - expect(result.statusCode).toBe(403); - }); - - it('session token authorizer accepts localhost request', async () => { - const fastify = setupFastify(); - - const result = await fastify.inject({method: 'GET', url: '/sessionToken', remoteAddress: '127.0.0.1'}); - - expect(result.statusCode).toBe(200); - }); - - it('session token authorizer rejects non localhost request without session token', async () => { - const fastify = setupFastify(); - - const result = await fastify.inject({method: 'GET', url: '/sessionToken', remoteAddress: '192.168.0.1'}); - - expect(result.statusCode).toBe(403); - }); - - it('session token authorizer accepts non localhost request with valid session token', async () => { - const fastify = setupFastify(); + describe('Authorization override', it => { + it('generates access tokens with max expiry', () => { + const ra = new RequestAuthorizer({auth: {required: false}} as unknown as ConfigType, fakeLogger()); - const {sessionToken} = fastify.requestAuthorizer.createSessionToken(); - const result = await fastify.inject({ - method: 'GET', - url: `/sessionToken?sessionToken=${sessionToken}`, - remoteAddress: '192.168.0.1', - }); + const {expires} = ra.getAccessToken(); - expect(result.statusCode).toBe(200); + expect(expires).toEqual(new Date(MAX_TIMESTAMP)); }); - }); - describe('Authorization override', it => { it('always accepts access tokens', () => { const ra = new RequestAuthorizer({auth: {required: false}} as unknown as ConfigType, fakeLogger()); @@ -210,40 +153,21 @@ describe('Request authorizer', () => { expect(ra.accessTokenIsValid('invalid')).toBeTruthy(); }); - it('always accepts session tokens', () => { + it('generates session tokens with max expiry', () => { const ra = new RequestAuthorizer({auth: {required: false}} as unknown as ConfigType, fakeLogger()); const {sessionToken} = ra.createSessionToken(); - expect(ra.accessTokenIsValid(sessionToken)).toBeTruthy(); - expect(ra.sessionTokenIsValid('invalid')).toBeTruthy(); expect(ra.getSessionTokenExpiry(sessionToken)).toEqual(new Date(MAX_TIMESTAMP)); }); - it('overrides localhost authorizer', async () => { - const ra = new RequestAuthorizer({auth: {required: false}} as unknown as ConfigType, fakeLogger()); - const fastify = Fastify(); - fastify.decorate('requestAuthorizer', ra); - - fastify.get('/localhost', {preHandler: fastify.requestAuthorizer.authorizeLocalhost}, (req, reply) => { - return reply.code(200).send(); - }); - const result = await fastify.inject({method: 'GET', url: '/localhost', remoteAddress: '192.168.0.1'}); - - expect(result.statusCode).toBe(200); - }); - - it('overrides session token authorizer', async () => { + it('always accepts session tokens', () => { const ra = new RequestAuthorizer({auth: {required: false}} as unknown as ConfigType, fakeLogger()); - const fastify = Fastify(); - fastify.decorate('requestAuthorizer', ra); - fastify.get('/sessionToken', {preHandler: fastify.requestAuthorizer.authorizeSessionToken}, (req, reply) => { - return reply.code(200).send(); - }); - const result = await fastify.inject({method: 'GET', url: '/sessionToken', remoteAddress: '192.168.0.1'}); + const {sessionToken} = ra.createSessionToken(); - expect(result.statusCode).toBe(200); + expect(ra.accessTokenIsValid(sessionToken)).toBeTruthy(); + expect(ra.sessionTokenIsValid('invalid')).toBeTruthy(); }); }); }); diff --git a/node-server/src/server/services/request_authorizer.ts b/node-server/src/server/services/token_service.ts similarity index 77% rename from node-server/src/server/services/request_authorizer.ts rename to node-server/src/server/services/token_service.ts index da44cc6..34b9400 100644 --- a/node-server/src/server/services/request_authorizer.ts +++ b/node-server/src/server/services/token_service.ts @@ -1,11 +1,10 @@ import type {ConfigType} from '@shared/config/config_schema.js'; import type {Logger} from '@shared/logger/logger.js'; -import type {DoneFuncWithErrOrRes, FastifyReply, FastifyRequest} from 'fastify'; import crypto from 'node:crypto'; const MAX_TIMESTAMP = 8640000000000000; -export default class RequestAuthorizer { +export default class TokenService { private _validAccessTokens: {[key: string]: Date} = {}; private _validSessionTokens: {[key: string]: Date} = {}; private _currentAccessToken = ''; @@ -21,9 +20,6 @@ export default class RequestAuthorizer { this._updateAccessTokens(); }, this._config.auth.accessTokenRefreshIntervalSec * 1000); } - - this.authorizeLocalhost = this.authorizeLocalhost.bind(this); - this.authorizeSessionToken = this.authorizeSessionToken.bind(this); } /** @@ -152,41 +148,4 @@ export default class RequestAuthorizer { return {sessionToken, expires}; } - - /** - * Fastify preHandler hook to accept/reject localhost connections - * @param request Fastify request object - * @param reply Fastify reply object - * @param done Fastify done function - */ - authorizeLocalhost( - request: FastifyRequest<{Querystring: {sessionToken?: string}}>, - reply: FastifyReply, - done: DoneFuncWithErrOrRes, - ) { - if (!this._config.auth.required) return done(); - if (request.socket.remoteAddress === '127.0.0.1') return done(); - - reply.code(403); - done(new Error('Unauthorized')); - } - - /** - * Fastify preHandler hook to accept/reject localhost and session token connections - * @param request Fastify request object - * @param reply Fastify reply object - * @param done Fastify done function - */ - authorizeSessionToken( - request: FastifyRequest<{Querystring: {sessionToken?: string}}>, - reply: FastifyReply, - done: DoneFuncWithErrOrRes, - ) { - if (!this._config.auth.required) return done(); - if (request.socket.remoteAddress === '127.0.0.1') return done(); - if (this.sessionTokenIsValid(request.query.sessionToken)) return done(); - - reply.code(403); - done(new Error('Unauthorized')); - } } diff --git a/node-server/src/server/start_server.ts b/node-server/src/server/start_server.ts index 01537e8..8c4308b 100644 --- a/node-server/src/server/start_server.ts +++ b/node-server/src/server/start_server.ts @@ -8,14 +8,18 @@ import fastifyWebsocket from '@fastify/websocket'; import websocketHandler from './routes/websocket_handler.js'; import fastifyHelmet from '@fastify/helmet'; import fastifySensible from '@fastify/sensible'; -import RequestAuthorizer from './services/request_authorizer.js'; +import TokenService from './services/token_service.js'; import accessTokenHandler from './routes/session_auth_handler.js'; declare module 'fastify' { export interface FastifyInstance { config: ConfigType; transcriptionEngine: TranscriptionEngine; - requestAuthorizer: RequestAuthorizer; + tokenService: TokenService; + } + + export interface FastifyRequest { + authorizationExpiryTimeout?: number; } } @@ -51,7 +55,7 @@ export default function createServer(config: ConfigType, logger: Logger) { // Make configuration and transcription engine avaiable on fastify instance (dependency injection) fastify.decorate('config', config); fastify.decorate('transcriptionEngine', new TranscriptionEngine(config, logger)); - fastify.decorate('requestAuthorizer', new RequestAuthorizer(config, logger)); + fastify.decorate('tokenService', new TokenService(config, logger)); // Register routes fastify.register(websocketHandler); diff --git a/node-server/src/shared/config/config_schema.ts b/node-server/src/shared/config/config_schema.ts index 829f78b..980cc41 100644 --- a/node-server/src/shared/config/config_schema.ts +++ b/node-server/src/shared/config/config_schema.ts @@ -50,6 +50,7 @@ const AUTH_CONFIG = Type.Union([ }), Type.Object({ REQUIRE_AUTH: Type.Const(true), + SOURCE_TOKEN: Type.String({minLength: 1}), ACCESS_TOKEN_BYTES: Type.Number({minimum: 1}), ACCESS_TOKEN_REFRESH_INTERVAL_SEC: Type.Number({minimum: 1}), ACCESS_TOKEN_VALID_PERIOD_SEC: Type.Number({minimum: 1}), @@ -91,6 +92,7 @@ export type ConfigType = Readonly<{ | {required: false} | { required: true; + sourceToken: string; accessTokenBytes: number; accessTokenRefreshIntervalSec: number; accessTokenValidPeriodSec: number; diff --git a/node-server/src/shared/config/load_config.ts b/node-server/src/shared/config/load_config.ts index 842b825..a6c2654 100644 --- a/node-server/src/shared/config/load_config.ts +++ b/node-server/src/shared/config/load_config.ts @@ -53,6 +53,7 @@ export default function loadConfig(path?: string): ConfigType { }, auth: { required: env.REQUIRE_AUTH, + sourceToken: env.SOURCE_TOKEN, accessTokenBytes: env.ACCESS_TOKEN_BYTES, accessTokenRefreshIntervalSec: env.ACCESS_TOKEN_REFRESH_INTERVAL_SEC, accessTokenValidPeriodSec: env.ACCESS_TOKEN_VALID_PERIOD_SEC, diff --git a/node-server/template.env b/node-server/template.env index d012af3..d88ef8a 100644 --- a/node-server/template.env +++ b/node-server/template.env @@ -1,7 +1,7 @@ NODE_ENV="development" LOG_LEVEL="debug" -#### Host and port API webserver should listen on +# ### Host and port API webserver should listen on HOST="0.0.0.0" PORT=8080 CORS_ORIGIN='*' @@ -11,13 +11,15 @@ USE_HTTPS=false KEY_FILEPATH='' CERTIFICATE_FILEPATH='' -#### URL to whisper service +# ### URL to whisper service WHISPER_SERVICE_ENDPOINT="ws://127.0.0.1:8000/whisper?api_key=CHANGEME&model_key=faster-whisper:cpu-tiny-en" WHISPER_RECONNECT_INTERVAL_SEC=1 -#### Authentication settings +# ### Authentication settings # Enable or disable authentication REQUIRE_AUTH=true +# Key used by frontend to connect as audio source +SOURCE_TOKEN="CHANGEME" # How many bytes of random data should be used to generate access token ACCESS_TOKEN_BYTES=8 # How often access token should be refreshed in seconds diff --git a/.env b/template.env similarity index 92% rename from .env rename to template.env index 59a3711..bf606d3 100644 --- a/.env +++ b/template.env @@ -16,6 +16,8 @@ WHISPER_RECONNECT_INTERVAL_SEC=1 #### Authentication settings # Enable or disable authentication REQUIRE_AUTH=false +# Key used by frontend to connect as audio source +SOURCE_TOKEN="CHANGEME" # How many bytes of random data should be used to generate access token ACCESS_TOKEN_BYTES=8 # How often access token should be refreshed in seconds