From 63bbbac9222869b676a86c8a4796d13954a9e4e9 Mon Sep 17 00:00:00 2001 From: lofyer Date: Mon, 24 Aug 2026 17:59:49 +0800 Subject: [PATCH] Add PM2 deployment support --- .env.example | 13 ++++ README.md | 67 ++++++++++-------- compose.yaml | 5 +- docs/operations.md | 75 ++++++++++++++++++++- ecosystem.config.cjs | 15 +++++ eslint.config.js | 3 +- package-lock.json | 14 ++-- package.json | 3 + src/server/auth-service.ts | 15 +++++ src/server/cli/create-admin.ts | 2 + src/server/config.ts | 120 ++++++++++++++++++++++++++++++--- src/server/environment.ts | 20 ++++++ src/server/main.ts | 11 +++ tests/auth-service.test.ts | 30 +++++++++ tests/config.test.ts | 53 +++++++++++++-- tests/environment.test.ts | 59 ++++++++++++++++ 16 files changed, 450 insertions(+), 55 deletions(-) create mode 100644 ecosystem.config.cjs create mode 100644 src/server/environment.ts create mode 100644 tests/environment.test.ts diff --git a/.env.example b/.env.example index 37a6654..0f73c86 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,19 @@ +# 可直接复制为 .env 用于本地 Node / PM2 启动。 +# 公网部署前必须修改初始管理员凭据和对外地址。 +NODE_ENV=development +HOST=127.0.0.1 +PORT=3000 +DATA_DIRECTORY=./data +# 留空时自动生成并保存到 DATA_DIRECTORY/.app-secret。 APP_SECRET= +INITIAL_ADMIN_EMAIL=admin@example.com +INITIAL_ADMIN_PASSWORD=change-this-admin-password +INITIAL_ADMIN_NAME=Administrator SESSION_TTL_HOURS=12 +TRUST_PROXY=false PUBLIC_RATE_LIMIT_PER_HOUR=20 PUBLIC_INSTALLATION_LIMIT_PER_HOUR=8 MAX_SCREENSHOT_BYTES=5242880 +# Docker Compose 使用此域名;Node production 改用 PUBLIC_ORIGIN。 FEEDBACK_DOMAIN=feedback.example.com +# PUBLIC_ORIGIN=https://feedback.example.com diff --git a/README.md b/README.md index 1995233..fd493fa 100644 --- a/README.md +++ b/README.md @@ -56,31 +56,10 @@ cp .env.example .env 编辑 `.env`: - 将 `FEEDBACK_DOMAIN` 改为真实域名 -- 用至少 32 字节的随机值替换 `APP_SECRET` +- 设置 `INITIAL_ADMIN_EMAIL` 和 `INITIAL_ADMIN_PASSWORD` +- 可按需设置 `INITIAL_ADMIN_NAME` -可用以下方式生成 secret: - -```sh -APP_SECRET="$(openssl rand -hex 32)" -sed -i "s|^APP_SECRET=.*|APP_SECRET=${APP_SECRET}|" .env -unset APP_SECRET -chmod 600 .env -``` - -构建镜像并创建首个管理员: - -```sh -docker compose build -read -rsp "Administrator password: " PRODUCT_ISSUE_ADMIN_PASSWORD -echo -export PRODUCT_ISSUE_ADMIN_PASSWORD -docker compose run --rm \ - -e PRODUCT_ISSUE_ADMIN_PASSWORD \ - app node dist/server/server/cli/create-admin.js \ - --email admin@example.com \ - --name "MesaLogo Admin" -unset PRODUCT_ISSUE_ADMIN_PASSWORD -``` +`APP_SECRET` 可以留空,应用会在数据目录生成并持久化随机 secret。 启动服务: @@ -90,17 +69,23 @@ docker compose ps curl "https://feedback.example.com/health" ``` +首次启动只在数据库没有管理员时根据 `.env` 创建初始管理员,不会在后续 +重启时重置密码。创建完成后建议从 `.env` 删除 +`INITIAL_ADMIN_PASSWORD`。 + 将示例域名替换为 `.env` 中的真实域名。管理后台位于 `https://你的域名/admin/`。 -> 管理员创建命令也用于密码重置。重置后,该管理员的所有既有会话会立即 -> 失效。不要把密码写入 `.env`、命令参数或 Git。 +> `admin:create` 命令仍可用于创建或重置其他管理员。重置后,该管理员的 +> 所有既有会话会立即失效。 ## 本地开发 要求 Node.js 24 或更新版本。 ```sh +cp .env.example .env +# 示例配置可直接启动;对外部署前必须修改初始管理员凭据 npm ci npm run typecheck npm test @@ -108,9 +93,12 @@ npm run lint npm run build ``` -开发服务器需要通过进程环境提供 `APP_SECRET` 和其他配置。后端默认监听 -`127.0.0.1:3000`,管理后台 Vite 服务默认监听 `127.0.0.1:5174` 并代理 -`/api`: +Node 后端和管理员 CLI 会自动读取项目根目录的 `.env`。已由 shell、PM2 +或系统服务注入的同名环境变量优先,不会被文件中的值覆盖。可通过 +`ENV_FILE=/path/to/file` 指定其他配置文件。 + +后端默认监听 `127.0.0.1:3000`,管理后台 Vite 服务默认监听 +`127.0.0.1:5174` 并代理 `/api`: ```sh npm run dev @@ -118,6 +106,27 @@ npm run dev npm run dev:admin ``` +## PM2 运行 + +没有 Docker 时可使用 PM2 直接管理构建后的 Node 进程: + +```sh +npm install --global pm2 +cp .env.example .env +# 示例配置可直接启动;生产环境必须先修改初始管理员凭据 +npm run pm2:start +pm2 save +``` + +初始管理员仅在数据库为空时创建。确认可以登录后,建议删除 `.env` 中的 +`INITIAL_ADMIN_PASSWORD`,再执行 `npm run pm2:reload`。未设置 +`APP_SECRET` 时,应用会把自动生成的 secret 保存到 +`DATA_DIRECTORY/.app-secret`。 + +更新代码后运行 `npm run pm2:reload`,查看日志使用 +`pm2 logs product-issue`。默认访问地址为 +`http://127.0.0.1:3000/admin/`。 + ## 文档 - [公共 API 契约](docs/api.md) diff --git a/compose.yaml b/compose.yaml index e1a5435..78d387d 100644 --- a/compose.yaml +++ b/compose.yaml @@ -22,7 +22,10 @@ services: PORT: 3000 PUBLIC_ORIGIN: https://${FEEDBACK_DOMAIN:?FEEDBACK_DOMAIN is required} DATA_DIRECTORY: /app/data - APP_SECRET: ${APP_SECRET:?APP_SECRET is required} + APP_SECRET: ${APP_SECRET:-} + INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL:-} + INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-} + INITIAL_ADMIN_NAME: ${INITIAL_ADMIN_NAME:-Administrator} SESSION_TTL_HOURS: ${SESSION_TTL_HOURS:-12} TRUST_PROXY: "true" PUBLIC_RATE_LIMIT_PER_HOUR: ${PUBLIC_RATE_LIMIT_PER_HOUR:-20} diff --git a/docs/operations.md b/docs/operations.md index 6083446..3cdb3f1 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -2,12 +2,23 @@ ## 配置 -Docker Compose 从项目根目录 `.env` 读取以下值: +Docker Compose、直接运行的 Node 服务和管理员 CLI 都会从项目根目录 +`.env` 读取配置。已由 shell、PM2 或系统服务注入的同名变量优先。设置 +`ENV_FILE=/path/to/file` 可改用其他配置文件;显式指定的文件不存在时, +进程会拒绝启动。 | 变量 | 说明 | 默认值 | | --- | --- | --- | +| `NODE_ENV` | 运行环境:`development`、`test` 或 `production` | `development` | +| `HOST` | Node 服务监听地址 | `127.0.0.1` | +| `PORT` | Node 服务监听端口 | `3000` | +| `PUBLIC_ORIGIN` | production 对外 HTTPS origin | production 必填 | +| `DATA_DIRECTORY` | SQLite 与附件目录 | `./data` | | `FEEDBACK_DOMAIN` | Caddy 申请证书并对外服务的域名 | 必填 | -| `APP_SECRET` | 会话、IP 和安装标识 digest 的主 secret,至少 32 字符 | 必填 | +| `APP_SECRET` | 会话、IP 和安装标识 digest 的主 secret,不限制长度 | 自动生成并持久化 | +| `INITIAL_ADMIN_EMAIL` | 数据库为空时创建的初始管理员邮箱 | 可选 | +| `INITIAL_ADMIN_PASSWORD` | 初始管理员密码,12–128 字符 | 可选 | +| `INITIAL_ADMIN_NAME` | 初始管理员显示名 | `Administrator` | | `SESSION_TTL_HOURS` | 管理员会话时长 | `12` | | `PUBLIC_RATE_LIMIT_PER_HOUR` | 单 IP 每小时公开提交上限 | `20` | | `PUBLIC_INSTALLATION_LIMIT_PER_HOUR` | 单安装实例每小时公开提交上限 | `8` | @@ -16,10 +27,70 @@ Docker Compose 从项目根目录 `.env` 读取以下值: `.env` 包含 secret,不得提交到 Git 或复制到工单、聊天和日志中。建议在 密码管理器中保存一份加密副本,并在主机上执行 `chmod 600 .env`。 +未设置或留空 `APP_SECRET` 时,应用会生成随机值并以权限 `0600` 保存到 +`DATA_DIRECTORY/.app-secret`。后续启动会复用该文件。应将此文件与 +SQLite 和附件一起备份,否则恢复后现有管理员会话将失效。 + +`INITIAL_ADMIN_EMAIL` 和 `INITIAL_ADMIN_PASSWORD` 必须同时设置。应用仅在 +管理员表为空时创建该账号,后续重启不会同步或重置密码。确认初始账号 +可登录后,应从 `.env` 删除 `INITIAL_ADMIN_PASSWORD`。 + Compose 会从 `FEEDBACK_DOMAIN` 生成 `PUBLIC_ORIGIN=https://FEEDBACK_DOMAIN`。直接运行 Node 服务时, production 环境仍必须显式提供只含 HTTPS origin 的 `PUBLIC_ORIGIN`。 +## PM2 部署 + +没有 Docker 时,可通过 Node.js 24+ 和 PM2 启动服务。先安装依赖并准备 +配置: + +```sh +npm ci +npm install --global pm2 +cp .env.example .env +``` + +复制后的示例配置可以直接以 development 模式启动,初始账号为 +`admin@example.com`,示例密码仅用于首次本地运行。公网部署前必须修改 +初始管理员凭据并配置 production 对外地址: + +```dotenv +NODE_ENV=production +HOST=127.0.0.1 +PORT=3000 +PUBLIC_ORIGIN=https://feedback.example.com +DATA_DIRECTORY=./data +APP_SECRET= +INITIAL_ADMIN_EMAIL=admin@example.com +INITIAL_ADMIN_PASSWORD=replace-with-a-secure-password +INITIAL_ADMIN_NAME=Administrator +``` + +构建并启动: + +```sh +npm run pm2:start +pm2 save +pm2 status +curl --fail "http://127.0.0.1:3000/health" +``` + +登录成功后删除 `.env` 中的 `INITIAL_ADMIN_PASSWORD`,再执行 +`npm run pm2:reload`。数据库已有管理员时,即使保留这些变量也不会重置 +账号,但不应长期把明文密码保存在主机配置中。 + +`ecosystem.config.cjs` 固定使用单进程 fork 模式,避免多个进程同时管理单机 +SQLite 和本地附件。更新代码后执行: + +```sh +npm ci +npm run pm2:reload +pm2 logs product-issue --lines 100 +``` + +PM2 只管理 Node 进程,不提供 TLS。公网部署仍须在前方配置 Caddy、Nginx +或等效 HTTPS 反向代理,并且只让代理访问监听端口。 + ## 网络与 TLS - DNS A/AAAA 记录必须指向主机。 diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs new file mode 100644 index 0000000..0dc7046 --- /dev/null +++ b/ecosystem.config.cjs @@ -0,0 +1,15 @@ +module.exports = { + apps: [ + { + name: 'product-issue', + script: 'dist/server/server/main.js', + cwd: __dirname, + instances: 1, + exec_mode: 'fork', + autorestart: true, + watch: false, + max_memory_restart: '512M', + kill_timeout: 10_000 + } + ] +} diff --git a/eslint.config.js b/eslint.config.js index 339d553..401af2b 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -9,7 +9,8 @@ export default tseslint.config( 'node_modules/**', 'coverage/**', 'data/**', - 'eslint.config.js' + 'eslint.config.js', + 'ecosystem.config.cjs' ] }, eslint.configs.recommended, diff --git a/package-lock.json b/package-lock.json index 898a7db..b578618 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2224,13 +2224,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.0.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.0.tgz", - "integrity": "sha512-yZQa2zm87aRVcqDyH5+4Hv9KYgSdgwX1rFnGvpbzMaC7YAljmhBET93TPiTd3ObwTL+gSpIzPKg5BqVxdCvxKg==", + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.8.0" + "undici-types": "~7.18.0" } }, "node_modules/@types/react": { @@ -5166,9 +5166,9 @@ } }, "node_modules/undici-types": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", - "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "dev": true, "license": "MIT" }, diff --git a/package.json b/package.json index 36daba7..5d5834f 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,9 @@ "build:admin": "vite build", "build:server": "tsc -p tsconfig.server.json", "start": "node dist/server/server/main.js", + "pm2:start": "npm run build && pm2 start ecosystem.config.cjs", + "pm2:reload": "npm run build && pm2 reload ecosystem.config.cjs --update-env", + "pm2:stop": "pm2 stop product-issue", "admin:create": "tsx src/server/cli/create-admin.ts", "admin:create:prod": "node dist/server/server/cli/create-admin.js", "typecheck": "tsc --noEmit -p tsconfig.server.json && tsc --noEmit -p tsconfig.admin.json && tsc --noEmit -p tsconfig.test.json", diff --git a/src/server/auth-service.ts b/src/server/auth-service.ts index a29ce2e..109b468 100644 --- a/src/server/auth-service.ts +++ b/src/server/auth-service.ts @@ -86,6 +86,21 @@ export class AuthService { })() } + async createInitialAdmin(input: { + email: string + displayName: string + password: string + }): Promise { + const existingAdmin = this.database + .prepare('SELECT 1 FROM admins LIMIT 1') + .get() + if (existingAdmin) { + return false + } + await this.createAdmin(input) + return true + } + async login(email: string, password: string): Promise { this.pruneExpiredSessionsIfDue() const admin = this.database diff --git a/src/server/cli/create-admin.ts b/src/server/cli/create-admin.ts index 39db3f1..f44ce32 100644 --- a/src/server/cli/create-admin.ts +++ b/src/server/cli/create-admin.ts @@ -3,6 +3,7 @@ import { z } from 'zod' import { AuthService } from '../auth-service.js' import { loadConfig } from '../config.js' import { openDatabase } from '../database.js' +import { loadEnvironmentFile } from '../environment.js' const argumentsSchema = z.object({ email: z.email().max(254), @@ -27,6 +28,7 @@ function parseArguments(input: string[]): { email: string; name: string } { process.umask(0o077) +loadEnvironmentFile() const config = loadConfig() const password = process.env['PRODUCT_ISSUE_ADMIN_PASSWORD'] if (!password || password.length < 12 || password.length > 128) { diff --git a/src/server/config.ts b/src/server/config.ts index bbac99f..bfea4ad 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -1,3 +1,9 @@ +import { randomBytes } from 'node:crypto' +import { + mkdirSync, + readFileSync, + writeFileSync +} from 'node:fs' import { resolve } from 'node:path' import { z } from 'zod' @@ -5,6 +11,18 @@ const booleanEnvironmentSchema = z .enum(['true', 'false']) .transform((value) => value === 'true') +const optionalEnvironmentString = ( + schema: T +) => + z + .preprocess( + (value) => + typeof value === 'string' && value.trim() === '' + ? undefined + : value, + schema + ) + const configSchema = z .object({ NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), @@ -12,14 +30,16 @@ const configSchema = z PORT: z.coerce.number().int().min(1).max(65_535).default(3_000), PUBLIC_ORIGIN: z.url().optional(), DATA_DIRECTORY: z.string().min(1).default('./data'), - APP_SECRET: z - .string() - .min(32) - .refine( - (value) => - value !== 'replace-with-at-least-32-random-characters', - 'APP_SECRET must be a random production secret' - ), + APP_SECRET: optionalEnvironmentString(z.string().optional()), + INITIAL_ADMIN_EMAIL: optionalEnvironmentString( + z.email().max(254).optional() + ), + INITIAL_ADMIN_PASSWORD: optionalEnvironmentString( + z.string().min(12).max(128).optional() + ), + INITIAL_ADMIN_NAME: optionalEnvironmentString( + z.string().trim().min(1).max(120).optional() + ), SESSION_TTL_HOURS: z.coerce.number().int().min(1).max(168).default(12), TRUST_PROXY: booleanEnvironmentSchema.default(false), PUBLIC_RATE_LIMIT_PER_HOUR: z.coerce @@ -43,6 +63,17 @@ const configSchema = z }) .strict() .superRefine((value, context) => { + if ( + Boolean(value.INITIAL_ADMIN_EMAIL) !== + Boolean(value.INITIAL_ADMIN_PASSWORD) + ) { + context.addIssue({ + code: 'custom', + path: ['INITIAL_ADMIN_EMAIL'], + message: + 'INITIAL_ADMIN_EMAIL and INITIAL_ADMIN_PASSWORD must be configured together' + }) + } if (value.NODE_ENV === 'production' && !value.PUBLIC_ORIGIN) { context.addIssue({ code: 'custom', @@ -89,6 +120,59 @@ export type AppConfig = { publicRateLimitPerHour: number publicInstallationLimitPerHour: number maxScreenshotBytes: number + initialAdmin?: { + email: string + displayName: string + password: string + } +} + +function resolveAppSecret( + configuredSecret: string | undefined, + dataDirectory: string +): string { + if (configuredSecret !== undefined) { + return configuredSecret + } + + mkdirSync(dataDirectory, { recursive: true, mode: 0o700 }) + const secretFile = resolve(dataDirectory, '.app-secret') + try { + const savedSecret = readFileSync(secretFile, 'utf8').trim() + if (savedSecret) { + return savedSecret + } + } catch (error) { + if ( + !(error instanceof Error) || + !('code' in error) || + error.code !== 'ENOENT' + ) { + throw error + } + } + + const generatedSecret = randomBytes(32).toString('base64url') + try { + writeFileSync(secretFile, `${generatedSecret}\n`, { + encoding: 'utf8', + mode: 0o600, + flag: 'wx' + }) + return generatedSecret + } catch (error) { + if ( + error instanceof Error && + 'code' in error && + error.code === 'EEXIST' + ) { + const savedSecret = readFileSync(secretFile, 'utf8').trim() + if (savedSecret) { + return savedSecret + } + } + throw error + } } export function loadConfig( @@ -101,6 +185,9 @@ export function loadConfig( PUBLIC_ORIGIN: environment['PUBLIC_ORIGIN'], DATA_DIRECTORY: environment['DATA_DIRECTORY'], APP_SECRET: environment['APP_SECRET'], + INITIAL_ADMIN_EMAIL: environment['INITIAL_ADMIN_EMAIL'], + INITIAL_ADMIN_PASSWORD: environment['INITIAL_ADMIN_PASSWORD'], + INITIAL_ADMIN_NAME: environment['INITIAL_ADMIN_NAME'], SESSION_TTL_HOURS: environment['SESSION_TTL_HOURS'], TRUST_PROXY: environment['TRUST_PROXY'], PUBLIC_RATE_LIMIT_PER_HOUR: @@ -109,6 +196,7 @@ export function loadConfig( environment['PUBLIC_INSTALLATION_LIMIT_PER_HOUR'], MAX_SCREENSHOT_BYTES: environment['MAX_SCREENSHOT_BYTES'] }) + const dataDirectory = resolve(parsed.DATA_DIRECTORY) return { nodeEnv: parsed.NODE_ENV, host: parsed.HOST, @@ -116,13 +204,23 @@ export function loadConfig( ...(parsed.PUBLIC_ORIGIN ? { publicOrigin: new URL(parsed.PUBLIC_ORIGIN).origin } : {}), - dataDirectory: resolve(parsed.DATA_DIRECTORY), - appSecret: parsed.APP_SECRET, + dataDirectory, + appSecret: resolveAppSecret(parsed.APP_SECRET, dataDirectory), sessionTtlMs: parsed.SESSION_TTL_HOURS * 60 * 60 * 1_000, trustProxy: parsed.TRUST_PROXY, publicRateLimitPerHour: parsed.PUBLIC_RATE_LIMIT_PER_HOUR, publicInstallationLimitPerHour: parsed.PUBLIC_INSTALLATION_LIMIT_PER_HOUR, - maxScreenshotBytes: parsed.MAX_SCREENSHOT_BYTES + maxScreenshotBytes: parsed.MAX_SCREENSHOT_BYTES, + ...(parsed.INITIAL_ADMIN_EMAIL && parsed.INITIAL_ADMIN_PASSWORD + ? { + initialAdmin: { + email: parsed.INITIAL_ADMIN_EMAIL, + displayName: + parsed.INITIAL_ADMIN_NAME ?? 'Administrator', + password: parsed.INITIAL_ADMIN_PASSWORD + } + } + : {}) } } diff --git a/src/server/environment.ts b/src/server/environment.ts new file mode 100644 index 0000000..4f8e3c0 --- /dev/null +++ b/src/server/environment.ts @@ -0,0 +1,20 @@ +import { existsSync } from 'node:fs' +import { resolve } from 'node:path' +import { loadEnvFile } from 'node:process' + +export function loadEnvironmentFile( + environmentFile = process.env['ENV_FILE'] +): string | undefined { + const configuredPath = environmentFile?.trim() + const filePath = resolve(configuredPath || '.env') + + if (!existsSync(filePath)) { + if (configuredPath) { + throw new Error(`Environment file does not exist: ${filePath}`) + } + return undefined + } + + loadEnvFile(filePath) + return filePath +} diff --git a/src/server/main.ts b/src/server/main.ts index 90c7c67..116e1fd 100644 --- a/src/server/main.ts +++ b/src/server/main.ts @@ -2,14 +2,25 @@ import { existsSync } from 'node:fs' import { mkdir } from 'node:fs/promises' import { join, resolve } from 'node:path' import { buildApp } from './app.js' +import { AuthService } from './auth-service.js' import { loadConfig } from './config.js' import { openDatabase } from './database.js' +import { loadEnvironmentFile } from './environment.js' process.umask(0o077) +loadEnvironmentFile() const config = loadConfig() await mkdir(config.dataDirectory, { recursive: true, mode: 0o700 }) const database = openDatabase(join(config.dataDirectory, 'product-issue.sqlite')) +if (config.initialAdmin) { + const auth = new AuthService( + database, + config.appSecret, + config.sessionTtlMs + ) + await auth.createInitialAdmin(config.initialAdmin) +} const adminDirectory = resolve('dist/admin') const serveAdmin = config.nodeEnv === 'production' || diff --git a/tests/auth-service.test.ts b/tests/auth-service.test.ts index 17cdb3b..b254f4c 100644 --- a/tests/auth-service.test.ts +++ b/tests/auth-service.test.ts @@ -20,6 +20,36 @@ afterEach(async () => { }) describe('AuthService', () => { + it('creates the initial administrator only when the database is empty', async () => { + const directory = await mkdtemp(join(tmpdir(), 'product-issue-auth-')) + directories.push(directory) + const database = openDatabase(join(directory, 'database.sqlite')) + databases.push(database) + const auth = new AuthService(database, 'secret', 60 * 60 * 1_000) + + await expect( + auth.createInitialAdmin({ + email: 'first@example.com', + displayName: 'First admin', + password: 'first secure password' + }) + ).resolves.toBe(true) + await expect( + auth.createInitialAdmin({ + email: 'second@example.com', + displayName: 'Second admin', + password: 'second secure password' + }) + ).resolves.toBe(false) + + await expect( + auth.login('first@example.com', 'first secure password') + ).resolves.toMatchObject({ displayName: 'First admin' }) + await expect( + auth.login('second@example.com', 'second secure password') + ).resolves.toBeUndefined() + }) + it('invalidates existing sessions when an administrator is reset', async () => { const directory = await mkdtemp(join(tmpdir(), 'product-issue-auth-')) directories.push(directory) diff --git a/tests/config.test.ts b/tests/config.test.ts index a3c3ea4..eb9b9d0 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -1,7 +1,17 @@ -import { describe, expect, it } from 'vitest' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' import { loadConfig } from '../src/server/config.js' const secret = 'test-secret-that-is-at-least-thirty-two-characters' +const directories: string[] = [] + +afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) describe('loadConfig', () => { it('normalizes a valid production origin', () => { @@ -40,11 +50,46 @@ describe('loadConfig', () => { ).toThrow() }) - it('rejects the example placeholder secret', () => { + it('accepts an explicitly configured secret without a length restriction', () => { + expect(loadConfig({ APP_SECRET: 'short' }).appSecret).toBe('short') + }) + + it('generates and reuses a secret when APP_SECRET is omitted', () => { + const directory = mkdtempSync(join(tmpdir(), 'product-issue-config-')) + directories.push(directory) + + const first = loadConfig({ DATA_DIRECTORY: directory }) + const second = loadConfig({ DATA_DIRECTORY: directory }) + + expect(first.appSecret).toHaveLength(43) + expect(second.appSecret).toBe(first.appSecret) + expect(readFileSync(join(directory, '.app-secret'), 'utf8').trim()).toBe( + first.appSecret + ) + }) + + it('parses initial administrator configuration', () => { + expect( + loadConfig({ + APP_SECRET: secret, + INITIAL_ADMIN_EMAIL: 'admin@example.com', + INITIAL_ADMIN_PASSWORD: 'a local admin password' + }).initialAdmin + ).toEqual({ + email: 'admin@example.com', + displayName: 'Administrator', + password: 'a local admin password' + }) + }) + + it('requires initial administrator email and password together', () => { expect(() => loadConfig({ - APP_SECRET: 'replace-with-at-least-32-random-characters' + APP_SECRET: secret, + INITIAL_ADMIN_EMAIL: 'admin@example.com' }) - ).toThrow() + ).toThrow( + 'INITIAL_ADMIN_EMAIL and INITIAL_ADMIN_PASSWORD must be configured together' + ) }) }) diff --git a/tests/environment.test.ts b/tests/environment.test.ts new file mode 100644 index 0000000..1e8549b --- /dev/null +++ b/tests/environment.test.ts @@ -0,0 +1,59 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { loadEnvironmentFile } from '../src/server/environment.js' + +const testVariable = 'PRODUCT_ISSUE_ENV_FILE_TEST' +const originalValue = process.env[testVariable] +const temporaryDirectories: string[] = [] + +afterEach(() => { + if (originalValue === undefined) { + delete process.env[testVariable] + } else { + process.env[testVariable] = originalValue + } + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +function environmentFile(value: string): string { + const directory = mkdtempSync(join(tmpdir(), 'product-issue-env-')) + temporaryDirectories.push(directory) + const filePath = join(directory, '.env') + writeFileSync(filePath, `${testVariable}=${value}\n`, { + encoding: 'utf8', + mode: 0o600 + }) + return filePath +} + +describe('loadEnvironmentFile', () => { + it('loads values from an explicit environment file', () => { + delete process.env[testVariable] + + const filePath = environmentFile('from-file') + + expect(loadEnvironmentFile(filePath)).toBe(filePath) + expect(process.env[testVariable]).toBe('from-file') + }) + + it('does not override values already provided by the process', () => { + process.env[testVariable] = 'from-process' + + loadEnvironmentFile(environmentFile('from-file')) + + expect(process.env[testVariable]).toBe('from-process') + }) + + it('fails when an explicitly configured file does not exist', () => { + const directory = mkdtempSync(join(tmpdir(), 'product-issue-env-')) + temporaryDirectories.push(directory) + + expect(() => loadEnvironmentFile(join(directory, '.env'))).toThrow( + 'Environment file does not exist' + ) + }) +})