diff --git a/apps/backend/server.js b/apps/backend/server.js index c121271..4c5fb33 100644 --- a/apps/backend/server.js +++ b/apps/backend/server.js @@ -9,6 +9,7 @@ import axios from 'axios'; import redisClient from './redis-client.js'; import * as appMetrics from './src/metrics.js'; import cors from 'cors'; +import helmet from 'helmet'; import sqlite3 from 'sqlite3'; import { promisify } from 'util'; import path from 'path'; @@ -39,6 +40,23 @@ const __dirname = path.dirname(__filename); const app = express(); const PORT = process.env.PORT || 3001; +// Security headers via helmet. Strict-Transport-Security is only sent when +// behind HTTPS (NODE_ENV=production). CSP is permissive enough for an API +// that serves JSON + serves the frontend from a different origin. +app.use(helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'"], + styleSrc: ["'self'", "'unsafe-inline'"], + imgSrc: ["'self'", 'data:', 'https:'], + connectSrc: ["'self'"], + frameAncestors: ["'none'"], + }, + }, + crossOriginResourcePolicy: { policy: 'cross-origin' }, +})); + // Structured JSON logger. The Pino OTel instrumentation attaches trace_id / // span_id to every log line so they can be correlated in Grafana. const logger = pino({ @@ -632,6 +650,50 @@ if (!pgPool) { `); await dbRun(`CREATE INDEX IF NOT EXISTS idx_pwd_reset_token ON password_reset_tokens(token)`); } + // ========== USER CONSENTS ========== + if (pgPool) { + await pgPool.query(` + CREATE TABLE IF NOT EXISTS user_consents ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + session_id VARCHAR(255), + category VARCHAR(20) NOT NULL CHECK (category IN ('essential', 'analytics', 'preferences', 'health_data')), + granted BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + `); + await pgPool.query(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_user_consents_user_cat + ON user_consents(user_id, category) WHERE user_id IS NOT NULL + `); + await pgPool.query(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_user_consents_sess_cat + ON user_consents(session_id, category) WHERE session_id IS NOT NULL + `); + await pgPool.query(`CREATE INDEX IF NOT EXISTS idx_user_consents_user_id ON user_consents(user_id) WHERE user_id IS NOT NULL`); + await pgPool.query(`CREATE INDEX IF NOT EXISTS idx_user_consents_session_id ON user_consents(session_id) WHERE session_id IS NOT NULL`); + } + if (!pgPool) { + await dbRun(` + CREATE TABLE IF NOT EXISTS user_consents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + session_id TEXT, + category TEXT NOT NULL CHECK (category IN ('essential', 'analytics', 'preferences', 'health_data')), + granted INTEGER NOT NULL DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + `); + try { + await dbRun(`CREATE UNIQUE INDEX IF NOT EXISTS idx_user_consents_user_cat ON user_consents(user_id, category) WHERE user_id IS NOT NULL`); + } catch (e) { if (!/duplicate/i.test(e.message)) throw e; } + try { + await dbRun(`CREATE UNIQUE INDEX IF NOT EXISTS idx_user_consents_sess_cat ON user_consents(session_id, category) WHERE session_id IS NOT NULL`); + } catch (e) { if (!/duplicate/i.test(e.message)) throw e; } + } } catch (err) { console.error('initDatabase failed:', err); throw err;