From 714a2b1006d499165e8d1a9356dc5e011f665b03 Mon Sep 17 00:00:00 2001 From: Horoli Date: Wed, 14 Oct 2026 19:33:03 +0900 Subject: [PATCH] Use JSON server configuration and repository database name --- .config.example.json | 13 ++++++++++ .env.example | 28 ---------------------- .gitignore | 3 ++- README.md | 11 +++++---- constants/server.ts | 2 +- docs/deployment.md | 16 ++++++++----- ecosystem.config.cjs | 3 --- package.json | 4 ++-- scripts/cleanup-legacy-data.ts | 7 +++--- src/server/config.ts | 43 ++++++++++++++++++++++++++++++++++ src/server/index.ts | 9 ++++--- tests/server-config.test.ts | 37 +++++++++++++++++++++++++++++ 12 files changed, 122 insertions(+), 54 deletions(-) create mode 100644 .config.example.json delete mode 100644 .env.example create mode 100644 src/server/config.ts create mode 100644 tests/server-config.test.ts diff --git a/.config.example.json b/.config.example.json new file mode 100644 index 0000000..f3e050b --- /dev/null +++ b/.config.example.json @@ -0,0 +1,13 @@ +{ + "MONGODB_URI": "mongodb://127.0.0.1:27017/?directConnection=true", + "MONGODB_DATABASE": "ssessesse-ki-battle", + "HOST": "127.0.0.1", + "PORT": 2100, + "TRUST_PROXY": "loopback", + "LOG_LEVEL": "info", + "ADMIN_USER": "", + "ADMIN_PASSWORD": "", + "STUN_URLS": [], + "TURN_URLS": [], + "TURN_SHARED_SECRET": "" +} diff --git a/.env.example b/.env.example deleted file mode 100644 index 3bb078d..0000000 --- a/.env.example +++ /dev/null @@ -1,28 +0,0 @@ -# Fastify + Socket.IO use one application port. -HOST=127.0.0.1 -PORT=2100 - -# Only trust the local reverse proxy. Leave empty for direct connections. -TRUST_PROXY=loopback -LOG_LEVEL=info - -# Set credentials/authSource for your private MongoDB deployment. -MONGODB_URI=mongodb://127.0.0.1:27017/?directConnection=true - -# Use a NEW database to start with empty users and records; no old data is deleted. -MONGODB_DATABASE=dragon_ball_production - -# Set a dedicated test endpoint before database integration tests. -# Tests must use a separate, isolated database and never reuse the application database. -TEST_MONGODB_URI= - -# Set BOTH to enable /admin/analytics. Keep real credentials in your private .env. -# Use HTTPS for the deployed administrator page and API. -ADMIN_USER= -ADMIN_PASSWORD= - -# WebRTC: comma-separated ICE URLs. Use HTTPS outside localhost. -# TURN uses coturn-compatible expiring HMAC credentials; never expose this secret to Vite. -STUN_URLS= -TURN_URLS= -TURN_SHARED_SECRET= diff --git a/.gitignore b/.gitignore index f3e48ad..282191a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,8 @@ node_modules/ dist/ .env .env.* -!.env.example +.config*.json +!.config.example.json .work/ .tmp/ .superpowers/ diff --git a/README.md b/README.md index dccbdba..4654aa9 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,12 @@ Node.js 24 이상, MongoDB Standalone이 필요하다. ```powershell npm ci -Copy-Item .env.example .env +Copy-Item .config.example.json .config.json npm run build npm start ``` -`.env`의 `MONGODB_URI`, `MONGODB_DATABASE`, `HOST`, `PORT`를 설정한다. 기본 접속은 `http://localhost:2100`이다. 외부 브라우저에서는 **HTTPS**가 필요하다. 관리자 인증과 Web Crypto를 포함한 기능을 보호하는 프록시 뒤에 배치한다. +`.config.json`의 `MONGODB_URI`, `MONGODB_DATABASE`, `HOST`, `PORT`를 설정한다. 기본 접속은 `http://localhost:2100`이다. 외부 브라우저에서는 **HTTPS**가 필요하다. 관리자 인증과 Web Crypto를 포함한 기능을 보호하는 프록시 뒤에 배치한다. Fastify HTTP API·페이지·Socket.IO `/signal`은 한 앱 포트와 MongoDB 연결 풀을 공유한다. 앱 다중 인스턴스의 시그널링 공유는 구현하지 않았다. 개발 중 클라이언트는 다시 빌드하고, 서버는 `npm run dev`로 감시 실행한다. 실행 중인 배포 폴더를 덮어쓰지 않고 새 빌드를 준비한 뒤 교체한다. @@ -51,7 +51,7 @@ Socket.IO는 방 대기와 SDP/ICE 협상에만 사용하고 DataChannel 준비 ## STUN / TURN -`.env.example`의 `STUN_URLS`, `TURN_URLS`, `TURN_SHARED_SECRET`을 운영 환경에 맞게 설정한다. URL은 쉼표로 구분한다. 직접 연결이 불가능한 네트워크에서는 TURN이 필요하다. TURN 포트·인증·방화벽과 중계 트래픽 비용은 별도 운영 대상이다. +`.config.json`의 `STUN_URLS`, `TURN_URLS`, `TURN_SHARED_SECRET`을 운영 환경에 맞게 설정한다. URL은 JSON 문자열 배열로 지정한다. 직접 연결이 불가능한 네트워크에서는 TURN이 필요하다. TURN 포트·인증·방화벽과 중계 트래픽 비용은 별도 운영 대상이다. 서버는 방 참가자에게 coturn 호환 단기 HMAC 자격 증명만 제공한다. 공유 비밀값은 서버 환경에만 두며 `VITE_*` 변수나 번들에 넣지 않는다. ICE 설정이 비어 있어도 일부 로컬 네트워크에서 동작할 수 있지만 외부 접속 가능성을 뜻하지 않는다. 운영 전 실제 TURN 경로와 모바일 네트워크 전환을 별도로 검증한다. @@ -86,8 +86,8 @@ Socket.IO는 방 대기와 SDP/ICE 협상에만 사용하고 DataChannel 준비 기존 DB 정리는 앱을 중지한 상태에서 아래 명령으로 실행한다. 기본 실행은 대상 건수만 조회하며, 적용할 때는 새 백업 파일이 필요하다. 사용자 식별·방문 이력·현재 세션 전적·임시 방은 보존하고, `matches`, 구형 서버 전용 이벤트·접속 표본, `players.firstMatchAt`만 제거한다. 서버 시작 시 자동 삭제하지 않는다. ```powershell -node --env-file-if-exists=.env --import tsx scripts/cleanup-legacy-data.ts --database dragon_ball -node --env-file-if-exists=.env --import tsx scripts/cleanup-legacy-data.ts --database dragon_ball --apply --backup .work/legacy-cleanup/removed-data.ejson +node --import tsx scripts/cleanup-legacy-data.ts --database ssessesse-ki-battle +node --import tsx scripts/cleanup-legacy-data.ts --database ssessesse-ki-battle --apply --backup .work/legacy-cleanup/removed-data.ejson ``` 이벤트·방문 세션·활동 표본은 90일 보관한다. 원문 UA·URL·방 코드·인증 토큰·IP·정밀 위치·오류 본문은 수집하지 않는다. 유입 도메인과 허용 캠페인 분류만 보관한다. 분석 전송 대기는 클라이언트 최대 100건·30분·6회 시도, 서버 최대 100건·30분이다. 분석 장애는 게임 진행과 결과 집계를 막지 않는다. @@ -102,6 +102,7 @@ npm run build ``` 테스트는 `dragon_ball_test_*` DB만 생성·정리한다. 환경변수가 없으면 DB 테스트가 skip되므로 통합 검증으로 간주하지 않는다. +테스트 전용 `TEST_MONGODB_URI`는 실행 환경변수로 유지하며 앱의 `.config.json` 연결을 자동으로 재사용하지 않는다. `tests/preview-server.ts`는 포트 3001의 로컬 검증 전용 서버다. `UI_PREVIEW_CLIENT`로 별도 빌드 경로를 지정할 수 있다. `/__test/peer`는 소켓·RTC 상태를 노출하는 진단 화면, `/__test/mobile`은 390×844 프레임(`?width=320&height=568` 지원), `/__test/desktop`은 1280×720 프레임이다. `/__test/admin`의 테스트 인증 프록시는 운영 빌드에 포함되지 않는다. diff --git a/constants/server.ts b/constants/server.ts index 0c5ad80..14ea369 100644 --- a/constants/server.ts +++ b/constants/server.ts @@ -7,4 +7,4 @@ export const HTTP_MESSAGES = { } as const; export const SERVER_LOG_LEVELS = ['fatal', 'error', 'warn', 'info', 'debug', 'trace', 'silent'] as const; export const SAFE_ERROR_TYPES = ['Error', 'TypeError', 'RangeError', 'SyntaxError', 'MongoServerError', 'MongoNetworkError', 'MongoServerSelectionError', 'MongoOperationTimeoutError'] as const; -export const SAFE_ERROR_CODES = ['ECONNREFUSED', 'ETIMEDOUT', 'EADDRINUSE', 'EACCES', 'ENOTFOUND', 'INVALID_TRUST_PROXY', 'MISSING_MONGODB_URI'] as const; +export const SAFE_ERROR_CODES = ['ECONNREFUSED', 'ETIMEDOUT', 'EADDRINUSE', 'EACCES', 'ENOTFOUND', 'INVALID_TRUST_PROXY', 'CONFIG_NOT_FOUND', 'INVALID_CONFIG'] as const; diff --git a/docs/deployment.md b/docs/deployment.md index 830fcc1..342ff2d 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -10,14 +10,14 @@ Linux 서버, Node.js 24 이상, MongoDB, 같은 호스트의 HTTPS nginx를 기 git clone https://gitea.horoli.kr/horoli/ssessesse-ki-battle.git dragon_ball cd dragon_ball npm ci -cp .env.example .env -chmod 600 .env +cp .config.example.json .config.json +chmod 600 .config.json ``` -`.env`에서 다음을 설정한다. +`.config.json`에서 다음을 설정한다. - `MONGODB_URI`: 서버에서 연결 가능한 MongoDB 주소와 인증 정보. DB 포트를 인터넷에 공개하지 않는다. -- `MONGODB_DATABASE`: 초기화하려면 기존에 사용하지 않은 DB 이름을 지정한다. 예시의 `dragon_ball_production`도 이미 사용했다면 다른 이름을 지정한다. 기존 DB를 자동 삭제하지 않는다. +- `MONGODB_DATABASE`: 저장소 이름과 같은 `ssessesse-ki-battle`을 사용한다. 해당 DB가 비어 있을 때 새 데이터로 시작하며, 기존 DB를 자동 삭제하거나 이전하지 않는다. - `HOST=127.0.0.1`, `PORT=2100`, `TRUST_PROXY=loopback`: 같은 서버 nginx 전용. `TRUST_PROXY`는 쉼표로 구분한 신뢰 프록시 IP/CIDR 또는 `loopback`이며 직접 접속이면 비운다. - `ADMIN_USER`, `ADMIN_PASSWORD`: 둘 다 지정하면 관리자 기능 활성화. 강한 전용 비밀번호를 사용한다. - `STUN_URLS`, `TURN_URLS`, `TURN_SHARED_SECRET`: 친구전 ICE 설정. TURN 서버의 공유 비밀과 일치해야 하며 TURN 수신 포트·relay 포트 범위는 별도로 개방한다. @@ -32,7 +32,11 @@ pm2 save pm2 startup ``` -`pm2 startup`이 출력하는 명령을 실행한다. Node 버전 관리자를 변경하면 startup 설정도 갱신한다. PM2 설정은 실행 위치와 무관하게 저장소 루트를 사용하며 `.env`가 없으면 시작하지 않는다. 운영 환경에 이미 설정된 환경변수는 Node의 `.env` 값보다 우선하므로 충돌하는 값을 서비스 셸에 남기지 않는다. +`pm2 startup`이 출력하는 명령을 실행한다. Node 버전 관리자를 변경하면 startup 설정도 갱신한다. PM2는 저장소 루트를 사용하며 `.config.json`이 없거나 형식이 잘못되면 시작하지 않는다. 앱 설정은 이 JSON 파일만 읽으며 같은 이름의 환경변수나 기존 `.env`로 덮어쓰지 않는다. `npm run dev`와 `npm start`는 프로젝트 루트에서 실행한다. + +JSON의 `PORT`는 숫자, `STUN_URLS`·`TURN_URLS`는 문자열 배열이다. URL이 없으면 `[]`를 사용한다. 로컬과 운영 모두 DB 이름은 `ssessesse-ki-battle`이므로 데이터를 분리하려면 `MONGODB_URI`가 서로 다른 MongoDB 인스턴스를 가리켜야 한다. 같은 인스턴스와 DB 이름을 사용하면 데이터를 공유한다. 설정 수정 후 프로세스를 재시작한다. 누락 시 로그의 `CONFIG_NOT_FOUND`, 형식 오류 시 `INVALID_CONFIG`를 확인한다. + +기존 `.env` 배포에서 전환할 때는 `.config.example.json`을 `.config.json`으로 복사한 후 기존 값을 옮긴다. 이전 PM2 프로세스에 저장된 `--env-file` 실행 인자를 없애려면 앱 중지·빌드 후 `pm2 delete dragon-ball`, `pm2 start ecosystem.config.cjs`, `pm2 save` 순서로 등록한다. 기존 `.env`는 백업으로 보관할 수 있지만 앱에서 읽지 않는다. ```bash curl --fail http://127.0.0.1:2100/health @@ -74,7 +78,7 @@ curl --fail http://127.0.0.1:2100/ready pm2 save ``` -각 명령이 성공한 경우에만 다음 단계로 진행한다. 빌드 실패 시 재시작하지 말고 수정하거나 이전 커밋으로 복구한 후 다시 설치·빌드한다. `.env`는 Git에서 제외되어 유지된다. 데이터 백업은 MongoDB를 별도로 관리한다. +각 명령이 성공한 경우에만 다음 단계로 진행한다. 빌드 실패 시 재시작하지 말고 수정하거나 이전 커밋으로 복구한 후 다시 설치·빌드한다. `.config.json`는 Git에서 제외되어 유지된다. 데이터 백업은 MongoDB를 별도로 관리한다. ## 로그 관리 및 운영 확인 diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index 5a09fac..9bd918c 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -1,11 +1,8 @@ -const path = require('node:path'); - module.exports = { apps: [{ name: 'dragon-ball', cwd: __dirname, script: 'dist/server/src/server/index.js', - node_args: [`--env-file=${path.join(__dirname, '.env')}`], exec_mode: 'fork', instances: 1, watch: false, diff --git a/package.json b/package.json index 876a332..281f328 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,8 @@ "node": ">=24" }, "scripts": { - "dev": "node --env-file-if-exists=.env --import tsx --watch src/server/index.ts", - "start": "node --env-file-if-exists=.env dist/server/src/server/index.js", + "dev": "node --import tsx --watch src/server/index.ts", + "start": "node dist/server/src/server/index.js", "typecheck": "tsc --noEmit", "build": "tsc -p tsconfig.server.json && vite build", "test": "tsx --test tests/*.test.ts" diff --git a/scripts/cleanup-legacy-data.ts b/scripts/cleanup-legacy-data.ts index c3311d0..867a848 100644 --- a/scripts/cleanup-legacy-data.ts +++ b/scripts/cleanup-legacy-data.ts @@ -3,6 +3,7 @@ import { dirname, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; import { BSON, MongoClient, type Db } from 'mongodb'; import { LEGACY_EVENT_TYPES } from '../constants/legacy-data.js'; +import { loadServerConfig } from '../src/server/config.js'; // Run while the old app is stopped. Never called by server startup. export async function cleanupLegacyData(db: Db, backupPath?: string) { @@ -32,12 +33,12 @@ export async function cleanupLegacyData(db: Db, backupPath?: string) { if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { const args = process.argv.slice(2), apply = args.includes('--apply'); const database = args[args.indexOf('--database') + 1]; - const configured = process.env.MONGODB_DATABASE || 'dragon_ball'; + const config = loadServerConfig(); + const configured = config.MONGODB_DATABASE; if (!args.includes('--database') || database !== configured || ['admin', 'config', 'local'].includes(database)) throw new Error('Specify --database matching MONGODB_DATABASE'); - if (!process.env.MONGODB_URI) throw new Error('MONGODB_URI_REQUIRED'); const backup = args.includes('--backup') ? args[args.indexOf('--backup') + 1] : undefined; if (apply && (!backup || backup.startsWith('--'))) throw new Error('--apply requires --backup '); - const client = new MongoClient(process.env.MONGODB_URI, { serverSelectionTimeoutMS: 5000 }); + const client = new MongoClient(config.MONGODB_URI, { serverSelectionTimeoutMS: 5000 }); try { await client.connect(); console.log(JSON.stringify(await cleanupLegacyData(client.db(database), apply ? resolve(backup!) : undefined), null, 2)); } finally { await client.close(); } } diff --git a/src/server/config.ts b/src/server/config.ts new file mode 100644 index 0000000..3331d12 --- /dev/null +++ b/src/server/config.ts @@ -0,0 +1,43 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { SERVER_LOG_LEVELS } from '../../constants/server.js'; +import { parseTrustProxy } from './http-operations.js'; + +export interface ServerConfig { + MONGODB_URI: string; MONGODB_DATABASE: string; HOST: string; PORT: number; + TRUST_PROXY: string; LOG_LEVEL: string; ADMIN_USER: string; ADMIN_PASSWORD: string; + STUN_URLS: string[]; TURN_URLS: string[]; TURN_SHARED_SECRET: string; +} +const invalid = () => Object.assign(new Error('INVALID_CONFIG'), { code: 'INVALID_CONFIG' }); +export function loadServerConfig(path = resolve('.config.json')): ServerConfig { + let input: unknown; + try { input = JSON.parse(readFileSync(path, 'utf8').replace(/^\uFEFF/, '')); } + catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') throw Object.assign(new Error('CONFIG_NOT_FOUND'), { code: 'CONFIG_NOT_FOUND' }); + throw invalid(); + } + if (!input || typeof input !== 'object' || Array.isArray(input)) throw invalid(); + const value = input as Record; + const text = (key: string, fallback?: string): string => { + const result = value[key] ?? fallback; + if (typeof result !== 'string' || (fallback === undefined && !result.trim())) throw invalid(); + return result; + }; + const urls = (key: string): string[] => { + const result = value[key] ?? []; + if (!Array.isArray(result) || result.some(url => typeof url !== 'string' || !url.trim())) throw invalid(); + return result.map(url => url.trim()); + }; + const config: ServerConfig = { + MONGODB_URI: text('MONGODB_URI'), MONGODB_DATABASE: text('MONGODB_DATABASE'), + HOST: text('HOST', '127.0.0.1'), PORT: (value.PORT ?? 2100) as number, + TRUST_PROXY: text('TRUST_PROXY', ''), LOG_LEVEL: text('LOG_LEVEL', 'info'), + ADMIN_USER: text('ADMIN_USER', ''), ADMIN_PASSWORD: text('ADMIN_PASSWORD', ''), + STUN_URLS: urls('STUN_URLS'), TURN_URLS: urls('TURN_URLS'), TURN_SHARED_SECRET: text('TURN_SHARED_SECRET', ''), + }; + if (!/^mongodb(?:\+srv)?:\/\//.test(config.MONGODB_URI) || !config.HOST.trim() + || !Number.isInteger(config.PORT) || config.PORT < 1 || config.PORT > 65535 + || !SERVER_LOG_LEVELS.some(level => level === config.LOG_LEVEL)) throw invalid(); + try { parseTrustProxy(config.TRUST_PROXY); } catch { throw invalid(); } + return config; +} diff --git a/src/server/index.ts b/src/server/index.ts index bd69ca2..b640bc0 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,15 +1,14 @@ import { resolve } from 'node:path'; import { createGameServer } from './create-server.js'; import { logSafeError, parseTrustProxy } from './http-operations.js'; -const { MONGODB_URI, MONGODB_DATABASE = 'dragon_ball', HOST = '127.0.0.1', PORT = '2100', ADMIN_USER, ADMIN_PASSWORD } = process.env; -const urls = (value: string | undefined) => value?.split(',').map(s => s.trim()).filter(Boolean); +import { loadServerConfig } from './config.js'; const startupLogger = { error: (data: unknown) => console.error(JSON.stringify({ level: 50, time: Date.now(), ...data as object })) }; let app: Awaited> | undefined; try { - if (!MONGODB_URI) throw Object.assign(new Error(), { code: 'MISSING_MONGODB_URI' }); + const { MONGODB_URI, MONGODB_DATABASE, HOST, PORT, ADMIN_USER, ADMIN_PASSWORD, TRUST_PROXY, LOG_LEVEL, STUN_URLS, TURN_URLS, TURN_SHARED_SECRET } = loadServerConfig(); app = await createGameServer({ mongoUri: MONGODB_URI, databaseName: MONGODB_DATABASE, clientDirectory: resolve('dist/client'), adminUser: ADMIN_USER, adminPassword: ADMIN_PASSWORD, - trustProxy: parseTrustProxy(process.env.TRUST_PROXY), logLevel: process.env.LOG_LEVEL, - rtc: { stunUrls: urls(process.env.STUN_URLS), turnUrls: urls(process.env.TURN_URLS), turnSecret: process.env.TURN_SHARED_SECRET } }); + trustProxy: parseTrustProxy(TRUST_PROXY), logLevel: LOG_LEVEL, + rtc: { stunUrls: STUN_URLS, turnUrls: TURN_URLS, turnSecret: TURN_SHARED_SECRET } }); let closing = false; for (const signal of ['SIGINT', 'SIGTERM'] as const) process.once(signal, () => { if (closing) return; diff --git a/tests/server-config.test.ts b/tests/server-config.test.ts new file mode 100644 index 0000000..e7c6b51 --- /dev/null +++ b/tests/server-config.test.ts @@ -0,0 +1,37 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { loadServerConfig } from '../src/server/config.js'; + +test('JSON configuration preserves database and secrets independently of environment variables', async t => { + const previous = process.env.MONGODB_DATABASE; + process.env.MONGODB_DATABASE = 'must_not_override_json'; + t.after(() => { if (previous === undefined) delete process.env.MONGODB_DATABASE; else process.env.MONGODB_DATABASE = previous; }); + const dir = await mkdtemp(join(tmpdir(), 'dragon-config-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const path = join(dir, '.config.json'); + const input = { MONGODB_URI: 'mongodb://localhost:27017', MONGODB_DATABASE: 'local_game', HOST: '127.0.0.1', PORT: 2100, + ADMIN_PASSWORD: ' spaces # $ remain ', TRUST_PROXY: 'loopback', STUN_URLS: ['stun:example.com:3478'] }; + await writeFile(path, JSON.stringify(input)); + const config = loadServerConfig(path); + assert.equal(config.MONGODB_DATABASE, 'local_game'); + assert.equal(config.ADMIN_PASSWORD, input.ADMIN_PASSWORD); + assert.equal(config.PORT, 2100); + assert.deepEqual(config.STUN_URLS, input.STUN_URLS); +}); + +test('missing, malformed and invalid configuration produces safe actionable error codes', async t => { + const dir = await mkdtemp(join(tmpdir(), 'dragon-config-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const path = join(dir, '.config.json'); + assert.throws(() => loadServerConfig(path), { code: 'CONFIG_NOT_FOUND' }); + await writeFile(path, '{"password":"private'); + assert.throws(() => loadServerConfig(path), error => (error as Error).message === 'INVALID_CONFIG'); + for (const input of [null, {}, { MONGODB_URI: 'secret', MONGODB_DATABASE: 'db', PORT: '2100' }, + { MONGODB_URI: 'mongodb://localhost', MONGODB_DATABASE: 'db', STUN_URLS: 'stun:host' }]) { + await writeFile(path, JSON.stringify(input)); + assert.throws(() => loadServerConfig(path), { code: 'INVALID_CONFIG' }); + } +});