diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..2820dd2 --- /dev/null +++ b/src/config.js @@ -0,0 +1,26 @@ +import { MINUTE } from './utils/time.js'; + +// Getters keep reads lazy, so each setting is resolved from process.env at access time. +export const config = { + get authSecret() { + return process.env.AUTH_SECRET ?? 'dev-secret'; + }, + get corsOrigin() { + return process.env.CORS_ORIGIN ?? '*'; + }, + get cacheTtlMs() { + return Number(process.env.CACHE_TTL_MS ?? 5 * MINUTE); + }, + get smtpHost() { + return process.env.SMTP_HOST ?? 'localhost'; + }, + get smtpPort() { + return Number(process.env.SMTP_PORT ?? 25); + }, + get port() { + return Number(process.env.PORT ?? 3000); + }, + get logLevel() { + return process.env.LOG_LEVEL ?? 'info'; + }, +}; diff --git a/src/middleware/auth.js b/src/middleware/auth.js index 4e939a7..c60c0b5 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -1,10 +1,9 @@ import { createHmac, timingSafeEqual } from 'node:crypto'; import { getUser } from '../services/users.js'; - -const { AUTH_SECRET } = process.env; +import { config } from '../config.js'; export function sign(userId) { - const mac = createHmac('sha256', AUTH_SECRET ?? 'dev-secret').update(userId).digest('hex'); + const mac = createHmac('sha256', config.authSecret).update(userId).digest('hex'); return `${userId}.${mac}`; } diff --git a/src/middleware/cors.js b/src/middleware/cors.js index e3e6a9d..02066a4 100644 --- a/src/middleware/cors.js +++ b/src/middleware/cors.js @@ -1,5 +1,7 @@ +import { config } from '../config.js'; + export function corsHeaders(origin) { - const allowed = process.env.CORS_ORIGIN ?? '*'; + const allowed = config.corsOrigin; if (allowed !== '*' && origin !== allowed) return {}; return { 'access-control-allow-origin': allowed === '*' ? '*' : origin, diff --git a/src/routes/version.js b/src/routes/version.js new file mode 100644 index 0000000..ac2f392 --- /dev/null +++ b/src/routes/version.js @@ -0,0 +1,7 @@ +import { readFileSync } from 'node:fs'; + +const { version } = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')); + +export const versionRoutes = { + 'GET /version': () => ({ status: 200, body: { version } }), +}; diff --git a/src/server.js b/src/server.js index 3560d4b..53d7f07 100644 --- a/src/server.js +++ b/src/server.js @@ -4,13 +4,15 @@ import { tagsRoutes } from './routes/tags.js'; import { searchRoutes } from './routes/search.js'; import { adminRoutes } from './routes/admin.js'; import { healthRoutes } from './routes/health.js'; +import { versionRoutes } from './routes/version.js'; import { requireUser } from './middleware/auth.js'; import { corsHeaders } from './middleware/cors.js'; import { handleErrors } from './middleware/errors.js'; +import { config } from './config.js'; -export const port = Number(process.env.PORT ?? 3000); +export const port = config.port; const PUBLIC = new Set(['GET /health', 'POST /signup']); -const routes = { ...notesRoutes, ...usersRoutes, ...tagsRoutes, ...searchRoutes, ...adminRoutes, ...healthRoutes }; +const routes = { ...notesRoutes, ...usersRoutes, ...tagsRoutes, ...searchRoutes, ...adminRoutes, ...healthRoutes, ...versionRoutes }; function match(method, path) { for (const [key, handler] of Object.entries(routes)) { diff --git a/src/services/cache.js b/src/services/cache.js index 7ab41c6..26927dc 100644 --- a/src/services/cache.js +++ b/src/services/cache.js @@ -1,7 +1,7 @@ -import { isExpired, MINUTE } from '../utils/time.js'; +import { isExpired } from '../utils/time.js'; +import { config } from '../config.js'; -const env = process.env; -const ttl = Number(env.CACHE_TTL_MS ?? 5 * MINUTE); +const ttl = config.cacheTtlMs; const entries = new Map(); export function cached(key, compute, now = Date.now()) { diff --git a/src/services/mailer.js b/src/services/mailer.js index fe75798..c039884 100644 --- a/src/services/mailer.js +++ b/src/services/mailer.js @@ -1,11 +1,12 @@ import { log } from '../utils/log.js'; +import { config } from '../config.js'; const outbox = []; function transport() { return { - host: process.env.SMTP_HOST ?? 'localhost', - port: Number(process.env['SMTP_PORT'] ?? 25), + host: config.smtpHost, + port: config.smtpPort, }; } diff --git a/src/utils/log.js b/src/utils/log.js index be6ce13..d42efe9 100644 --- a/src/utils/log.js +++ b/src/utils/log.js @@ -1,5 +1,7 @@ +import { config } from '../config.js'; + const LEVELS = ['debug', 'info', 'warn', 'error']; -const threshold = LEVELS.indexOf(process.env.LOG_LEVEL ?? 'info'); +const threshold = LEVELS.indexOf(config.logLevel); const lines = []; function write(level, message, fields) { diff --git a/tests/api.test.js b/tests/api.test.js index 32d499e..acab278 100644 --- a/tests/api.test.js +++ b/tests/api.test.js @@ -1,5 +1,6 @@ import { test, beforeEach } from 'node:test'; import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; import { seed } from '../src/db/seed.js'; import { handle } from '../src/server.js'; import { sign } from '../src/middleware/auth.js'; @@ -11,6 +12,13 @@ test('health is public', () => { assert.equal(handle({ method: 'GET', path: '/health' }).status, 200); }); +test('version comes from package.json', () => { + const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')); + const res = as('u1', { method: 'GET', path: '/version' }); + assert.equal(res.status, 200); + assert.deepEqual(res.body, { version }); +}); + test('notes need a valid token', () => { assert.equal(handle({ method: 'GET', path: '/notes', headers: {} }).status, 401); });