import express, { Request, Response, NextFunction } from 'express'; import path from 'node:path'; import fs from 'node:fs'; import { verifyJwtAndLoadUser } from '../../middleware/auth'; import { db } from '../../db/database'; import { mcpHandler } from '../../mcp'; import { trekOAuthProvider, trekClientsStore } from '../../mcp/oauthProvider'; import { isAddonEnabled } from '../../services/adminService'; import { ADDON_IDS } from '../../addons'; import { ALL_SCOPES } from '../../mcp/scopes'; import { mcpAuthMetadataRouter } from '@modelcontextprotocol/sdk/server/auth/router'; import { authorizationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/authorize'; import { clientRegistrationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/register'; import type { OAuthMetadata } from '@modelcontextprotocol/sdk/shared/auth'; import { getMcpSafeUrl } from '../../services/notifications'; // Platform / transport routes extracted verbatim from createApp() (app.ts) so they can be // mounted on either the legacy Express app or the NestJS Express instance (strangler A6/A8). // // IMPORTANT — path resolution: the original blocks lived in src/app.ts, where __dirname // resolves to the directory of app.js (one level above the uploads/public anchor), so they // used '../uploads/...' and '../public'. This file lives three levels deeper // (src/nest/platform/), so __dirname is three levels deeper too. The relative prefixes are // therefore '../../../uploads/...' and '../../../public' — which resolve to the EXACT same // absolute paths as before. This is the only intentional change; everything else is byte-for-byte // identical. (rootDir/outDir preserve the tree, so the offset holds in both source/test and // compiled/dist execution — matching the other nest controllers that use '../../../uploads/...'.) const UPLOADS_DIR = path.join(__dirname, '../../../uploads'); export const PUBLIC_DIR = path.join(__dirname, '../../../public'); /** * Static + guarded /uploads/* routes. Must be applied BEFORE the API route mounts * (identical to its original position near the top of createApp). */ export function applyPlatformUploads(app: express.Application): void { // 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(UPLOADS_DIR, 'avatars'))); app.use('/uploads/covers', express.static(path.join(UPLOADS_DIR, 'covers'))); app.use('/uploads/journey', express.static(path.join(UPLOADS_DIR, '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(UPLOADS_DIR, 'photos', safeName); const resolved = path.resolve(filePath); if (!resolved.startsWith(path.resolve(UPLOADS_DIR, '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'); }); } /** * Legacy /api/health handler, the OAuth/MCP SDK + transport wiring (well-known metadata, * authorize/register SDK handlers, the COOP header, the /mcp routes), and the production * SPA static + catch-all. Must be applied AFTER the API route mounts and BEFORE the global * error handler (identical to its original position near the bottom of createApp). * * Note: the SDK metadata closures (getOAuthMetadata/getMetaRouter) and their lazy-init * cache are kept module-local PER CALL so each app instance gets its own lazy state — the * same as when they were function-local inside createApp. */ export function applyPlatformTransport(app: express.Application): void { app.get('/health', (_req: Request, res: Response) => res.status(200).send('OK')); app.get('/api/health', (_req: Request, res: Response) => { res.setHeader('Cache-Control', 'no-store, must-revalidate') res.json({ status: 'ok' }) }); // 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(); }; // 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 })); // 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 SPA serving: the built client static assets + the index.html catch-all * for client-side routes. This is the LEGACY (plain Express 4) form — a real * `app.get(catch-all)` registered as the terminal handler. The NestJS bootstrap can * NOT use this (its router terminates unmatched requests with a 404 before any * post-init route runs, and Express 5's path-to-regexp rejects a bare '*'); it serves * the SPA via the SpaFallbackFilter instead. Both produce the identical result: * unmatched GET → index.html in production. */ export function applyPlatformSpa(app: express.Application): void { applyPlatformStatic(app); if (process.env.NODE_ENV !== 'production') return; // /.*/ rather than '*' so the helper is Express-4 and Express-5 safe. app.get(/.*/, (_req: Request, res: Response) => { res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); res.sendFile(path.join(PUBLIC_DIR, 'index.html')); }); } /** * Production static serving of the built client (JS/CSS/assets). Split out from * applyPlatformSpa because the NestJS bootstrap needs the static files served * BEFORE its router (so a real asset request returns the file, not the SPA * index.html), while the index.html catch-all is handled separately (legacy: * app.get catch-all; Nest: SpaFallbackFilter). No-op outside production. */ export function applyPlatformStatic(app: express.Application): void { if (process.env.NODE_ENV !== 'production') return; app.use( express.static(PUBLIC_DIR, { setHeaders: (res, filePath) => { if (filePath.endsWith('index.html')) { res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); } }, }), ); }