File size: 13,468 Bytes
57a889c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f2b20ec
57a889c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
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');
        }
      },
    }),
  );
}