import { ADDON_IDS } from './addons'; import { db } from './db/database'; import { mcpHandler } from './mcp'; import { trekOAuthProvider, trekClientsStore } from './mcp/oauthProvider'; import { ALL_SCOPES } from './mcp/scopes'; import { authenticate, verifyJwtAndLoadUser } from './middleware/auth'; import { enforceGlobalMfaPolicy } from './middleware/mfaPolicy'; import adminRoutes from './routes/admin'; import airportsRoutes from './routes/airports'; import assignmentsRoutes from './routes/assignments'; import atlasRoutes from './routes/atlas'; import authRoutes from './routes/auth'; import backupRoutes from './routes/backup'; import budgetRoutes from './routes/budget'; import categoriesRoutes from './routes/categories'; import collabRoutes from './routes/collab'; import dayNotesRoutes from './routes/dayNotes'; import daysRoutes, { accommodationsRouter as accommodationsRoutes } from './routes/days'; import filesRoutes from './routes/files'; import journeyRoutes from './routes/journey'; import journeyPublicRoutes from './routes/journeyPublic'; import mapsRoutes from './routes/maps'; import memoriesRoutes from './routes/memories/unified'; import notificationRoutes from './routes/notifications'; import { oauthPublicRouter, oauthApiRouter } from './routes/oauth'; import oidcRoutes from './routes/oidc'; import packingRoutes from './routes/packing'; import photoRoutes from './routes/photos'; import placesRoutes from './routes/places'; import publicConfigRoutes from './routes/publicConfig'; import reservationsRoutes from './routes/reservations'; import settingsRoutes from './routes/settings'; import shareRoutes from './routes/share'; import systemNoticesRoutes from './routes/systemNotices'; import tagsRoutes from './routes/tags'; import todoRoutes from './routes/todo'; import tripsRoutes from './routes/trips'; import vacayRoutes from './routes/vacay'; import { getCollabFeatures } from './services/adminService'; import { isAddonEnabled } from './services/adminService'; import { logDebug, logWarn, logError } from './services/auditLog'; import { getPhotoProviderConfig } from './services/memories/helpersService'; import { getMcpSafeUrl } from './services/notifications'; import { Addon } from './types'; import { authorizationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/authorize'; import { clientRegistrationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/register'; import { mcpAuthMetadataRouter } from '@modelcontextprotocol/sdk/server/auth/router'; import type { OAuthMetadata } from '@modelcontextprotocol/sdk/shared/auth'; import cookieParser from 'cookie-parser'; import cors from 'cors'; import express, { Request, Response, NextFunction } from 'express'; import helmet from 'helmet'; import multer from 'multer'; import fs from 'node:fs'; import path from 'node:path'; export function createApp(): express.Application { const app = express(); // Trust first proxy (nginx/Docker) for correct req.ip if (process.env.NODE_ENV?.toLowerCase() === 'production' || process.env.TRUST_PROXY) { app.set('trust proxy', Number.parseInt(process.env.TRUST_PROXY) || 1); } const allowedOrigins = process.env.ALLOWED_ORIGINS ? process.env.ALLOWED_ORIGINS.split(',') .map((o) => o.trim()) .filter(Boolean) : null; let corsOrigin: cors.CorsOptions['origin']; if (allowedOrigins) { corsOrigin = (origin: string | undefined, callback: (err: Error | null, allow?: boolean) => void) => { if (!origin || allowedOrigins.includes(origin)) callback(null, true); else callback(new Error('Not allowed by CORS')); }; } else if (process.env.NODE_ENV?.toLowerCase() === 'production') { corsOrigin = false; } else { corsOrigin = true; } const shouldForceHttps = process.env.FORCE_HTTPS?.toLowerCase() === 'true'; // HSTS is worth enabling any time we're serving production traffic, // not only when FORCE_HTTPS is set. Self-hosters behind Traefik / // Caddy / Cloudflare Tunnel typically leave FORCE_HTTPS unset (the // proxy handles the redirect for them), and the previous "HSTS off by // default" meant those instances never advertised HSTS at all. // // `includeSubDomains` stays OFF by default on purpose: an instance // running on an apex domain would otherwise force HTTPS on every // sibling subdomain the same operator may still be running over plain // HTTP. Operators who want the stricter policy opt in with // `HSTS_INCLUDE_SUBDOMAINS=true`. const hstsActive = shouldForceHttps || process.env.NODE_ENV === 'production'; const hstsIncludeSubdomains = process.env.HSTS_INCLUDE_SUBDOMAINS === 'true'; // RFC 8414 / RFC 9728 / RFC 7591: discovery docs and DCR are world-readable/writable. // /mcp needs open CORS so external MCP clients (ChatGPT, Claude.ai, Inspector) can call it // with Bearer tokens from any origin. /oauth/register and /oauth/authorize need it for // browser-based DCR/authorization preflights — the global cors({ origin: false }) would // answer OPTIONS without Access-Control-Allow-Origin before the SDK's own cors() runs. // All /.well-known/* paths get open CORS so clients probing openid-configuration or the // RFC 8414 path-suffixed AS metadata form don't get CORS-blocked (they get 404 JSON instead). app.use((req: Request, _res: Response, next: NextFunction) => { if ( req.path.startsWith('/.well-known/') || req.path === '/oauth/register' || req.path === '/oauth/authorize' || req.path === '/oauth/userinfo' || req.path === '/mcp' ) { cors({ origin: '*', credentials: false })(req, _res, next); } else { next(); } }); app.use(cors({ origin: corsOrigin, credentials: true })); app.use( helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'", "'wasm-unsafe-eval'", "'unsafe-eval'"], styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com', 'https://unpkg.com'], imgSrc: ["'self'", 'data:', 'blob:', 'https:'], connectSrc: [ "'self'", 'ws:', 'wss:', 'https://nominatim.openstreetmap.org', 'https://overpass-api.de', 'https://places.googleapis.com', 'https://api.openweathermap.org', 'https://en.wikipedia.org', 'https://commons.wikimedia.org', 'https://*.basemaps.cartocdn.com', 'https://*.tile.openstreetmap.org', 'https://unpkg.com', 'https://open-meteo.com', 'https://api.open-meteo.com', 'https://geocoding-api.open-meteo.com', 'https://api.exchangerate-api.com', 'https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_countries.geojson', 'https://router.project-osrm.org/route/v1/', 'https://api.mapbox.com', 'https://*.tiles.mapbox.com', 'https://events.mapbox.com', ], workerSrc: ["'self'", 'blob:'], childSrc: ["'self'", 'blob:'], fontSrc: ["'self'", 'https://fonts.gstatic.com', 'data:'], objectSrc: ["'none'"], frameSrc: ["'none'"], frameAncestors: ["'self'"], upgradeInsecureRequests: shouldForceHttps ? [] : null, }, }, crossOriginEmbedderPolicy: false, hsts: hstsActive ? { maxAge: 31536000, includeSubDomains: hstsIncludeSubdomains } : false, referrerPolicy: { policy: 'strict-origin-when-cross-origin' }, }), ); if (shouldForceHttps) { app.use((req: Request, res: Response, next: NextFunction) => { if (req.path === '/api/health') return next(); if (req.secure || req.headers['x-forwarded-proto'] === 'https') return next(); res.redirect(301, 'https://' + req.headers.host + req.url); }); } app.use(express.json({ limit: '100kb' })); app.use(express.urlencoded({ extended: true })); app.use(cookieParser()); app.use(enforceGlobalMfaPolicy); // Request logging with sensitive field redaction { const SENSITIVE_KEYS = new Set([ 'password', 'new_password', 'current_password', 'token', 'jwt', 'authorization', 'cookie', 'client_secret', 'mfa_token', 'code', 'smtp_pass', ]); const redact = (value: unknown): unknown => { if (!value || typeof value !== 'object') return value; if (Array.isArray(value)) return (value as unknown[]).map(redact); const out: Record = {}; for (const [k, v] of Object.entries(value as Record)) { out[k] = SENSITIVE_KEYS.has(k.toLowerCase()) ? '[REDACTED]' : redact(v); } return out; }; app.use((req: Request, res: Response, next: NextFunction) => { if (req.path === '/api/health') return next(); const startedAt = Date.now(); res.on('finish', () => { const ms = Date.now() - startedAt; if (res.statusCode >= 500) { logError(`${req.method} ${req.path} ${res.statusCode} ${ms}ms ip=${req.ip}`); } else if (res.statusCode === 401 || res.statusCode === 403) { logDebug(`${req.method} ${req.path} ${res.statusCode} ${ms}ms ip=${req.ip}`); } else if (res.statusCode >= 400) { logWarn(`${req.method} ${req.path} ${res.statusCode} ${ms}ms ip=${req.ip}`); } const q = Object.keys(req.query).length ? ` query=${JSON.stringify(redact(req.query))}` : ''; const b = req.body && Object.keys(req.body).length ? ` body=${JSON.stringify(redact(req.body))}` : ''; logDebug(`${req.method} ${req.path} ${res.statusCode} ${ms}ms ip=${req.ip}${q}${b}`); }); next(); }); } // Static: avatars, covers, and journey photos. // // Security model (audit SEC-M9): these paths are unauthenticated by // design. All filenames are server-chosen UUID v4 (see `uuid()` in // the multer storage config for avatars / covers / journey uploads), // which gives each asset >122 bits of namespace entropy — not // guessable via enumeration. An attacker would need to have already // seen the URL (email, shared journey, etc.) to request the file. // // Moving these behind auth would also break: // - Unauthenticated trip-card rendering on public share links // - Journey public-share pages (/public/journey/:token) // - Email-embedded avatars // // The `/uploads/photos/...` route below is DIFFERENT: photo URLs are // not embedded in unauthenticated UI contexts, so that endpoint IS // gated (session JWT with pv, or a share token scoped to the photo's // trip). app.use('/uploads/avatars', express.static(path.join(__dirname, '../uploads/avatars'))); app.use('/uploads/covers', express.static(path.join(__dirname, '../uploads/covers'))); app.use('/uploads/journey', express.static(path.join(__dirname, '../uploads/journey'))); // Photos require either a valid logged-in session (via JWT with the // password_version gate) OR a share token that covers the SPECIFIC // photo's trip. Previously any share token for any trip could request // any photo filename by UUID — fine in practice because UUIDs are // unguessable, but the auth model was wrong. app.get('/uploads/photos/:filename', (req: Request, res: Response) => { const safeName = path.basename(req.params.filename); const filePath = path.join(__dirname, '../uploads/photos', safeName); const resolved = path.resolve(filePath); if (!resolved.startsWith(path.resolve(__dirname, '../uploads/photos'))) { return res.status(403).send('Forbidden'); } // existsSync here is cheap and avoids a sendFile error frame; kept // sync because the handler is already short-lived. if (!fs.existsSync(resolved)) return res.status(404).send('Not found'); const authHeader = req.headers.authorization; const rawToken = (req.query.token as string) || (authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null); if (!rawToken) return res.status(401).send('Authentication required'); // JWT session path (with pv check). const user = verifyJwtAndLoadUser(rawToken); if (user) return res.sendFile(resolved); // Share-token path: require the token to cover the exact trip the // photo belongs to. Expired tokens fall through to 401. const photo = db.prepare('SELECT trip_id FROM photos WHERE filename = ?').get(safeName) as | { trip_id: number } | undefined; if (!photo) return res.status(401).send('Authentication required'); const share = db .prepare( "SELECT trip_id FROM share_tokens WHERE token = ? AND (expires_at IS NULL OR expires_at > datetime('now'))", ) .get(rawToken) as { trip_id: number } | undefined; if (!share || share.trip_id !== photo.trip_id) { return res.status(401).send('Authentication required'); } res.sendFile(resolved); }); // Block direct access to /uploads/files app.use('/uploads/files', (_req: Request, res: Response) => { res.status(401).send('Authentication required'); }); // API Routes app.use('/api/auth', authRoutes); app.use('/api/auth/oidc', oidcRoutes); app.use('/api/trips', tripsRoutes); app.use('/api/trips/:tripId/days', daysRoutes); app.use('/api/trips/:tripId/accommodations', accommodationsRoutes); app.use('/api/trips/:tripId/places', placesRoutes); app.use('/api/trips/:tripId/packing', packingRoutes); app.use('/api/trips/:tripId/todo', todoRoutes); app.use('/api/trips/:tripId/files', filesRoutes); app.use('/api/trips/:tripId/budget', budgetRoutes); app.use('/api/trips/:tripId/collab', collabRoutes); app.use('/api/trips/:tripId/reservations', reservationsRoutes); app.use('/api/trips/:tripId/days/:dayId/notes', dayNotesRoutes); app.get('/api/health', (_req: Request, res: Response) => { res.setHeader('Cache-Control', 'no-store, must-revalidate'); res.json({ status: 'ok' }); }); app.use('/api/config', publicConfigRoutes); app.use('/api', assignmentsRoutes); app.use('/api/tags', tagsRoutes); app.use('/api/categories', categoriesRoutes); app.use('/api/admin', adminRoutes); // Addons list endpoint app.get('/api/addons', authenticate, (_req: Request, res: Response) => { const addons = db .prepare('SELECT id, name, type, icon, enabled FROM addons WHERE enabled = 1 ORDER BY sort_order') .all() as Pick[]; const providers = db .prepare( ` SELECT id, name, icon, enabled, sort_order FROM photo_providers WHERE enabled = 1 ORDER BY sort_order, id `, ) .all() as Array<{ id: string; name: string; icon: string; enabled: number; sort_order: number }>; const fields = db .prepare( ` SELECT provider_id, field_key, label, input_type, placeholder, hint, required, secret, settings_key, payload_key, sort_order FROM photo_provider_fields ORDER BY sort_order, id `, ) .all() as Array<{ provider_id: string; field_key: string; label: string; input_type: string; placeholder?: string | null; hint?: string | null; required: number; secret: number; settings_key?: string | null; payload_key?: string | null; sort_order: number; }>; const fieldsByProvider = new Map(); for (const field of fields) { const arr = fieldsByProvider.get(field.provider_id) || []; arr.push(field); fieldsByProvider.set(field.provider_id, arr); } res.json({ collabFeatures: getCollabFeatures(), addons: [ ...addons.map((a) => ({ ...a, enabled: !!a.enabled })), ...providers.map((p) => ({ id: p.id, name: p.name, type: 'photo_provider', icon: p.icon, enabled: !!p.enabled, config: getPhotoProviderConfig(p.id), fields: (fieldsByProvider.get(p.id) || []).map((f) => ({ key: f.field_key, label: f.label, input_type: f.input_type, placeholder: f.placeholder || '', hint: f.hint || null, required: !!f.required, secret: !!f.secret, settings_key: f.settings_key || null, payload_key: f.payload_key || null, sort_order: f.sort_order, })), })), ], }); }); // Addon routes app.use('/api/addons/vacay', vacayRoutes); app.use('/api/addons/atlas', atlasRoutes); app.use( '/api/journeys', (req, res, next) => { if (!isAddonEnabled(ADDON_IDS.JOURNEY)) return res.status(404).json({ error: 'Journey addon is not enabled' }); next(); }, journeyRoutes, ); app.use('/api/public/journey', journeyPublicRoutes); app.use('/api/integrations/memories', memoriesRoutes); app.use('/api/photos', photoRoutes); app.use('/api/maps', mapsRoutes); app.use('/api/airports', airportsRoutes); // /api/weather is served by the NestJS weather module (see src/nest/weather); // the legacy Express route was decommissioned after the migration (L1). app.use('/api/settings', settingsRoutes); app.use('/api/system-notices', systemNoticesRoutes); app.use('/api/backup', backupRoutes); app.use('/api/notifications', notificationRoutes); app.use('/api', shareRoutes); // OAuth 2.1 — public endpoints // Gate: 404 when MCP addon is disabled (M2 — prevents feature fingerprinting) const mcpAddonGate = (_req: Request, res: Response, next: NextFunction) => { if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end(); next(); }; // OAuth 2.1 — SPA-facing authenticated endpoints (/api/oauth/*) // Mounted first: per-route 403 checks inside oauthApiRouter are the gate, not mcpAddonGate app.use('/api/oauth', oauthApiRouter); // SDK metadata router — built lazily on first request so getAppUrl() (which queries the DB) // is not called at createApp() time, before test tables have been created. // mcpAuthMetadataRouter serves: // /.well-known/oauth-authorization-server — RFC 8414 AS metadata // /.well-known/oauth-protected-resource/mcp — RFC 9728 path-based PRM (fixes issue #959 bug 1) let _oauthMetadata: OAuthMetadata | null = null; let _sdkMetaRouter: express.Router | null = null; function getOAuthMetadata(): OAuthMetadata { if (_oauthMetadata) return _oauthMetadata; const base = getMcpSafeUrl().replace(/\/+$/, ''); _oauthMetadata = { issuer: base, authorization_endpoint: `${base}/oauth/authorize`, token_endpoint: `${base}/oauth/token`, revocation_endpoint: `${base}/oauth/revoke`, registration_endpoint: `${base}/oauth/register`, response_types_supported: ['code'], grant_types_supported: ['authorization_code', 'refresh_token', 'client_credentials'], code_challenge_methods_supported: ['S256'], token_endpoint_auth_methods_supported: ['client_secret_post', 'none'], scopes_supported: ALL_SCOPES, }; return _oauthMetadata; } function getMetaRouter(): express.Router { if (_sdkMetaRouter) return _sdkMetaRouter; const metadata = getOAuthMetadata(); _sdkMetaRouter = mcpAuthMetadataRouter({ oauthMetadata: metadata, resourceServerUrl: new URL(`${metadata.issuer}/mcp`), scopesSupported: ALL_SCOPES as string[], resourceName: 'TREK MCP', }); return _sdkMetaRouter; } // Only invoke the SDK metadata router for /.well-known/* paths. // Calling getMetaRouter() on every request triggers lazy init (new URL(...)) which // throws "Invalid URL" when APP_URL lacks a protocol — breaking all page loads. app.use((req: Request, res: Response, next: NextFunction) => { if (req.path.startsWith('/.well-known/') && !isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end(); getMetaRouter()(req, res, next); }); // ChatGPT (and other OIDC-first clients) bootstrap OAuth discovery via // /.well-known/openid-configuration. Serve the AS metadata plus the OIDC // userinfo_endpoint so ChatGPT can fetch the authenticated user's email // for authorization domain claiming. app.get('/.well-known/openid-configuration', (_req: Request, res: Response) => { const meta = getOAuthMetadata(); res.json({ ...meta, userinfo_endpoint: `${meta.issuer}/oauth/userinfo`, }); }); // RFC 9728 flat well-known URL — served alongside the path-based form the SDK already provides. // Clients like ChatGPT probe /.well-known/oauth-protected-resource (no path suffix) on every // fresh discovery. Without this, they get 404, fall back to the issuer URL as the resource // parameter, and the authorize handler rejects them with invalid_target — showing the user // the TREK home page instead of the consent form. app.get('/.well-known/oauth-protected-resource', (_req: Request, res: Response) => { if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end(); const meta = getOAuthMetadata(); res.json({ resource: `${meta.issuer}/mcp`, authorization_servers: [meta.issuer], bearer_methods_supported: ['header'], scopes_supported: ALL_SCOPES, resource_name: 'TREK MCP', }); }); // SDK authorize handler: validates OAuth params, calls provider.authorize() which redirects // to the SPA consent page at /oauth/consent app.use('/oauth/authorize', mcpAddonGate, authorizationHandler({ provider: trekOAuthProvider })); // SDK DCR handler: accepts registrations without scope (fixes issue #959 bug 2) app.use('/oauth/register', mcpAddonGate, clientRegistrationHandler({ clientsStore: trekClientsStore })); // Token and revoke keep TREK's own handlers (timing-safe hash comparison not supported by SDK clientAuth) // oauthPublicRouter has per-route isAddonEnabled checks; no blanket gate needed here app.use('/', oauthPublicRouter); // MCP endpoint app.post('/mcp', mcpHandler); app.get('/mcp', mcpHandler); app.delete('/mcp', mcpHandler); // Return 404 JSON for any /.well-known/* path the SDK metadata router doesn't handle. // Without this, the SPA catch-all serves HTML — clients probing // /.well-known/openid-configuration or the RFC 8414 path-suffixed AS metadata URL // receive a 200 HTML response they can't parse as JSON, causing "does not implement OAuth". app.use((req: Request, res: Response, next: NextFunction) => { if (req.path.startsWith('/.well-known/')) return res.status(404).json({ error: 'not_found' }); next(); }); // Helmet's COOP: same-origin isolates the consent popup from its cross-origin opener (ChatGPT etc.), making window.opener null and breaking the OAuth flow. app.use('/oauth/consent', (_req: Request, res: Response, next: NextFunction) => { res.setHeader('Cross-Origin-Opener-Policy', 'unsafe-none'); next(); }); // Production static file serving if (process.env.NODE_ENV === 'production') { const publicPath = path.join(__dirname, '../public'); app.use( express.static(publicPath, { setHeaders: (res, filePath) => { if (filePath.endsWith('index.html')) { res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); } }, }), ); app.get('*', (_req: Request, res: Response) => { res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); res.sendFile(path.join(publicPath, 'index.html')); }); } // Global error handler app.use( (err: Error & { status?: number; statusCode?: number }, _req: Request, res: Response, _next: NextFunction) => { if (process.env.NODE_ENV === 'production') { console.error('Unhandled error:', err.message); } else { console.error('Unhandled error:', err); } if (err instanceof multer.MulterError) { const status = err.code === 'LIMIT_FILE_SIZE' ? 413 : 400; return res.status(status).json({ error: err.message }); } const status = err.statusCode || err.status || 500; // Expose the message for client errors (4xx); keep 'Internal server error' for 5xx. const message = status < 500 ? err.message : 'Internal server error'; res.status(status).json({ error: message }); }, ); return app; }